diff --git a/app/api/locations/locations.apriori-location.delete.spec.js b/app/api/locations/locations.apriori-location.delete.spec.js new file mode 100644 index 00000000..b5823c4e --- /dev/null +++ b/app/api/locations/locations.apriori-location.delete.spec.js @@ -0,0 +1,257 @@ +const { expectNoSideEffects, expectSideEffects, loadInitialState } = require('../../../spec/expectations/side-effects'); +const { createOwner } = require('../../../spec/fixtures/owners'); +const { createCollection } = require('../../../spec/fixtures/collections'); +const { createImage } = require('../../../spec/fixtures/images'); +const { createAPrioriLocation } = require('../../../spec/fixtures/apriori_locations'); +const { createUser, generateJwtFor } = require('../../../spec/fixtures/users'); +const { typeError } = require('../../../spec/expectations/errors'); +const { freeze, testHttpRequest } = require('../../../spec/utils/api'); +const { resetDatabase } = require('../../../spec/utils/db'); +const { setUpGlobalHooks } = require('../../../spec/utils/hooks'); +const { expect } = require('../../../spec/utils/chai'); +const { createApplicationWithMocks } = require('../../../spec/utils/mocks'); +const { ensureTranslation } = require('../../../spec/utils/i18n'); + +// This should be in every integration test file. +setUpGlobalHooks(); + +describe('DELETE /locations/apriori_locations/:aprioriLocationId', () => { + let app; + + beforeEach(async () => { + await resetDatabase(); + ({ app } = createApplicationWithMocks()); + }); + + it('does not accept invalid path parameters', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ] }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'DELETE', + path: '/locations/apriori_locations/foo', + headers: { + Authorization: `Bearer ${token}` + } + }; + + expect(req).to.matchRequestDocumentation({ invalidParameters: [ 'aprioriLocationId' ] }); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(400) + .and.to.have.requestParametersValidationErrors([ + typeError({ location: 'path', property: 'aprioriLocationId', type: 'integer' }), + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a guest to delete an apriori location', async () => { + const initialState = await loadInitialState(); + + const req = { + method: 'DELETE', + path: '/locations/apriori_locations/1' + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(401) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/401', + title: 'Unauthorized', + status: 401, + detail: ensureTranslation('auth.error.generalServerAuth') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a volunteer to delete an apriori location', async () => { + const volunteer = await createUser({ roles: [ 'volunteer' ] }); + const token = await generateJwtFor(volunteer); + const initialState = await loadInitialState(); + + const req = { + method: 'DELETE', + path: '/locations/apriori_locations/1', + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + describe('with default fixtures', () => { + + let owner1, owner2; + let col1; + let image1; + let aprioriLocation1; + let baseRequest; + + beforeEach(async () => { + owner1 = await createOwner(); + owner2 = await createOwner(); + col1 = await createCollection({ owner: owner1 }); + + image1 = await createImage({ collection: col1, state: 'initial' }); + aprioriLocation1 = await createAPrioriLocation({ + image_id: image1.id, longitude: 7.44, latitude: 46.95, azimuth: 12.5, exact: false + }); + + baseRequest = freeze({ + method: 'DELETE', + path: `/locations/apriori_locations/${aprioriLocation1.id}` + }); + }); + + it('does not authorize an owner administrator from another owner', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner2 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize access to an inexistant apriori location', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'DELETE', + path: '/locations/apriori_locations/100', + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + [ 'owner_admin', 'owner_validator' ].forEach(role => { + it(`deletes the apriori location for an ${role} of the same owner`, async () => { + const user = await createUser({ roles: [ role ], owner: owner1 }); + const token = await generateJwtFor(user); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody({ + message: "The apriori location was deleted." + }) + .and.to.matchResponseDocumentation(); + + await expectSideEffects(app, { + initialDatabaseCounts: initialState.initialDatabaseCounts, + databaseChanges: { apriori_locations: -1 } + }); + + const getReq = { + method: 'GET', + path: `/locations/images/${image1.id}/apriori_locations`, + headers: { + Authorization: `Bearer ${token}` + } + }; + const getRes = await testHttpRequest(app, getReq); + expect(getRes) + .to.have.status(200) + .and.to.have.jsonBody([]); + }); + }); + + it('deletes any apriori location for a super administrator', async () => { + const superAdmin = await createUser({ roles: [ 'super_admin' ] }); + const token = await generateJwtFor(superAdmin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody({ + message: "The apriori location was deleted." + }) + .and.to.matchResponseDocumentation(); + + await expectSideEffects(app, { + initialDatabaseCounts: initialState.initialDatabaseCounts, + databaseChanges: { apriori_locations: -1 } + }); + }); + }); +}); diff --git a/app/api/locations/locations.apriori-locations.get-list.spec.js b/app/api/locations/locations.apriori-locations.get-list.spec.js new file mode 100644 index 00000000..7fe4e234 --- /dev/null +++ b/app/api/locations/locations.apriori-locations.get-list.spec.js @@ -0,0 +1,882 @@ +const { expectNoSideEffects, loadInitialState } = require('../../../spec/expectations/side-effects'); +const { createOwner } = require('../../../spec/fixtures/owners'); +const { createCollection } = require('../../../spec/fixtures/collections'); +const { createImage } = require('../../../spec/fixtures/images'); +const { createAPrioriLocation } = require('../../../spec/fixtures/apriori_locations'); +const { createUser, generateJwtFor } = require('../../../spec/fixtures/users'); +const { typeError } = require('../../../spec/expectations/errors'); +const { freeze, testHttpRequest } = require('../../../spec/utils/api'); +const { resetDatabase } = require('../../../spec/utils/db'); +const { setUpGlobalHooks } = require('../../../spec/utils/hooks'); +const { expect } = require('../../../spec/utils/chai'); +const { createApplicationWithMocks } = require('../../../spec/utils/mocks'); +const { ensureTranslation } = require('../../../spec/utils/i18n'); + +// This should be in every integration test file. +setUpGlobalHooks(); + +describe('GET /locations/:collectionId/apriori_locations', () => { + let app; + + beforeEach(async () => { + await resetDatabase(); + ({ app } = createApplicationWithMocks()); + }); + + it('does not accept invalid path parameters', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ] }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: '/locations/foo/apriori_locations', + headers: { + Authorization: `Bearer ${token}` + } + }; + + expect(req).to.matchRequestDocumentation({ invalidParameters: [ 'collectionId' ] }); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(400) + .and.to.have.requestParametersValidationErrors([ + typeError({ location: 'path', property: 'collectionId', type: 'integer' }), + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a guest to retrieve apriori locations', async () => { + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: '/locations/1/apriori_locations' + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(401) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/401', + title: 'Unauthorized', + status: 401, + detail: ensureTranslation('auth.error.generalServerAuth') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a volunteer to retrieve apriori locations', async () => { + const volunteer = await createUser({ roles: [ 'volunteer' ] }); + const token = await generateJwtFor(volunteer); + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: '/locations/1/apriori_locations', + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + describe('with default fixtures', () => { + + let owner1, owner2; + let col1, col2; + let image1, image2, image3, image5; + let aprioriLocation1, aprioriLocation2, aprioriLocation3; + let baseRequest; + + beforeEach(async () => { + owner1 = await createOwner(); + owner2 = await createOwner(); + [ col1, col2 ] = await Promise.all([ + createCollection({ owner: owner1 }), + createCollection({ owner: owner2 }) + ]); + + // Image in the default 'initial' state and an a priori location: included by default. + image1 = await createImage({ collection: col1, state: 'initial' }); + aprioriLocation1 = await createAPrioriLocation({ + image_id: image1.id, longitude: 7.44, latitude: 46.95, azimuth: 12.5, exact: false + }); + + // Image in a different state: excluded by default since the default state filter is 'initial'. + image2 = await createImage({ collection: col1, state: 'waiting_validation' }); + aprioriLocation2 = await createAPrioriLocation({ + image_id: image2.id, longitude: 7.34, latitude: 45.95, exact: true + }); + + // Image with a validated state: excluded by default since the default state filter is 'initial'. + image3 = await createImage({ collection: col1, state: 'validated' }); + aprioriLocation3 = await createAPrioriLocation({ image_id: image3.id, longitude: 7.5, latitude: 46 }); + + // Image without any a priori location: nothing to include. + await createImage({ collection: col1, state: 'initial' }); + + // Image belonging to another collection: its a priori location must be excluded. + image5 = await createImage({ collection: col2, state: 'initial' }); + await createAPrioriLocation({ image_id: image5.id, longitude: 6.5, latitude: 46.5 }); + + baseRequest = freeze({ + method: 'GET', + path: `/locations/${col1.id}/apriori_locations` + }); + }); + + it('does not authorize an owner administrator from another owner', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner2 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + [ 'owner_admin', 'owner_validator' ].forEach(role => { + it(`retrieves the collection's apriori locations in the default 'initial' state for an ${role} of the same owner`, async () => { + const user = await createUser({ roles: [ role ], owner: owner1 }); + const token = await generateJwtFor(user); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + }); + + it('accepts the lang query parameter', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + lang: 'fr' + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('retrieves the apriori locations of any collection in the default \'initial\' state for a super administrator', async () => { + const superAdmin = await createUser({ roles: [ 'super_admin' ] }); + const token = await generateJwtFor(superAdmin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('retrieves an empty list for a collection with no matching a priori locations', async () => { + const emptyCollection = await createCollection({ owner: owner1 }); + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const newInitialState = await loadInitialState(); + + const req = { + method: 'GET', + path: `/locations/${emptyCollection.id}/apriori_locations`, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, newInitialState); + }); + + it('does not accept an invalid state query parameter', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + state: 'foo' + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation({ invalidParameters: [ 'state' ] }); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(400) + .and.to.have.requestParametersValidationErrors([ + { + location: 'query', + path: '/state', + message: 'should be a valid image state or an array of valid image states', + validation: [ 'enum', 'oneOf', 'type' ] + }, + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('filters the apriori locations to a single requested state', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + state: 'validated' + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation3.id, + image_id: image3.id, + longitude: 7.5, + latitude: 46, + azimuth: null, + exact: false, + title: image3.title, + original_id: image3.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('filters the apriori locations to several requested states', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + state: [ 'initial', 'waiting_validation' ] + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + }, + { + id: aprioriLocation2.id, + image_id: image2.id, + longitude: 7.34, + latitude: 45.95, + azimuth: null, + exact: true, + title: image2.title, + original_id: image2.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('filters the apriori locations to the image with the requested original_id', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + original_id: image1.original_id + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('combines the original_id and state filters', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + original_id: image2.original_id, + state: 'waiting_validation' + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation2.id, + image_id: image2.id, + longitude: 7.34, + latitude: 45.95, + azimuth: null, + exact: true, + title: image2.title, + original_id: image2.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('retrieves an empty list when no image matches the requested original_id', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + original_id: 'does-not-exist' + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + }); +}); + +describe('GET /locations/images/:imageId/apriori_locations', () => { + let app; + + beforeEach(async () => { + await resetDatabase(); + ({ app } = createApplicationWithMocks()); + }); + + it('does not accept invalid path parameters', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ] }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: '/locations/images/foo/apriori_locations', + headers: { + Authorization: `Bearer ${token}` + } + }; + + expect(req).to.matchRequestDocumentation({ invalidParameters: [ 'imageId' ] }); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(400) + .and.to.have.requestParametersValidationErrors([ + typeError({ location: 'path', property: 'imageId', type: 'integer' }), + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a guest to retrieve apriori locations', async () => { + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: '/locations/images/1/apriori_locations' + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(401) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/401', + title: 'Unauthorized', + status: 401, + detail: ensureTranslation('auth.error.generalServerAuth') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a volunteer to retrieve apriori locations', async () => { + const volunteer = await createUser({ roles: [ 'volunteer' ] }); + const token = await generateJwtFor(volunteer); + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: '/locations/images/1/apriori_locations', + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + describe('with default fixtures', () => { + + let owner1, owner2; + let col1, col2; + let image1, imageValidated, imageWithoutAprioriLocation, imageOfAnotherOwner; + let aprioriLocation1, aprioriLocationValidated; + let baseRequest; + + beforeEach(async () => { + owner1 = await createOwner(); + owner2 = await createOwner(); + [ col1, col2 ] = await Promise.all([ + createCollection({ owner: owner1 }), + createCollection({ owner: owner2 }) + ]); + + // Image with an a priori location: included regardless of its state. + image1 = await createImage({ collection: col1, state: 'initial' }); + aprioriLocation1 = await createAPrioriLocation({ + image_id: image1.id, longitude: 7.44, latitude: 46.95, azimuth: 12.5, exact: false + }); + + // Image with a validated state: included as well since state is not filtered for this route. + imageValidated = await createImage({ collection: col1, state: 'validated' }); + aprioriLocationValidated = await createAPrioriLocation({ image_id: imageValidated.id, longitude: 7.5, latitude: 46 }); + + // Image without any a priori location: nothing to include. + imageWithoutAprioriLocation = await createImage({ collection: col1, state: 'initial' }); + + // Image belonging to another owner: used to check the authorization scope. + imageOfAnotherOwner = await createImage({ collection: col2, state: 'initial' }); + await createAPrioriLocation({ image_id: imageOfAnotherOwner.id, longitude: 6.5, latitude: 46.5 }); + + baseRequest = freeze({ + method: 'GET', + path: `/locations/images/${image1.id}/apriori_locations` + }); + }); + + it('does not authorize an owner administrator from another owner', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner2 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize access to an inexistant image', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: '/locations/images/100/apriori_locations', + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + [ 'owner_admin', 'owner_validator' ].forEach(role => { + it(`retrieves the apriori locations of an image for an ${role} of the same owner`, async () => { + const user = await createUser({ roles: [ role ], owner: owner1 }); + const token = await generateJwtFor(user); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + }); + + it('accepts the lang query parameter', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + query: { + lang: 'fr' + }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('retrieves the apriori locations of any image for a super administrator', async () => { + const superAdmin = await createUser({ roles: [ 'super_admin' ] }); + const token = await generateJwtFor(superAdmin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocation1.id, + image_id: image1.id, + longitude: 7.44, + latitude: 46.95, + azimuth: 12.5, + exact: false, + title: image1.title, + original_id: image1.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('retrieves the apriori location of a validated image regardless of its state', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: `/locations/images/${imageValidated.id}/apriori_locations`, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: aprioriLocationValidated.id, + image_id: imageValidated.id, + longitude: 7.5, + latitude: 46, + azimuth: null, + exact: false, + title: imageValidated.title, + original_id: imageValidated.original_id + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('retrieves an empty list for an image without any a priori location', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'GET', + path: `/locations/images/${imageWithoutAprioriLocation.id}/apriori_locations`, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody([]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + }); +}); diff --git a/app/api/locations/locations.controller.js b/app/api/locations/locations.controller.js index 19fd926f..4684e634 100644 --- a/app/api/locations/locations.controller.js +++ b/app/api/locations/locations.controller.js @@ -1,6 +1,64 @@ const models = require("../../models"); const utils = require("../../utils/express"); -const { notFoundError } = require("../../utils/errors"); +const { notFoundError, authorizationError, requestBodyValidationError } = require("../../utils/errors"); +const { cleanProp, inUniqueOrList } = require("../../utils/params"); + +const aprioriLocationAttributes = [ + "id", + "image_id", + [models.sequelize.literal("ST_X(apriori_locations.geom)"), "longitude"], + [models.sequelize.literal("ST_Y(apriori_locations.geom)"), "latitude"], + "azimuth", + "exact" +]; + +async function assertCollectionOwnerScope(req, collectionId) { + if (req.user.isSuperAdmin()) { + return; + } + + const collection = await models.collections.findByPk(collectionId, { + attributes: [ 'owner_id' ] + }); + + if (!collection || collection.owner_id !== req.user.owner_id) { + throw authorizationError(req.__('general.accessForbidden')); + } +} + +async function assertImageOwnerScope(req, imageId) { + if (req.user.isSuperAdmin()) { + return; + } + + const image = await models.images.findByPk(imageId, { + attributes: [], + include: [{ + model: models.collections, + attributes: [ 'owner_id' ] + }] + }); + + if (!image || !image.collection || image.collection.owner_id !== req.user.owner_id) { + throw authorizationError(req.__('general.accessForbidden')); + } +} + +function findAprioriLocations(imagesWhere) { + return models.apriori_locations.findAll({ + attributes: [ + ...aprioriLocationAttributes, + [ models.sequelize.col('image.title'), 'title' ], + [ models.sequelize.col('image.original_id'), 'original_id' ] + ], + include: [{ + model: models.images, + attributes: [], + where: imagesWhere + }], + order: [ [ 'id', 'ASC' ] ] + }); +} exports.getCountryCode = utils.route(async (req, res) => { const longitude = req.params.longitude; @@ -25,3 +83,117 @@ exports.getCountryCode = utils.route(async (req, res) => { res.status(200).send(result.iso_a2); }); +exports.getAprioriLocationsByCollection = utils.route(async (req, res) => { + const collectionId = req.params.collectionId; + const state = req.query.state || 'initial'; + + await assertCollectionOwnerScope(req, collectionId); + + const aprioriLocations = await findAprioriLocations(cleanProp({ + collection_id: collectionId, + state: inUniqueOrList(state), + original_id: inUniqueOrList(req.query.original_id) + })); + + res.status(200).send(aprioriLocations); +}); + +exports.getAprioriLocationsByImage = utils.route(async (req, res) => { + const imageId = req.params.imageId; + + await assertImageOwnerScope(req, imageId); + + const aprioriLocations = await findAprioriLocations({ id: imageId }); + + res.status(200).send(aprioriLocations); +}); + +exports.updateExactLocation = utils.route(async (req, res) => { + const imageId = req.params.imageId; + const { longitude, latitude } = req.body; + const user = req.user; + + const image = await models.images.findByPk(imageId, { + attributes: [ 'id', 'original_id', 'state' ], + include: [{ + model: models.collections, + attributes: [ 'owner_id' ] + }] + }); + + if (!image || !image.collection || (!user.isSuperAdmin() && image.collection.owner_id !== user.owner_id)) { + throw authorizationError(req.__('general.accessForbidden')); + } + + if (image.state === 'waiting_validation' || image.state === 'validated') { + throw requestBodyValidationError(req, [{ + location: 'body', + path: '', + message: req.__('locations.imageAlreadyGeoreferenced'), + validation: 'imageAlreadyGeoreferenced' + }]); + } + + const geom = models.sequelize.fn( + "ST_SetSRID", + models.sequelize.fn("ST_MakePoint", longitude, latitude, 1000), + "4326" + ); + + // Replace any previous apriori locations of the image with the new, exact + // one. This is done in a transaction so that either both changes are + // applied, or none of them are (e.g. if the creation of the new apriori + // location fails, the previous one(s) must not be destroyed). + const newAprioriLocation = await models.sequelize.transaction(async transaction => { + await models.apriori_locations.destroy({ + where: { image_id: image.id }, + transaction + }); + + return models.apriori_locations.create({ + image_id: image.id, + original_id: image.original_id, + geom, + exact: true + }, { transaction }); + }); + + res.status(200).send({ + id: newAprioriLocation.id, + image_id: image.id, + longitude, + latitude, + azimuth: null, + exact: true + }); +}); + +exports.deleteAprioriLocation = utils.route(async (req, res) => { + const aprioriLocationId = req.params.aprioriLocationId; + const user = req.user; + + const aprioriLocation = await models.apriori_locations.findByPk(aprioriLocationId, { + include: [{ + model: models.images, + attributes: [ 'id' ], + include: [{ + model: models.collections, + attributes: [ 'owner_id' ] + }] + }] + }); + + if ( + !aprioriLocation || !aprioriLocation.image || !aprioriLocation.image.collection || + (!user.isSuperAdmin() && aprioriLocation.image.collection.owner_id !== user.owner_id) + ) { + throw authorizationError(req.__('general.accessForbidden')); + } + + await aprioriLocation.destroy(); + + res.status(200).send({ + message: "The apriori location was deleted." + }); +}); + diff --git a/app/api/locations/locations.exact-location.put.spec.js b/app/api/locations/locations.exact-location.put.spec.js new file mode 100644 index 00000000..73120a6d --- /dev/null +++ b/app/api/locations/locations.exact-location.put.spec.js @@ -0,0 +1,348 @@ +const { expectNoSideEffects, expectSideEffects, loadInitialState } = require('../../../spec/expectations/side-effects'); +const { createOwner } = require('../../../spec/fixtures/owners'); +const { createCollection } = require('../../../spec/fixtures/collections'); +const { createImage } = require('../../../spec/fixtures/images'); +const { createAPrioriLocation } = require('../../../spec/fixtures/apriori_locations'); +const { createUser, generateJwtFor } = require('../../../spec/fixtures/users'); +const { missingPropertyError, typeError } = require('../../../spec/expectations/errors'); +const { freeze, testHttpRequest } = require('../../../spec/utils/api'); +const { resetDatabase } = require('../../../spec/utils/db'); +const { setUpGlobalHooks } = require('../../../spec/utils/hooks'); +const { expect } = require('../../../spec/utils/chai'); +const { createApplicationWithMocks } = require('../../../spec/utils/mocks'); +const { ensureTranslation } = require('../../../spec/utils/i18n'); + +// This should be in every integration test file. +setUpGlobalHooks(); + +describe('PUT /locations/images/:imageId/exact-location', () => { + let app; + + beforeEach(async () => { + await resetDatabase(); + ({ app } = createApplicationWithMocks()); + }); + + it('does not accept invalid path parameters', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ] }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + method: 'PUT', + path: '/locations/images/foo/exact-location', + body: { longitude: 7.44, latitude: 46.95 }, + headers: { + Authorization: `Bearer ${token}` + } + }; + + expect(req).to.matchRequestDocumentation({ invalidParameters: [ 'imageId' ] }); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(400) + .and.to.have.requestParametersValidationErrors([ + typeError({ location: 'path', property: 'imageId', type: 'integer' }), + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a guest to update the exact location', async () => { + const initialState = await loadInitialState(); + + const req = { + method: 'PUT', + path: '/locations/images/1/exact-location', + body: { longitude: 7.44, latitude: 46.95 } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(401) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/401', + title: 'Unauthorized', + status: 401, + detail: ensureTranslation('auth.error.generalServerAuth') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize a volunteer to update the exact location', async () => { + const volunteer = await createUser({ roles: [ 'volunteer' ] }); + const token = await generateJwtFor(volunteer); + const initialState = await loadInitialState(); + + const req = { + method: 'PUT', + path: '/locations/images/1/exact-location', + body: { longitude: 7.44, latitude: 46.95 }, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + describe('with default fixtures', () => { + + let owner1, owner2; + let col1; + let image1; + let aprioriLocation1; + let baseRequest; + + beforeEach(async () => { + owner1 = await createOwner(); + owner2 = await createOwner(); + col1 = await createCollection({ owner: owner1 }); + + image1 = await createImage({ collection: col1, state: 'initial' }); + aprioriLocation1 = await createAPrioriLocation({ + image_id: image1.id, longitude: 7.44, latitude: 46.95, azimuth: 12.5, exact: false + }); + + baseRequest = freeze({ + method: 'PUT', + path: `/locations/images/${image1.id}/exact-location`, + body: { longitude: 7.5, latitude: 47 } + }); + }); + + it('does not accept a request body missing required properties', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + body: {}, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation({ invalidBody: true }); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.requestBodyValidationErrors([ + missingPropertyError({ property: 'longitude' }), + missingPropertyError({ property: 'latitude' }) + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize an owner administrator from another owner', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner2 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + it('does not authorize access to an inexistant image', async () => { + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + path: '/locations/images/100/exact-location', + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(403) + .and.have.httpProblemDetailsBody({ + type: 'https://httpstatuses.com/403', + title: 'Forbidden', + status: 403, + detail: ensureTranslation('general.accessForbidden') + }) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + + [ 'waiting_validation', 'validated' ].forEach(state => { + it(`does not allow updating the exact location of an image already in the ${state} state`, async () => { + const georeferencedImage = await createImage({ collection: col1, state }); + await createAPrioriLocation({ image_id: georeferencedImage.id, longitude: 7.44, latitude: 46.95 }); + + const admin = await createUser({ roles: [ 'owner_admin' ], owner: owner1 }); + const token = await generateJwtFor(admin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + path: `/locations/images/${georeferencedImage.id}/exact-location`, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.requestBodyValidationErrors([ + { + location: 'body', + path: '', + message: ensureTranslation('locations.imageAlreadyGeoreferenced'), + validation: 'imageAlreadyGeoreferenced' + } + ]) + .and.to.matchResponseDocumentation(); + + await expectNoSideEffects(app, initialState); + }); + }); + + [ 'owner_admin', 'owner_validator' ].forEach(role => { + it(`replaces the apriori location of an image with a new exact one for an ${role} of the same owner`, async () => { + const user = await createUser({ roles: [ role ], owner: owner1 }); + const token = await generateJwtFor(user); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody({ + id: actualId => expect(actualId).to.be.a('number').and.not.equal(aprioriLocation1.id), + image_id: image1.id, + longitude: 7.5, + latitude: 47, + azimuth: null, + exact: true + }) + .and.to.matchResponseDocumentation(); + + // Replacing the previous apriori location destroys the old row and + // creates a new one, for a net change of zero. + await expectSideEffects(app, { + initialDatabaseCounts: initialState.initialDatabaseCounts, + databaseChanges: { apriori_locations: 0 } + }); + + // The old apriori location must be gone, replaced by the new one. + const getReq = { + method: 'GET', + path: `/locations/images/${image1.id}/apriori_locations`, + headers: { + Authorization: `Bearer ${token}` + } + }; + const getRes = await testHttpRequest(app, getReq); + expect(getRes) + .to.have.status(200) + .and.to.have.jsonBody([ + { + id: res.body.id, + image_id: image1.id, + longitude: 7.5, + latitude: 47, + azimuth: null, + exact: true, + title: image1.title, + original_id: image1.original_id + } + ]); + }); + }); + + it('creates an apriori location for an image without one, for a super administrator', async () => { + const imageWithoutAprioriLocation = await createImage({ collection: col1, state: 'initial' }); + const superAdmin = await createUser({ roles: [ 'super_admin' ] }); + const token = await generateJwtFor(superAdmin); + const initialState = await loadInitialState(); + + const req = { + ...baseRequest, + path: `/locations/images/${imageWithoutAprioriLocation.id}/exact-location`, + headers: { + Authorization: `Bearer ${token}` + } + }; + expect(req).to.matchRequestDocumentation(); + + const res = await testHttpRequest(app, req); + + expect(res) + .to.have.status(200) + .and.to.have.jsonBody({ + id: actualId => expect(actualId).to.be.a('number'), + image_id: imageWithoutAprioriLocation.id, + longitude: 7.5, + latitude: 47, + azimuth: null, + exact: true + }) + .and.to.matchResponseDocumentation(); + + await expectSideEffects(app, { + initialDatabaseCounts: initialState.initialDatabaseCounts, + databaseChanges: { apriori_locations: 1 } + }); + }); + }); +}); diff --git a/app/api/locations/locations.openapi.yml b/app/api/locations/locations.openapi.yml index 778abc92..7098f9a8 100644 --- a/app/api/locations/locations.openapi.yml +++ b/app/api/locations/locations.openapi.yml @@ -27,3 +27,176 @@ example: CH "400": $ref: "#/components/responses/RequestParametersValidationErrors" + +/locations/{collectionId}/apriori_locations: + get: + summary: Get the apriori locations of a collection's images, optionally filtered by state. + description: |- + ## Authorization + + **Required role:** `owner_admin`, `owner_validator` or `superadmin`. + + **Scope:** an owner administrator or validator can only access apriori + locations of a collection belonging to their own owner. + operationId: getAprioriLocationsByCollection + parameters: + - $ref: "#/components/parameters/LanguageParameter" + - name: collectionId + in: path + description: The ID of the collection. + example: 1 + required: true + schema: + $ref: "#/components/schemas/ApiId" + - name: state + in: query + description: >- + Filter apriori locations by the state of their image. Defaults to + `initial` if not provided. + example: initial + schema: + errorMessage: should be a valid image state or an array of valid image states + oneOf: + - $ref: "#/components/schemas/ImageState" + - type: array + items: + $ref: "#/components/schemas/ImageState" + - name: original_id + in: query + description: >- + Filter apriori locations to the image of the collection with this + original ID. + example: LB_3456_ETHZ + schema: + type: string + responses: + "200": + description: Successful request. + content: + application/json: + schema: + $ref: "#/components/schemas/AprioriLocationWithImageTitleList" + "400": + $ref: "#/components/responses/RequestParametersValidationErrors" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + security: + bearerToken: [] + +/locations/images/{imageId}/apriori_locations: + get: + summary: Get the apriori locations of an image, regardless of its state. + description: |- + ## Authorization + + **Required role:** `owner_admin`, `owner_validator` or `superadmin`. + + **Scope:** an owner administrator or validator can only access apriori + locations of an image belonging to their own owner. + operationId: getAprioriLocationsByImage + parameters: + - $ref: "#/components/parameters/LanguageParameter" + - name: imageId + in: path + description: The ID of the image. + example: 42 + required: true + schema: + $ref: "#/components/schemas/ApiId" + responses: + "200": + description: Successful request. + content: + application/json: + schema: + $ref: "#/components/schemas/AprioriLocationWithImageTitleList" + "400": + $ref: "#/components/responses/RequestParametersValidationErrors" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + security: + bearerToken: [] + +/locations/images/{imageId}/exact-location: + put: + summary: Replace the apriori locations of an image with a new, exact one. + description: |- + ## Authorization + + **Required role:** `owner_admin`, `owner_validator` or `superadmin`. + + **Scope:** an owner administrator or validator can only update the + apriori location of an image belonging to their own owner. + operationId: updateExactLocation + parameters: + - $ref: "#/components/parameters/LanguageParameter" + - name: imageId + in: path + description: The ID of the image. + example: 42 + required: true + schema: + $ref: "#/components/schemas/ApiId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateExactLocationRequest" + responses: + "200": + description: Successful request. + content: + application/json: + schema: + $ref: "#/components/schemas/AprioriLocation" + "400": + $ref: "#/components/responses/RequestParametersValidationErrors" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "422": + $ref: "#/components/responses/RequestBodyValidationErrors" + security: + bearerToken: [] + +/locations/apriori_locations/{aprioriLocationId}: + delete: + summary: Delete an apriori location. + description: |- + ## Authorization + + **Required role:** `owner_admin`, `owner_validator` or `superadmin`. + + **Scope:** an owner administrator or validator can only delete an + apriori location belonging to their own owner. + operationId: deleteAprioriLocation + parameters: + - $ref: "#/components/parameters/LanguageParameter" + - name: aprioriLocationId + in: path + description: The ID of the apriori location. + example: 42 + required: true + schema: + $ref: "#/components/schemas/ApiId" + responses: + "200": + description: Successful request. + content: + application/json: + schema: + $ref: "#/components/schemas/SuccessMessageResponse" + "400": + $ref: "#/components/responses/RequestParametersValidationErrors" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + security: + bearerToken: [] diff --git a/app/api/locations/locations.routes.js b/app/api/locations/locations.routes.js index c558b441..f852a2f5 100644 --- a/app/api/locations/locations.routes.js +++ b/app/api/locations/locations.routes.js @@ -1,6 +1,7 @@ const express = require("express"); -const { validateDocumentedRequestParametersFor } = require('../../utils/validation'); +const { authenticate, authorize } = require("../../utils/authorization"); +const { validateDocumentedRequestParametersFor, validateRequestBodyWithJsonSchema } = require('../../utils/validation'); const controller = require("./locations.controller"); const router = new express.Router(); @@ -11,4 +12,37 @@ router.get("/locations/:longitude,:latitude/countrycode", controller.getCountryCode ); +// Get the apriori locations of a collection's non-validated images. +router.get("/locations/:collectionId/apriori_locations", + authenticate(), + authorize("owner_admin", "owner_validator"), + validateDocumentedRequestParametersFor('GET', '/locations/{collectionId}/apriori_locations'), + controller.getAprioriLocationsByCollection +); + +// Get the apriori locations of a non-validated image. +router.get("/locations/images/:imageId/apriori_locations", + authenticate(), + authorize("owner_admin", "owner_validator"), + validateDocumentedRequestParametersFor('GET', '/locations/images/{imageId}/apriori_locations'), + controller.getAprioriLocationsByImage +); + +// Replace the apriori locations of an image with a new, exact one. +router.put("/locations/images/:imageId/exact-location", + authenticate(), + authorize("owner_admin", "owner_validator"), + validateDocumentedRequestParametersFor('PUT', '/locations/images/{imageId}/exact-location'), + validateRequestBodyWithJsonSchema('UpdateExactLocationRequest'), + controller.updateExactLocation +); + +// Delete an apriori location. +router.delete("/locations/apriori_locations/:aprioriLocationId", + authenticate(), + authorize("owner_admin", "owner_validator"), + validateDocumentedRequestParametersFor('DELETE', '/locations/apriori_locations/{aprioriLocationId}'), + controller.deleteAprioriLocation +); + module.exports = router; diff --git a/app/api/locations/schemas/apriori-location-with-image-title.list.schema.json b/app/api/locations/schemas/apriori-location-with-image-title.list.schema.json new file mode 100644 index 00000000..990c4504 --- /dev/null +++ b/app/api/locations/schemas/apriori-location-with-image-title.list.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "AprioriLocationWithImageTitleList", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "array", + "items": { + "$ref": "AprioriLocationWithImageTitle#" + }, + "additionalProperties": false +} diff --git a/app/api/locations/schemas/apriori-location-with-image-title.schema.json b/app/api/locations/schemas/apriori-location-with-image-title.schema.json new file mode 100644 index 00000000..12844f13 --- /dev/null +++ b/app/api/locations/schemas/apriori-location-with-image-title.schema.json @@ -0,0 +1,42 @@ +{ + "$id": "AprioriLocationWithImageTitle", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "id": { + "$ref": "ApiId#" + }, + "image_id": { + "$ref": "ApiId#" + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": ["number", "null"] + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": ["string", "null"] + }, + "original_id": { + "type": ["string", "null"] + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] +} diff --git a/app/api/locations/schemas/apriori-location.list.schema.json b/app/api/locations/schemas/apriori-location.list.schema.json new file mode 100644 index 00000000..342fa5e9 --- /dev/null +++ b/app/api/locations/schemas/apriori-location.list.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "AprioriLocationList", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "array", + "items": { + "$ref": "AprioriLocation#" + }, + "additionalProperties": false +} diff --git a/app/api/locations/schemas/apriori-location.schema.json b/app/api/locations/schemas/apriori-location.schema.json new file mode 100644 index 00000000..9393418a --- /dev/null +++ b/app/api/locations/schemas/apriori-location.schema.json @@ -0,0 +1,34 @@ +{ + "$id": "AprioriLocation", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "id": { + "$ref": "ApiId#" + }, + "image_id": { + "$ref": "ApiId#" + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": ["number", "null"] + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] +} diff --git a/app/api/locations/schemas/update-exact-location.req.schema.json b/app/api/locations/schemas/update-exact-location.req.schema.json new file mode 100644 index 00000000..cd4486f2 --- /dev/null +++ b/app/api/locations/schemas/update-exact-location.req.schema.json @@ -0,0 +1,18 @@ +{ + "$id": "UpdateExactLocationRequest", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + } + }, + "additionalProperties": false, + "required": [ + "longitude", + "latitude" + ] +} diff --git a/app/generated/openapi.dereferenced.json b/app/generated/openapi.dereferenced.json index da175f14..1e62f5a8 100644 --- a/app/generated/openapi.dereferenced.json +++ b/app/generated/openapi.dereferenced.json @@ -14612,6 +14612,1304 @@ } } }, + "/locations/{collectionId}/apriori_locations": { + "get": { + "summary": "Get the apriori locations of a collection's images, optionally filtered by state.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only access apriori\nlocations of a collection belonging to their own owner.", + "operationId": "getAprioriLocationsByCollection", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "collectionId", + "in": "path", + "description": "The ID of the collection.", + "example": 1, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "state", + "in": "query", + "description": "Filter apriori locations by the state of their image. Defaults to `initial` if not provided.", + "example": "initial", + "schema": { + "errorMessage": "should be a valid image state or an array of valid image states", + "oneOf": [ + { + "type": "string", + "enum": [ + "initial", + "waiting_alignment", + "waiting_validation", + "validated", + "impossible" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "initial", + "waiting_alignment", + "waiting_validation", + "validated", + "impossible" + ] + } + } + ] + } + }, + { + "name": "original_id", + "in": "query", + "description": "Filter apriori locations to the image of the collection with this original ID.", + "example": "LB_3456_ETHZ", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, + "/locations/images/{imageId}/apriori_locations": { + "get": { + "summary": "Get the apriori locations of an image, regardless of its state.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only access apriori\nlocations of an image belonging to their own owner.", + "operationId": "getAprioriLocationsByImage", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "imageId", + "in": "path", + "description": "The ID of the image.", + "example": 42, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, + "/locations/images/{imageId}/exact-location": { + "put": { + "summary": "Replace the apriori locations of an image with a new, exact one.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only update the\napriori location of an image belonging to their own owner.", + "operationId": "updateExactLocation", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "imageId", + "in": "path", + "description": "The ID of the image.", + "example": 42, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + } + }, + "additionalProperties": false, + "required": [ + "longitude", + "latitude" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "422": { + "description": "The request body contains invalid data.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request body contains invalid data.", + "properties": { + "type": { + "example": "https://httpstatuses.com/422", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unprocessable Entity", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 422, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, + "/locations/apriori_locations/{aprioriLocationId}": { + "delete": { + "summary": "Delete an apriori location.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only delete an\napriori location belonging to their own owner.", + "operationId": "deleteAprioriLocation", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "aprioriLocationId", + "in": "path", + "description": "The ID of the apriori location.", + "example": 42, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, "/me/info": { "get": { "summary": "Retrieve profile info from the logged in user.", @@ -28679,6 +29977,190 @@ "additionalProperties": false, "required": [] }, + "AprioriLocationWithImageTitleList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "additionalProperties": false + }, + "AprioriLocationWithImageTitle": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "AprioriLocationList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] + }, + "additionalProperties": false + }, + "AprioriLocation": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] + }, + "UpdateExactLocationRequest": { + "type": "object", + "properties": { + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + } + }, + "additionalProperties": false, + "required": [ + "longitude", + "latitude" + ] + }, "UserCorrectionInfoList": { "type": "array", "items": { diff --git a/app/generated/openapi.json b/app/generated/openapi.json index da175f14..1e62f5a8 100644 --- a/app/generated/openapi.json +++ b/app/generated/openapi.json @@ -14612,6 +14612,1304 @@ } } }, + "/locations/{collectionId}/apriori_locations": { + "get": { + "summary": "Get the apriori locations of a collection's images, optionally filtered by state.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only access apriori\nlocations of a collection belonging to their own owner.", + "operationId": "getAprioriLocationsByCollection", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "collectionId", + "in": "path", + "description": "The ID of the collection.", + "example": 1, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + }, + { + "name": "state", + "in": "query", + "description": "Filter apriori locations by the state of their image. Defaults to `initial` if not provided.", + "example": "initial", + "schema": { + "errorMessage": "should be a valid image state or an array of valid image states", + "oneOf": [ + { + "type": "string", + "enum": [ + "initial", + "waiting_alignment", + "waiting_validation", + "validated", + "impossible" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "initial", + "waiting_alignment", + "waiting_validation", + "validated", + "impossible" + ] + } + } + ] + } + }, + { + "name": "original_id", + "in": "query", + "description": "Filter apriori locations to the image of the collection with this original ID.", + "example": "LB_3456_ETHZ", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, + "/locations/images/{imageId}/apriori_locations": { + "get": { + "summary": "Get the apriori locations of an image, regardless of its state.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only access apriori\nlocations of an image belonging to their own owner.", + "operationId": "getAprioriLocationsByImage", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "imageId", + "in": "path", + "description": "The ID of the image.", + "example": 42, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, + "/locations/images/{imageId}/exact-location": { + "put": { + "summary": "Replace the apriori locations of an image with a new, exact one.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only update the\napriori location of an image belonging to their own owner.", + "operationId": "updateExactLocation", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "imageId", + "in": "path", + "description": "The ID of the image.", + "example": 42, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + } + }, + "additionalProperties": false, + "required": [ + "longitude", + "latitude" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "422": { + "description": "The request body contains invalid data.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request body contains invalid data.", + "properties": { + "type": { + "example": "https://httpstatuses.com/422", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unprocessable Entity", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 422, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, + "/locations/apriori_locations/{aprioriLocationId}": { + "delete": { + "summary": "Delete an apriori location.", + "description": "## Authorization\n\n**Required role:** `owner_admin`, `owner_validator` or `superadmin`.\n\n**Scope:** an owner administrator or validator can only delete an\napriori location belonging to their own owner.", + "operationId": "deleteAprioriLocation", + "parameters": [ + { + "name": "lang", + "in": "query", + "description": "Language to localize data and message returned by the API.", + "example": "en", + "schema": { + "type": "string", + "enum": [ + "en", + "de", + "fr", + "it", + "pt", + "ja" + ] + } + }, + { + "name": "aprioriLocationId", + "in": "path", + "description": "The ID of the apriori location.", + "example": 42, + "required": true, + "schema": { + "type": "integer", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Successful request.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "message" + ] + } + } + } + }, + "400": { + "description": "The request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the request has invalid parameters (HTTP headers, URL query parameters or URL path parameters).", + "properties": { + "type": { + "example": "https://httpstatuses.com/400", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Bad Request", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 400, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "description": "An error indicating that part of an HTTP request is invalid.", + "properties": { + "location": { + "type": "string", + "description": "Which part of the request contains the invalid data.", + "enum": [ + "body", + "header", + "path", + "query" + ], + "example": "query" + }, + "message": { + "type": "string", + "description": "The human-readable error message." + }, + "path": { + "type": "string", + "description": "The path to the invalid data (location-dependent).", + "example": "/foo", + "format": "json-pointer" + }, + "validation": { + "description": "The type(s) of validation that failed.", + "example": "type", + "oneOf": [ + { + "type": "string", + "example": "someValidation" + }, + { + "type": "array", + "items": { + "type": "string", + "example": "someValidation" + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false, + "required": [ + "location", + "message", + "path", + "validation" + ] + } + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "errors", + "status", + "title", + "type" + ] + } + } + } + }, + "401": { + "description": "Authentication failed.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that authentication failed.", + "properties": { + "type": { + "example": "https://httpstatuses.com/401", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Unauthorized", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 401, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + }, + "403": { + "description": "Authentication was successful but the user is not authorized to perform this action due to insufficient privileges.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "description": "A response body indicating that the user is not authorized to perform this action despite being authenticated, probably due to insufficient privileges.", + "properties": { + "type": { + "example": "https://httpstatuses.com/403", + "type": "string", + "description": "A URI reference that identifies the problem type.", + "format": "uri" + }, + "title": { + "example": "Forbidden", + "type": "string", + "description": "A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization." + }, + "status": { + "example": 403, + "type": "number", + "description": "The HTTP status code generated by the origin server for this occurence of the problem.", + "minimum": 100, + "maximum": 599 + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem." + }, + "instance": { + "type": "string", + "description": "A URI reference that identifies the specific occurrence of the problem", + "format": "uri" + } + }, + "additionalProperties": false, + "required": [ + "detail", + "status", + "title", + "type" + ] + } + } + } + } + }, + "security": { + "bearerToken": [] + } + } + }, "/me/info": { "get": { "summary": "Retrieve profile info from the logged in user.", @@ -28679,6 +29977,190 @@ "additionalProperties": false, "required": [] }, + "AprioriLocationWithImageTitleList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "additionalProperties": false + }, + "AprioriLocationWithImageTitle": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": "string", + "nullable": true + }, + "original_id": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + "AprioriLocationList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] + }, + "additionalProperties": false + }, + "AprioriLocation": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": 0 + }, + "image_id": { + "type": "integer", + "minimum": 0 + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": "number", + "nullable": true + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] + }, + "UpdateExactLocationRequest": { + "type": "object", + "properties": { + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + } + }, + "additionalProperties": false, + "required": [ + "longitude", + "latitude" + ] + }, "UserCorrectionInfoList": { "type": "array", "items": { diff --git a/app/generated/schemas.json b/app/generated/schemas.json index 02ebb4c4..7cb35b81 100644 --- a/app/generated/schemas.json +++ b/app/generated/schemas.json @@ -4463,6 +4463,130 @@ "additionalProperties": false, "required": [] }, + { + "$id": "AprioriLocationWithImageTitleList", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "array", + "items": { + "$ref": "AprioriLocationWithImageTitle#" + }, + "additionalProperties": false + }, + { + "$id": "AprioriLocationWithImageTitle", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "id": { + "$ref": "ApiId#" + }, + "image_id": { + "$ref": "ApiId#" + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": [ + "number", + "null" + ] + }, + "exact": { + "type": "boolean" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "original_id": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact", + "title", + "original_id" + ] + }, + { + "$id": "AprioriLocationList", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "array", + "items": { + "$ref": "AprioriLocation#" + }, + "additionalProperties": false + }, + { + "$id": "AprioriLocation", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "id": { + "$ref": "ApiId#" + }, + "image_id": { + "$ref": "ApiId#" + }, + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + }, + "azimuth": { + "type": [ + "number", + "null" + ] + }, + "exact": { + "type": "boolean" + } + }, + "additionalProperties": false, + "required": [ + "id", + "image_id", + "longitude", + "latitude", + "azimuth", + "exact" + ] + }, + { + "$id": "UpdateExactLocationRequest", + "$schema": "http://json-schema.org/draft-07/schema", + "type": "object", + "properties": { + "longitude": { + "type": "number" + }, + "latitude": { + "type": "number" + } + }, + "additionalProperties": false, + "required": [ + "longitude", + "latitude" + ] + }, { "$id": "UserCorrectionInfoList", "$schema": "http://json-schema.org/draft-07/schema", diff --git a/locales/de.json b/locales/de.json index df77e320..608a7597 100644 --- a/locales/de.json +++ b/locales/de.json @@ -67,6 +67,7 @@ "impossible": "Die berechnete Position ist nicht möglich, überprüfen Sie die Punktepaare." }, "locations":{ - "noCountry": "Der Standort entspricht keinem Land." + "noCountry": "Der Standort entspricht keinem Land.", + "imageAlreadyGeoreferenced": "Dieses Bild wurde bereits georeferenziert; seine ungefähre Position kann nicht mehr aktualisiert werden." } } diff --git a/locales/en.json b/locales/en.json index 717c835c..e72320e4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -68,6 +68,7 @@ "impossible": "The computed pose is impossible, check that there is no error on the correspondences." }, "locations":{ - "noCountry": "The location is not within a country." + "noCountry": "The location is not within a country.", + "imageAlreadyGeoreferenced": "This image has already been georeferenced; its apriori location can no longer be updated." } } diff --git a/locales/fr.json b/locales/fr.json index 39511339..176e2832 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -67,6 +67,7 @@ "impossible": "La position calculée est impossible. Contrôlez qu'il n'y ait pas une erreur sur une correspondance." }, "locations":{ - "noCountry": "La localisation ne correspond à aucun pays." + "noCountry": "La localisation ne correspond à aucun pays.", + "imageAlreadyGeoreferenced": "Cette image a déjà été géoréférencée ; sa position a priori ne peut plus être modifiée." } } diff --git a/locales/it.json b/locales/it.json index 81a50188..246069dd 100644 --- a/locales/it.json +++ b/locales/it.json @@ -67,6 +67,7 @@ "impossible": "La posizione calcolata è impossibile, controlla che non ci siano errori nelle corrispondenze." }, "locations":{ - "noCountry": "La posizione non corrisponde ad alcun paese." + "noCountry": "La posizione non corrisponde ad alcun paese.", + "imageAlreadyGeoreferenced": "Questa immagine è già stata georeferenziata; la sua posizione a priori non può più essere aggiornata." } } diff --git a/locales/ja.json b/locales/ja.json index e3f280e6..5f141572 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -68,6 +68,7 @@ "impossible": "計算されたポーズは不可能です。対応関係に誤りがないか確認してください" }, "locations": { - "noCountry": "この場所は国内に存在しません。" + "noCountry": "この場所は国内に存在しません。", + "imageAlreadyGeoreferenced": "この画像は既にジオリファレンスされているため、事前位置を更新できません。" } } diff --git a/locales/pt.json b/locales/pt.json index 09a56415..84a6a70f 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -67,6 +67,7 @@ "impossible": "A computação de posição é impossível, verifique se não há erros nas correspondências." }, "locations":{ - "noCountry": "O local não corresponde a nenhum país." + "noCountry": "O local não corresponde a nenhum país.", + "imageAlreadyGeoreferenced": "Esta imagem já foi georreferenciada; a sua localização a priori já não pode ser atualizada." } }