Skip to content

Commit

Permalink
Merge pull request #939 from AtlasOfLivingAustralia/dev
Browse files Browse the repository at this point in the history
Merging dev to v4.6 release branch
  • Loading branch information
chrisala authored May 10, 2024
2 parents 8224c5c + 9c8832b commit 10d227b
Show file tree
Hide file tree
Showing 13 changed files with 422 additions and 135 deletions.
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ plugins {
id "com.gorylenko.gradle-git-properties" version "2.4.1"
}

version "4.5.1"
version "4.6-SNAPSHOT"
group "au.org.ala"
description "Ecodata"

Expand Down
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
55 changes: 22 additions & 33 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 @@ -232,20 +221,20 @@ class ParatooService {

Map authHeader = getAuthHeader()
Promise promise = task {
userService.setCurrentUser(userId)
asyncFetchCollection(collection, authHeader, userId, project)
}
promise.onError { Throwable e ->
log.error("An error occurred feching ${collection.orgMintedUUID}: ${e.message}", e)
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)
promise.onComplete { Map result ->
userService.clearCurrentUser()
}

def result = projectService.updateDataSet(project.id, dataSet)

[updateResult: result, promise: promise]
}

Expand Down Expand Up @@ -297,29 +286,24 @@ class ParatooService {
surveyDataAndObservations[PARATOO_DATAMODEL_PLOT_LAYOUT] = dataSet.siteId
}

dataSet.startDate = config.getStartDate(surveyDataAndObservations)
dataSet.endDate = config.getEndDate(surveyDataAndObservations)
dataSet.format = DATASET_DATABASE_TABLE
dataSet.sizeUnknown = true

// Delete previously created activity so that duplicate species records are not created.
// Updating existing activity will also create duplicates since it relies on outputSpeciesId to determine
// if a record is new and new ones are created by code.
if (dataSet.activityId) {
activityService.delete(dataSet.activityId, true)
}

String activityId = createActivityFromSurveyData(form, surveyDataAndObservations, surveyId, dataSet.siteId, userId)
String activityId = createActivityFromSurveyData(form, surveyDataAndObservations, surveyId, dataSet, userId)
List records = recordService.getAllByActivity(activityId)
dataSet.areSpeciesRecorded = records?.size() > 0
dataSet.activityId = activityId

dataSet.startDate = config.getStartDate(surveyDataAndObservations)
dataSet.endDate = config.getEndDate(surveyDataAndObservations)
dataSet.format = DATASET_DATABASE_TABLE
dataSet.sizeUnknown = true

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 Expand Up @@ -456,14 +440,19 @@ class ParatooService {
* @param siteId
* @return
*/
private String createActivityFromSurveyData(ActivityForm activityForm, Map surveyObservations, ParatooCollectionId collection, String siteId, String userId) {
private String createActivityFromSurveyData(ActivityForm activityForm, Map surveyObservations, ParatooCollectionId collection, Map dataSet, String userId) {
Map activityProps = [
type : activityForm.name,
formVersion : activityForm.formVersion,
description : "Activity submitted by monitor",
projectId : collection.projectId,
publicationStatus: "published",
siteId : siteId,
siteId : dataSet.siteId,
startDate : dataSet.startDate,
endDate : dataSet.endDate,
plannedStartDate : dataSet.startDate,
plannedEndDate : dataSet.endDate,
externalIds : [new ExternalId(idType: ExternalId.IdType.MONITOR_MINTED_COLLECTION_ID, externalId: dataSet.dataSetId)],
userId : userId,
outputs : [[
data: surveyObservations,
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)
}
}
}

}
Loading

0 comments on commit 10d227b

Please sign in to comment.