Skip to content

Commit

Permalink
Merge pull request #936 from AtlasOfLivingAustralia/feature/issue934
Browse files Browse the repository at this point in the history
Feature/issue934
  • Loading branch information
chrisala authored May 6, 2024
2 parents 3c2b83d + a143e4e commit 9c8832b
Show file tree
Hide file tree
Showing 7 changed files with 365 additions and 57 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package au.org.ala.ecodata

import org.apache.http.HttpStatus

class DataSetSummaryController {

static responseFormats = ['json', 'xml']
static allowedMethods = [update:['POST', 'PUT'], delete:'DELETE', bulkUpdate: 'POST']

ProjectService projectService

/** Updates a single dataset for a project */
def update(String projectId) {
Map dataSet = request.JSON
projectId = projectId ?: dataSet.projectId

if (!projectId) {
render status: HttpStatus.SC_BAD_REQUEST, text: "projectId is required"
return
}

if (dataSet.projectId && dataSet.projectId != projectId) {
render status: HttpStatus.SC_BAD_REQUEST, text: "projectId must match the data set projectId"
return
}

respond projectService.updateDataSet(projectId, dataSet)
}

/**
* Updates multiple data sets for a project.
* This endpoint exists to support the use case of associating multiple data sets with a
* report and updating their publicationStatus when the report is submitted/approved.
*
* This method expects the projectId to be supplied via the URL and the data sets to be supplied in the request
* body as a JSON object with key="dataSets" and value=List of data sets.
*/
def bulkUpdate(String projectId) {
Map postBody = request.JSON
List dataSets = postBody?.dataSets

if (!projectId) {
render status: HttpStatus.SC_BAD_REQUEST, text: "projectId is required"
return
}

for (Map dataSet in dataSets) {
if (dataSet.projectId && dataSet.projectId != projectId) {
render status: HttpStatus.SC_BAD_REQUEST, text: "projectId must match the projectId in all supplied data sets"
return
}
}

respond projectService.updateDataSets(projectId, dataSets)
}

def delete(String projectId, String dataSetId) {
if (!projectId || !dataSetId) {
render status: HttpStatus.SC_BAD_REQUEST, text: "projectId and dataSetId are required"
return
}
respond projectService.deleteDataSet(projectId, dataSetId)
}
}
6 changes: 6 additions & 0 deletions grails-app/controllers/au/org/ala/ecodata/UrlMappings.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ class UrlMappings {
"/ws/project/getDefaultFacets"(controller: "project", action: "getDefaultFacets")
"/ws/project/$projectId/dataSet/$dataSetId/records"(controller: "project", action: "fetchDataSetRecords")
"/ws/admin/initiateSpeciesRematch"(controller: "admin", action: "initiateSpeciesRematch")
"/ws/dataSetSummary/$projectId/$dataSetId?"(controller :'dataSetSummary') {

action = [POST:'update', PUT:'update', DELETE:'delete']
}

"/ws/dataSetSummary/bulkUpdate/$projectId"(controller:'dataSetSummary', action:'bulkUpdate')

"/ws/document/download"(controller:"document", action:"download")

Expand Down
32 changes: 5 additions & 27 deletions grails-app/services/au/org/ala/ecodata/ParatooService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import static grails.async.Promises.task
*/
@Slf4j
class ParatooService {
static final Object LOCK = new Object()

static final String DATASET_DATABASE_TABLE = 'Database Table'
static final int PARATOO_MAX_RETRIES = 3
static final String PARATOO_PROTOCOL_PATH = '/protocols'
Expand Down Expand Up @@ -186,18 +186,7 @@ class ParatooService {
dataSet.orgMintedIdentifier = paratooCollectionId.encodeAsOrgMintedIdentifier()

log.info "Minting identifier for Monitor collection: ${paratooCollectionId}: ${dataSet.orgMintedIdentifier}"
Map result
synchronized (LOCK) {
Map latestProject = projectService.get(projectId)
if (!latestProject.custom) {
latestProject.custom = [:]
}
if (!latestProject.custom.dataSets) {
latestProject.custom.dataSets = []
}
latestProject.custom.dataSets << dataSet
result = projectService.update([custom: latestProject.custom], projectId, false)
}
Map result = projectService.updateDataSet(projectId, dataSet)

if (!result.error) {
result.orgMintedIdentifier = dataSet.orgMintedIdentifier
Expand Down Expand Up @@ -239,18 +228,12 @@ class ParatooService {
log.error("An error occurred feching ${collection.orgMintedUUID}: ${e.message}", e)
userService.clearCurrentUser()
}

promise.onComplete { Map result ->
userService.clearCurrentUser()
}

def result
synchronized (LOCK) {
Map latestProject = projectService.get(project.id)
Map latestDataSet = latestProject.custom?.dataSets?.find { it.dataSetId == collection.orgMintedUUID }
latestDataSet.putAll(dataSet)
result = projectService.update([custom: latestProject.custom], project.id, false)
}
def result = projectService.updateDataSet(project.id, dataSet)

[updateResult: result, promise: promise]
}
Expand Down Expand Up @@ -320,12 +303,7 @@ class ParatooService {
dataSet.areSpeciesRecorded = records?.size() > 0
dataSet.activityId = activityId

synchronized (LOCK) {
Map latestProject = projectService.get(project.project.projectId)
Map latestDataSet = latestProject.custom?.dataSets?.find { it.dataSetId == collection.orgMintedUUID }
latestDataSet.putAll(dataSet)
projectService.update([custom: latestProject.custom], project.id, false)
}
projectService.updateDataSet(project.id, dataSet)
}
}
}
Expand Down
125 changes: 105 additions & 20 deletions grails-app/services/au/org/ala/ecodata/ProjectService.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class ProjectService {
static final ENHANCED = 'enhanced'
static final PRIVATE_SITES_REMOVED = 'privatesitesremoved'

/** A Map containing a per-project lock for synchronizing locks for updates. The purpose of this
* is to support concurrent edits on different project data set summaries which are currently modelled as
* an embedded array but can be added and updated by both the UI and the Monitor (Parataoo) application API */
static final Map PROJECT_UPDATE_LOCKS = Collections.synchronizedMap([:].withDefault{ new Object() })

GrailsApplication grailsApplication
MessageSource messageSource
SessionLocaleResolver localeResolver
Expand All @@ -51,6 +56,8 @@ class ProjectService {
grailsApplication.mainContext.commonService
}*/



def getBrief(listOfIds, version = null) {
if (listOfIds) {
if (version) {
Expand Down Expand Up @@ -469,30 +476,37 @@ class ProjectService {
}

def update(Map props, String id, Boolean shouldUpdateCollectory = true) {
Project project = Project.findByProjectId(id)
if (project) {
// retrieve any project activities associated with the project
List projectActivities = projectActivityService.getAllByProject(id)
props = includeProjectFundings(props)
props = includeProjectActivities(props, projectActivities)
synchronized (PROJECT_UPDATE_LOCKS.get(id)) {
Project project = Project.findByProjectId(id)
if (project) {
// retrieve any project activities associated with the project
List projectActivities = projectActivityService.getAllByProject(id)
props = includeProjectFundings(props)
props = includeProjectActivities(props, projectActivities)

try {
bindEmbeddedProperties(project, props)
commonService.updateProperties(project, props)
if (shouldUpdateCollectory) {
updateCollectoryLinkForProject(project, props)
try {
// Custom currently holds keys "details" and "dataSets". Only update the "custom" properties
// that are supplied in the update, leaving the others intact.
if (project.custom && props.custom) {
project.custom.putAll(props.remove('custom'))
}
bindEmbeddedProperties(project, props)
commonService.updateProperties(project, props)
if (shouldUpdateCollectory) {
updateCollectoryLinkForProject(project, props)
}
return [status: 'ok']
} catch (Exception e) {
Project.withSession { session -> session.clear() }
def error = "Error updating project ${id} - ${e.message}"
log.error error, e
return [status: 'error', error: error]
}
return [status: 'ok']
} catch (Exception e) {
Project.withSession { session -> session.clear() }
def error = "Error updating project ${id} - ${e.message}"
log.error error, e
} else {
def error = "Error updating project - no such id ${id}"
log.error error
return [status: 'error', error: error]
}
} else {
def error = "Error updating project - no such id ${id}"
log.error error
return [status: 'error', error: error]
}
}

Expand Down Expand Up @@ -1054,4 +1068,75 @@ class ProjectService {
records
}

/**
* Updates a single data set associated with a project. Because the datasets are stored as an embedded
* array in the Project collection, this method is synchronized on the project to avoid concurrent updates to
* different data sets overwriting each other.
* Due to the way it's been modelled as an embedded array, the client is allowed to supply a dataSetId
* when creating a new data set (e.g. a data set created by a submission from the Monitor app uses the
* submissionId as the dataSetId).
* @param projectId The project to update
* @param dataSet the data set to update.
* @return
*/
Map updateDataSet(String projectId, Map dataSet) {
updateDataSets(projectId, [dataSet])
}

/**
* Updates multiple data sets associated with a project at the same time. This method exists to support
* the use case of associating multiple data sets with a report and updating their publicationStatus when
* the report is submitted/approved.
*
* Because the datasets are stored as an embedded
* array in the Project collection, this method is synchronized on the project to avoid concurrent updates to
* different data sets overwriting each other.
* Due to the way it's been modelled as an embedded array, the client is allowed to supply a dataSetId
* when creating a new data set (e.g. a data set created by a submission from the Monitor app uses the
* submissionId as the dataSetId).
* @param projectId The project to update
* @param dataSet the data sets to update.
* @return
*/
Map updateDataSets(String projectId, List dataSets) {
synchronized (PROJECT_UPDATE_LOCKS.get(projectId)) {
Project project = Project.findByProjectId(projectId)
if (!project) {
return [status: 'error', error: "No project exists with projectId=${projectId}"]
}
for (Map dataSet in dataSets) {
if (!dataSet.dataSetId) {
dataSet.dataSetId = Identifiers.getNew(true, '')
}
Map matchingDataSet = project.custom?.dataSets?.find { it.dataSetId == dataSet.dataSetId }
if (matchingDataSet) {
matchingDataSet.putAll(dataSet)
} else {
if (!project.custom) {
project.custom = [:]
}
if (!project.custom?.dataSets) {
project.custom.dataSets = []
}
project.custom.dataSets.add(dataSet)
}
}
update([custom: project.custom], project.projectId, false)
}
}

Map deleteDataSet(String projectId, String dataSetId) {
synchronized (PROJECT_UPDATE_LOCKS.get(projectId)) {
Project project = Project.findByProjectId(projectId)

boolean foundMatchingDataSet = project?.custom?.dataSets?.removeAll { it.dataSetId == dataSetId }
if (!foundMatchingDataSet) {
return [status: 'error', error: 'No such data set']
}
else {
update([custom: project.custom], project.projectId, false)
}
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package au.org.ala.ecodata

import grails.testing.web.controllers.ControllerUnitTest
import org.apache.http.HttpStatus
import spock.lang.Specification

class DataSetSummaryControllerSpec extends Specification implements ControllerUnitTest<DataSetSummaryController> {

ProjectService projectService = Mock(ProjectService)
def setup() {
controller.projectService = projectService
}

def cleanup() {
}

void "The update method delegates to the projectService"() {
setup:
String projectId = 'p1'
Map dataSetSummary = [dataSetId:'d1', name:'Data set 1']

when:
request.method = 'POST'
request.json = dataSetSummary
controller.update(projectId)

then:
1 * projectService.updateDataSet(projectId, dataSetSummary) >> [status:'ok']
response.json == ['status':'ok']

}

void "A project id must be specified either in the path or as part of the data set summary"() {
setup:
Map dataSetSummary = [dataSetId: 'd1', name: 'Data set 1']

when:
request.method = 'POST'
request.json = dataSetSummary
controller.update()

then:
0 * projectService.updateDataSet(_, _)
response.status == HttpStatus.SC_BAD_REQUEST
}

void "The delete method delegates to the projectService"() {
setup:
String projectId = 'p1'
String dataSetSummaryId = 'd1'

when:
request.method = 'DELETE'
controller.delete(projectId, dataSetSummaryId)

then:
1 * projectService.deleteDataSet(projectId, dataSetSummaryId) >> [status:'ok']
response.json == ['status':'ok']
}

void "The bulkUpdate method delegates to the projectService"() {
setup:
String projectId = 'p1'
Map postBody = [dataSets:[[dataSetId:'d1', name:'Data set 1']]]

when:
request.method = 'POST'
request.json = postBody
controller.bulkUpdate(projectId)

then:
1 * projectService.updateDataSets(projectId, postBody.dataSets) >> [status:'ok']
response.json == ['status':'ok']
}

void "If a projectId is present in a dataSet it much match the projectId parameter in bulkUpdate"() {
setup:
String projectId = 'p1'
Map postBody = [dataSets:[[dataSetId:'d1', name:'Data set 1', projectId:'p1'], [dataSetId:'d2', name:'Data set 2', projectId:'p2']]]

when:
request.method = 'POST'
request.json = postBody
controller.bulkUpdate(projectId)

then:
0 * projectService.updateDataSets(_, _)
response.status == HttpStatus.SC_BAD_REQUEST
}
}
Loading

0 comments on commit 9c8832b

Please sign in to comment.