diff --git a/grails-app/conf/application.groovy b/grails-app/conf/application.groovy index f920c50ac..cbccb18c3 100644 --- a/grails-app/conf/application.groovy +++ b/grails-app/conf/application.groovy @@ -1,3 +1,7 @@ +import au.org.ala.ecodata.Document +import static au.org.ala.ecodata.Status.DELETED +import org.joda.time.DateTime +import org.joda.time.DateTimeZone def appName = 'ecodata' @@ -47,7 +51,7 @@ environments { grails.mongodb.default.mapping = { version false - '*'(reference:true) + '*'(reference: true) } @@ -147,7 +151,7 @@ if (!biocollect.scienceType) { ] } -if(!biocollect.dataCollectionWhiteList){ +if (!biocollect.dataCollectionWhiteList) { biocollect.dataCollectionWhiteList = [ "Animals", "Biodiversity", @@ -423,7 +427,7 @@ if (!countries) { ] } -if(!spatial.geoJsonEnvelopeConversionThreshold){ +if (!spatial.geoJsonEnvelopeConversionThreshold) { spatial.geoJsonEnvelopeConversionThreshold = 1_000_000 } @@ -431,7 +435,7 @@ spatial.intersectionThreshold = 0.05 spatial.intersectionAreaThresholdInHectare = 10_000 homepageIdx { - elasticsearch { + elasticsearch { fieldsAndBoosts { name = 50 description = 30 @@ -544,7 +548,7 @@ if (!biocacheService.baseURL) { biocacheService.baseURL = 'https://biocache.ala.org.au/ws' } if (!imagesService.baseURL) { - imagesService.baseURL = 'https://images-dev.ala.org.au' + imagesService.baseURL = 'https://images-test.ala.org.au' } if (!security.cas.bypass) { security.cas.bypass = false @@ -637,7 +641,6 @@ environments { grails.mail.host="localhost" grails.mail.port=1025 ehcache.directory="./ehcache" - } test { ehcache.directory="./ehcache" @@ -711,86 +714,86 @@ environments { facets.data = [ [ - name: "projectNameFacet", - title: 'Project', + name : "projectNameFacet", + title : 'Project', dataType: 'text', helpText: 'Name of the project' ], [ - name: "organisationNameFacet", - title: 'Organisation', + name : "organisationNameFacet", + title : 'Organisation', dataType: 'text', helpText: 'Organisations either running projects or associated with projects (eg. as partners).' ], [ - name: "projectActivityNameFacet", - title: 'Survey name', + name : "projectActivityNameFacet", + title : 'Survey name', dataType: 'text', helpText: 'Name of survey' ], [ - name: "recordNameFacet", - title: 'Species name', + name : "recordNameFacet", + title : 'Species name', dataType: 'text', helpText: 'Sighting\'s scientific name' ], [ - name: "userId", - title: 'Owner', + name : "userId", + title : 'Owner', dataType: 'text', helpText: 'User who created the record' ], [ - name: "embargoed", + name : "embargoed", dataType: 'text', - title: 'Unpublished records' + title : 'Unpublished records' ], [ - name: "activityLastUpdatedMonthFacet", - title: 'Month', + name : "activityLastUpdatedMonthFacet", + title : 'Month', dataType: 'text', helpText: 'Month the record was last edited' ], [ - name: "activityLastUpdatedYearFacet", - title: 'Year', + name : "activityLastUpdatedYearFacet", + title : 'Year', dataType: 'text', helpText: 'Year the record was last edited' ], [ - name: "surveyMonthFacet", - title: 'Month', + name : "surveyMonthFacet", + title : 'Month', dataType: 'text', helpText: 'Month the sighting was observed' ], [ - name: "surveyYearFacet", - title: 'Year', + name : "surveyYearFacet", + title : 'Year', dataType: 'text', helpText: 'Year the sighting was observed' ], [ - name: "individualCount", - title: 'Presence or Absence', - dataType: 'number', - helpText: 'Is species present or absent', + name : "individualCount", + title : 'Presence or Absence', + dataType : 'number', + helpText : 'Is species present or absent', facetTermType: 'PresenceOrAbsence' ], [ - name: "associatedProgramFacet", - title: 'Program Name', + name : "associatedProgramFacet", + title : 'Program Name', dataType: 'text', helpText: 'The administrative Program under which a project is being run.' ], [ - name: "siteNameFacet", - title: 'Site Name', + name : "siteNameFacet", + title : 'Site Name', dataType: 'text', helpText: 'A site at which data has been collected for one or projects.' ], [ - name: "associatedSubProgramFacet", - title: 'Sub Program', + name : "associatedSubProgramFacet", + title : 'Sub Program', dataType: 'text', helpText: 'Titles of sub-programmes under listed programmes.' ], @@ -807,38 +810,38 @@ facets.data = [ helpText: '' ], [ - name: "spatialAccuracy", - title: 'Spatial accuracy confidence', + name : "spatialAccuracy", + title : 'Spatial accuracy confidence', dataType: 'text', helpText: '' ], [ - name: "speciesIdentification", - title: 'Species identification confidence', + name : "speciesIdentification", + title : 'Species identification confidence', dataType: 'text', helpText: '' ], [ - name: "temporalAccuracy", - title: 'Temporal accuracy confidence', + name : "temporalAccuracy", + title : 'Temporal accuracy confidence', dataType: 'text', helpText: '' ], [ - name: "nonTaxonomicAccuracy", - title: 'Non-taxonomic data accuracy', + name : "nonTaxonomicAccuracy", + title : 'Non-taxonomic data accuracy', dataType: 'text', helpText: '' ], [ - name: "dataQualityAssuranceMethods", - title: 'Data quality assurance method', + name : "dataQualityAssuranceMethods", + title : 'Data quality assurance method', dataType: 'text', helpText: '' ], [ - name: "isDataManagementPolicyDocumented", - title: 'Is data management policy documented?', + name : "isDataManagementPolicyDocumented", + title : 'Is data management policy documented?', dataType: 'text', helpText: '' ] @@ -847,211 +850,211 @@ facets.data = [ facets.project = [ [ - name: "isExternal", - title: 'Project', + name : "isExternal", + title : 'Project', dataType: 'text', helpText: 'Name of the project' ], [ - name: "isBushfire", - title: 'Bushfire Recovery', + name : "isBushfire", + title : 'Bushfire Recovery', dataType: 'text', helpText: 'Project associated to bushfire recovery' ], [ - name: "organisationFacet", - title: 'Organisation', + name : "organisationFacet", + title : 'Organisation', dataType: 'text', helpText: 'Organisations either running projects or associated with projects (eg. as partners).' ], [ - name: "uNRegions", - title: 'UN Regions', + name : "uNRegions", + title : 'UN Regions', dataType: 'text', helpText: 'The continental regions in which projects occur according to the United Nations regional classification scheme.' ], [ - name: "countries", - title: 'Countries', + name : "countries", + title : 'Countries', dataType: 'text', helpText: 'Countries in which people can participate in the project.' ], [ - name: "origin", - title: 'Source System', + name : "origin", + title : 'Source System', dataType: 'text', helpText: 'The project catalogue system in which the project is registered.' ], [ - name: "scienceType", - title: 'Science Type', + name : "scienceType", + title : 'Science Type', dataType: 'text', helpText: 'Categories of science which survey-based projects are addressing.' ], [ - name: "tags", - title: 'Tags', + name : "tags", + title : 'Tags', dataType: 'text', helpText: 'Classifications for citizen science projects to assist decision making for participation' ], [ - name: "difficulty", - title: 'Difficulty', + name : "difficulty", + title : 'Difficulty', dataType: 'text', helpText: 'A general level of difficulty for citizen science participation' ], [ - name: "status", - title: 'Status', + name : "status", + title : 'Status', dataType: 'text', helpText: 'Active projects are still running, whereas \'completed\' projects have ended and are no longer \'active\'' ], [ - name: "typeOfProject", - title: 'Project Types', + name : "typeOfProject", + title : 'Project Types', dataType: 'text', helpText: 'The project type reflects the hub in which projects were created, but will be either \'survey-based\' (Citizen and Ecological science) or \'schedule-based\' (Works and MERIT) formats for recording data.' ], [ - name: "ecoScienceType", - title: 'Science Type', + name : "ecoScienceType", + title : 'Science Type', dataType: 'text', helpText: 'Categories of science which survey-based projects are addressing.' ], [ - name: "associatedProgramFacet", - title: 'Program Name', + name : "associatedProgramFacet", + title : 'Program Name', dataType: 'text', helpText: 'The administrative Program under which a project is being run.' ], [ - name: "siteNameFacet", - title: 'Site Name', + name : "siteNameFacet", + title : 'Site Name', dataType: 'text', helpText: 'A site at which data has been collected for one or projects.' ], [ - name: "associatedSubProgramFacet", - title: 'Sub Program', + name : "associatedSubProgramFacet", + title : 'Sub Program', dataType: 'text', helpText: 'Titles of sub-programmes under listed programmes.' ], [ - name: "plannedStartDate", - title: 'Project Start Date', - dataType: 'date', - helpText: 'Selects projects that start between the specified date range.', + name : "plannedStartDate", + title : 'Project Start Date', + dataType : 'date', + helpText : 'Selects projects that start between the specified date range.', facetTermType: 'Date' ], [ - name: "fundingSourceFacet", - title: 'Funding source', + name : "fundingSourceFacet", + title : 'Funding source', dataType: 'text', helpText: '' ], [ - name: "reportingThemesFacet", - title: 'Reporting theme', + name : "reportingThemesFacet", + title : 'Reporting theme', dataType: 'text', helpText: '' ], [ - name: "typeFacet", - title: 'Activity type', + name : "typeFacet", + title : 'Activity type', dataType: 'text', helpText: '' ], [ - name: "assessment", - title: 'Assessment', + name : "assessment", + title : 'Assessment', dataType: 'text', helpText: '' ], [ - name: "stateFacet", - title: 'State', + name : "stateFacet", + title : 'State', dataType: 'text', helpText: 'Australian State' ], [ - name: "lgaFacet", - title: 'LGA', + name : "lgaFacet", + title : 'LGA', dataType: 'text', helpText: 'Local Government Areas' ], [ - name: "nrmFacet", - title: 'Management Areas', + name : "nrmFacet", + title : 'Management Areas', dataType: 'text', helpText: 'Natural Resource Management Areas' ], [ - name: "mvgFacet", - title: 'Major Vegetation Group', + name : "mvgFacet", + title : 'Major Vegetation Group', dataType: 'text', helpText: 'National Vegetation Information System Major Vegetation Group' ], [ - name: "mainThemeFacet", - title: 'Reporting Theme', + name : "mainThemeFacet", + title : 'Reporting Theme', dataType: 'text', helpText: '' ], [ - name: "ibraFacet", - title: 'Biogeographic Regions', + name : "ibraFacet", + title : 'Biogeographic Regions', dataType: 'text', helpText: '' ], [ - name: "imcra4_pbFacet", - title: 'Marine Regions', + name : "imcra4_pbFacet", + title : 'Marine Regions', helpText: '' ], [ - name: "otherFacet", - title: 'Other Regions', + name : "otherFacet", + title : 'Other Regions', dataType: 'text', helpText: '' ], [ - name: "electFacet", - title: 'Federal Electorates', + name : "electFacet", + title : 'Federal Electorates', dataType: 'text', helpText: '' ], [ - name: "cmzFacet", - title: 'CMZ', + name : "cmzFacet", + title : 'CMZ', dataType: 'text', helpText: 'Conservation Management Zones' ], [ - name: "meriPlanAssetFacet", - title: 'Assets Addressed', + name : "meriPlanAssetFacet", + title : 'Assets Addressed', dataType: 'text', helpText: 'Assets addressed in the MERI plan' ], [ - name: "projLifecycleStatus", - title: 'Project Lifecycle Status', + name : "projLifecycleStatus", + title : 'Project Lifecycle Status', dataType: 'text', helpText: 'Filters projects by project lifecycle status' ], [ - name: "partnerOrganisationTypeFacet", - title: 'Partner Organisations', + name : "partnerOrganisationTypeFacet", + title : 'Partner Organisations', dataType: 'text', helpText: 'Organisation type of partner organisations' ], [ - name: "industryFacet", - title: "Industry", + name : "industryFacet", + title : "Industry", helpText: "The industries relevant to the project" ], [ - name: "bushfireCategoriesFacet", - title: "Bushfire Categories", + name : "bushfireCategoriesFacet", + title : "Bushfire Categories", helpText: "The bushfire categories relevant to the project" ] ] @@ -1063,6 +1066,8 @@ biocollect.projectActivityDataURL="${biocollect.baseURL}/bioActivity/projectReco biocollect.projectArea.simplificationThreshold=10000 biocollect.projectArea.simplificationTolerance=0.0001 +fieldcapture.baseURL="https://fieldcapture.ala.org.au" + // elasticsearch cluster setting // can transport layer connection be made from apps outside JVM elasticsearch.local = true @@ -1086,58 +1091,58 @@ geoServer.elasticPort = "9300" geoServer.clusterName = "elasticsearch" geoServer.readTimeout = 600000 geoServer.layerNames = [ - "_general" : [ - "pa": [ name: "general", attributes: ['sites.geoIndex', 'sites.geometryType']], - "project": [ name: "generalproject", attributes: ['projectArea.geoIndex', 'projectArea.geometryType']], - ], - "_info" : [ - "pa": [ name: "layerinfo", - attributes: [ - 'sites.geoIndex', - 'dateCreated', - 'projectId', - 'thumbnailUrl', - 'activityId', - 'recordNameFacet', - 'projectActivityNameFacet', - 'projectNameFacet', - 'surveyMonthFacet', - 'surveyYearFacet', - 'sites.geometryType' - ] + "_general": [ + "pa" : [name: "general", attributes: ['sites.geoIndex', 'sites.geometryType']], + "project": [name: "generalproject", attributes: ['projectArea.geoIndex', 'projectArea.geometryType']], + ], + "_info" : [ + "pa" : [name : "layerinfo", + attributes: [ + 'sites.geoIndex', + 'dateCreated', + 'projectId', + 'thumbnailUrl', + 'activityId', + 'recordNameFacet', + 'projectActivityNameFacet', + 'projectNameFacet', + 'surveyMonthFacet', + 'surveyYearFacet', + 'sites.geometryType' + ] ], - "project": [ name: "layerinfoproject", - attributes: [ - 'projectArea.geoIndex', - 'projectArea.geometryType', - 'name', - 'aim', - 'projectId', - 'imageUrl', - 'logoAttribution', - 'plannedStartDate', - 'plannedEndDate' - ] + "project": [name : "layerinfoproject", + attributes: [ + 'projectArea.geoIndex', + 'projectArea.geometryType', + 'name', + 'aim', + 'projectId', + 'imageUrl', + 'logoAttribution', + 'plannedStartDate', + 'plannedEndDate' + ] ], ], - "_time": [ - "pa": [ name: "time", attributes: ['sites.geoIndex', 'sites.geometryType']] + "_time" : [ + "pa": [name: "time", attributes: ['sites.geoIndex', 'sites.geometryType']] ], "_indices": [ - "pa": [ name: "colour_by", attributes: ['sites.geometryType']], - "project": [ name: "colour_byproject", attributes: ['projectArea.geometryType']], + "pa" : [name: "colour_by", attributes: ['sites.geometryType']], + "project": [name: "colour_byproject", attributes: ['projectArea.geometryType']], ] ] geoServer.layerConfiguration = [ "pasearch": [ - "name": "layerName", - "nativeName": "layerNativeName", - "title": "BioCollect survey activity", - "keywords": ["activity", "survey", "biocollect"], - "timeEnabled": false, + "name" : "layerName", + "nativeName" : "layerNativeName", + "title" : "BioCollect survey activity", + "keywords" : ["activity", "survey", "biocollect"], + "timeEnabled" : false, "timeAttribute": "dateCreated", - "attributes": [ + "attributes" : [ [ "name": "sites.geoIndex", "shortName": "sites.geoIndex", @@ -1157,13 +1162,13 @@ geoServer.layerConfiguration = [ ] ], "homepage": [ - "name": "layerName", - "nativeName": "layerNativeName", - "title": "BioCollect survey activity", - "keywords": ["activity", "survey", "biocollect"], - "timeEnabled": false, + "name" : "layerName", + "nativeName" : "layerNativeName", + "title" : "BioCollect survey activity", + "keywords" : ["activity", "survey", "biocollect"], + "timeEnabled" : false, "timeAttribute": "dateCreated", - "attributes": [ + "attributes" : [ [ "name": "projectArea.geoIndex", "shortName": "projectArea.geoIndex", @@ -1203,75 +1208,75 @@ if (!geoserver.facetRangeColour) { geohash.lookupTable = [ [ length: 1, - width: 5009400, + width : 5009400, height: 4992600, - area:25009930440000 + area : 25009930440000 ], [ length: 2, - width: 1252300, + width : 1252300, height: 624100, - area: 781560430000 + area : 781560430000 ], [ length: 3, - width: 156500, + width : 156500, height: 156000, - area: 24414000000 + area : 24414000000 ], [ length: 4, - width: 39100, + width : 39100, height: 19500, - area: 762450000 + area : 762450000 ], [ length: 5, - width: 4900, + width : 4900, height: 4900, - area: 24010000 + area : 24010000 ], [ length: 6, - width: 1200, + width : 1200, height: 609.4, - area: 731280 + area : 731280 ], [ length: 7, - width: 152.9, + width : 152.9, height: 152.4, - area: 23301.96 + area : 23301.96 ], [ length: 8, - width: 38.2, + width : 38.2, height: 19, - area: 725.8 + area : 725.8 ], [ length: 9, - width: 4.8, + width : 4.8, height: 4.8, - area: 23.04 + area : 23.04 ], [ length: 10, - width: 1.2, + width : 1.2, height: 0.0595, - area: 0.0714 + area : 0.0714 ], [ length: 11, - width: 0.0149, + width : 0.0149, height: 0.0149, - area: 0.00022201 + area : 0.00022201 ], [ length: 12, - width: 0.0037, + width : 0.0037, height: 0.0019, - area: 0.00000703 + area : 0.00000703 ] ] @@ -1280,7 +1285,7 @@ geohash.maxNumberOfGrids = 250 // Sets the maximum precision geohash grid. // Using higher precision will be able to narrow the record to precise location. Use lower precision if the aim is to // hide exact location. -geohash.maxLength = 5 +geohash.maxLength = 5 if(!additionalFieldsForDataTypes){ additionalFieldsForDataTypes = [ @@ -1400,6 +1405,489 @@ elasticsearch { password = 'password' } +if (!darwinCore.projectActivityToDwC) { + darwinCore.projectActivityToDwC = [ + "Event": [ + [ + "name": "eventID", + "ref" : "projectActivityId" + ], + [ + "name": "eventDate", + "code" : { record, params -> + def pActivity = params.pActivity + if (pActivity.startDate && pActivity.endDate) { + return params.recordService.toStringIsoDateTime(pActivity.startDate) + "/" + params.recordService.toStringIsoDateTime(pActivity.endDate) + } + + return "" + } + ], + [ + "name": "eventRemarks", + "ref" : "description" + ], + [ + "name": "samplingProtocol", + "ref" : "methodName" + ], + [ + "name" : "eventType", + "default": "Survey" + ], + [ + "name" : "name" + ], + [ + "name" : "license", + "ref" : "attribution" + ], + [ + "name" : "pActivityFormName" + ], + [ + "name" : "startDate", + "code" : { record, params -> + def pActivity = params.pActivity + if (pActivity.startDate) { + return params.recordService.toStringIsoDateTime(pActivity.startDate) + } + + return "" + } + ], + [ + "name" : "endDate", + "code" : { record, params -> + def pActivity = params.pActivity + if (pActivity.endDate) { + return params.recordService.toStringIsoDateTime(pActivity.endDate) + } + + return "" + } + ], + [ + "name" : "legalCustodianOrganisation" + ], + [ + "name" : "dataAccessExternalURL" + ], + [ + "name" : "dataManagementPolicyDescription" + ], + [ + "name" : "dataManagementPolicyURL" + ], + [ + "name" : "dataManagementPolicyDocument" + ], + [ + "name" : "dataSharingLicense" + ] + ] + ] +} + +if (!darwinCore.termsGroupedByClass) { + darwinCore.termsGroupedByClass = [ + "Dataset" : [ + ], + "Event" : [ + [ + "name" : "eventID", + "namespace": "dwc" + ], + [ + "name" : "parentEventID", + "namespace": "dwc" + ], + [ + "name" : "eventType", + "namespace": "dwc", + "default" : "SiteVisit" + ], + [ + "name" : "eventDate", + "namespace": "dwc", + "code" : { record, params -> + if (record.eventTime && record.eventDate instanceof Date) { + Date date = record.eventDate + DateTime dt = new DateTime(date, DateTimeZone.UTC) + dt = dt.withTimeAtStartOfDay() + return dt.toDateTimeISO().toString() + } + else if (record.eventDate) + return params.recordService.toStringIsoDateTime (record.eventDate) + } + ], + [ + "name" : "eventTime", + "namespace": "dwc" + ], + [ + "name" : "endDayOfYear", + "namespace": "dwc" + ], + [ + "name" : "startDayOfYear", + "namespace": "dwc" + ], + [ + "name" : "verbatimEventDate", + "namespace": "dwc" + ], + [ + "name" : "day", + "namespace": "dwc" + ], + [ + "name" : "month", + "namespace": "dwc" + ], + [ + "name" : "year", + "namespace": "dwc" + ], + [ + "name" : "eventRemarks", + "namespace": "dwc" + ], + [ + "name" : "fieldNotes", + "namespace": "dwc" + ], + [ + "name" : "fieldNumber", + "namespace": "dwc" + ], + [ + "name" : "habitat", + "namespace": "dwc" + ], + [ + "name" : "sampleSizeUnit", + "namespace": "dwc" + ], + [ + "name" : "sampleSizeValue", + "namespace": "dwc" + ], + [ + "name" : "samplingEffort", + "namespace": "dwc" + ], + [ + "name" : "samplingProtocol", + "namespace": "dwc" + ], + [ + "name": "decimalLatitude" + ], + [ + "name": "decimalLongitude" + ], + [ + "name" : "geodeticDatum", + "default": "EPSG:4326" + ], + [ + "name": "coordinateUncertaintyInMeters" + ], + [ + "name": "footprintWKT" + ], + [ + "name" : "geodeticDatum", + "default": "EPSG:4326" + ], + [ + "name": "locationID", + "code": { Map record, Map params -> + params?.site?.siteId + } + ] + ], + "FossilSpecimen" : [ + ], + "GeologicalContext" : [ + ], + "HumanObservation" : [ + ], + "Identification" : [ + ], + "LivingSpecimen" : [ + ], + "Location" : [ + ], + "MachineObservation" : [ + ], + "MaterialCitation" : [ + ], + "MaterialSample" : [ + ], + "MeasurementOrFact" : [ + [ + "name" : "measurementsorfacts", + "substitute": { record, params -> + record.measurementsorfacts ?: [] + }, + order: [ + "eventID", + "occurrenceID", + "measurementValue", + "measurementAccuracy", + "measurementUnit", + "measurementUnitID", + "measurementType", + "measurementTypeID", + "measurementID" + ] + ] + ], + "Media" : [ + [ + "name" : "multimedia", + "substitute": { record, params -> + record?.multimedia?.collect { mediaRecord -> + if (mediaRecord.documentId) { + Document document = Document.findByDocumentIdAndStatusNotEqual(mediaRecord.documentId, DELETED) + if (document) { + String identifier = document.getUrl() + return [ + "eventID" : params?.activity?.activityId, + "occurrenceID": record?.occurrenceID , + "type" : document?.type ?: mediaRecord.type, + "identifier" : identifier ?: mediaRecord.identifier, + "format" : document?.contentType ?: mediaRecord.contentType, + "creator" : document?.creator ?: document?.attribution ?: mediaRecord.creator, + "licence" : document?.licence ?: document?.license ?: mediaRecord.license, + "rightsHolder": document?.rightsHolder ?: document?.attribution ?: mediaRecord.rightsHolder + ] + } + } + } + }, + "order": [ + "eventID", + "occurrenceID", + "type", + "identifier", + "format", + "creator", + "licence", + "rightsHolder" + ] + ] + ], + "Occurrence" : [ + [ + "name" : "eventID", + "namespace": "dwc" + ], + [ + "name" : "occurrenceID", + "namespace": "dwc", + "code" : { record, params -> + record?.occurrenceID ?: record?.outputSpeciesId + } + ], + [ + "name": "basisOfRecord" + ], + [ + "name": "scientificName" + ], + [ + "name" : "occurrenceStatus", + "namespace": "dwc", + "code" : { record, params -> + String count = record?.individualCount?.toString()?.trim() ?: "1" + try { + Integer.parseInt(count) + } + catch (NumberFormatException nfe) { + count == null + } + + count == "0" ? "absent" : "present" + } + ], + [ + "name" : "individualCount", + "namespace": "dwc", + "code": { record, params -> + String count = record?.individualCount?.toString()?.trim() ?: "1" + try { + Integer.parseInt(count) + return count + } + catch (NumberFormatException nfe) { + + } + + return "1" + } + ], + [ + "name" : "occurrenceRemarks", + "namespace": "dwc" + ], + [ + "name" : "associatedMedia", + "namespace": "dwc" + ], + [ + "name" : "associatedOccurrences", + "namespace": "dwc" + ], + [ + "name" : "associatedReferences", + "namespace": "dwc" + ], + [ + "name" : "associatedSequences", + "namespace": "dwc" + ], + [ + "name" : "associatedTaxa", + "namespace": "dwc" + ], + [ + "name" : "behavior", + "namespace": "dwc" + ], + [ + "name" : "catalogNumber", + "namespace": "dwc" + ], + [ + "name" : "degreeOfEstablishment", + "namespace": "dwc" + ], + [ + "name" : "disposition", + "namespace": "dwc" + ], + [ + "name" : "establishmentMeans", + "namespace": "dwc" + ], + [ + "name" : "georeferenceVerificationStatus", + "namespace": "dwc" + ], + [ + "name" : "lifeStage", + "namespace": "dwc" + ], + [ + "name" : "organismQuantity", + "namespace": "dwc" + ], + [ + "name" : "organismQuantityType", + "namespace": "dwc" + ], + [ + "name" : "otherCatalogNumbers", + "namespace": "dwc" + ], + [ + "name" : "pathway", + "namespace": "dwc" + ], + [ + "name" : "preparations", + "namespace": "dwc" + ], + [ + "name" : "recordedBy", + "namespace": "dwc" + ], + [ + "name" : "recordedByID", + "namespace": "dwc" + ], + [ + "name" : "recordNumber", + "namespace": "dwc" + ], + [ + "name" : "reproductiveCondition", + "namespace": "dwc" + ], + [ + "name" : "sex", + "namespace": "dwc" + ], + ], + "Organism" : [ + ], + "PreservedSpecimen" : [ + ], + "ResourceRelationship": [ + ], + "Taxon" : [ + ] + ] + + +} + +if (!darwinCore.namespaces) { + darwinCore.namespaces = [ + Archive : "http://rs.tdwg.org/dwc/text/", + Event : "http://rs.tdwg.org/dwc/terms/Event", + Occurrence : "http://rs.tdwg.org/dwc/terms/Occurrence", + MeasurementOrFact : "http://rs.iobis.org/obis/terms/ExtendedMeasurementOrFact", + Media : "http://rs.gbif.org/terms/1.0/Multimedia", + eventID : "http://rs.tdwg.org/dwc/terms/eventID", + parentEventID : "http://rs.tdwg.org/dwc/terms/parentEventID", + eventType : "eventType", + eventTime : "http://rs.tdwg.org/dwc/terms/eventTime", + eventDate : "http://rs.tdwg.org/dwc/terms/eventDate", + samplingProtocol : "http://rs.tdwg.org/dwc/terms/samplingProtocol", + sampleSizeValue : "http://rs.tdwg.org/dwc/terms/sampleSizeValue", + sampleSizeUnit : "http://rs.tdwg.org/dwc/terms/sampleSizeUnit", + samplingEffort : "http://rs.tdwg.org/dwc/terms/samplingEffort", + locationID : "http://rs.tdwg.org/dwc/terms/locationID", + decimalLatitude : "http://rs.tdwg.org/dwc/terms/decimalLatitude", + decimalLongitude : "http://rs.tdwg.org/dwc/terms/decimalLongitude", + geodeticDatum : "http://rs.tdwg.org/dwc/terms/geodeticDatum", + coordinateUncertaintyInMeters: "http://rs.tdwg.org/dwc/terms/coordinateUncertaintyInMeters", + footprintWKT : "http://rs.tdwg.org/dwc/terms/footprintWKT", + geodeticDatum : "http://rs.tdwg.org/dwc/terms/geodeticDatum", + eventRemarks : "http://rs.tdwg.org/dwc/terms/eventRemarks", + occurrenceID : "http://rs.tdwg.org/dwc/terms/occurrenceID", + basisOfRecord : "http://rs.tdwg.org/dwc/terms/basisOfRecord", + recordedBy : "http://rs.tdwg.org/dwc/terms/recordedBy", + individualCount : "http://rs.tdwg.org/dwc/terms/individualCount", + organismQuantity : "http://rs.tdwg.org/dwc/terms/organismQuantity", + organismQuantityType : "http://rs.tdwg.org/dwc/terms/organismQuantityType", + occurrenceStatus : "http://rs.tdwg.org/dwc/terms/occurrenceStatus", + scientificName : "http://rs.tdwg.org/dwc/terms/scientificName", + kingdom : "http://rs.tdwg.org/dwc/terms/kingdom", + family : "http://rs.tdwg.org/dwc/terms/family", + measurementID : "http://rs.tdwg.org/dwc/terms/measurementID", + measurementType : "http://rs.tdwg.org/dwc/terms/measurementType", + measurementTypeID : "http://rs.iobis.org/obis/terms/measurementTypeID", + measurementValue : "http://rs.tdwg.org/dwc/terms/measurementValue", + measurementAccuracy : "http://rs.tdwg.org/dwc/terms/measurementAccuracy", + measurementUnit : "http://rs.tdwg.org/dwc/terms/measurementUnit", + measurementUnitID : "http://rs.iobis.org/obis/terms/measurementUnitID", + measurementDeterminedDate : "http://rs.tdwg.org/dwc/terms/measurementDeterminedDate", + measurementDeterminedBy : "http://rs.tdwg.org/dwc/terms/measurementDeterminedBy", + measurementRemarks : "http://rs.tdwg.org/dwc/terms/measurementRemarks", + type : "http://purl.org/dc/terms/type", + identifier : "http://purl.org/dc/terms/identifier", + format : "http://purl.org/dc/terms/format", + creator : "http://purl.org/dc/terms/creator", + license : "http://purl.org/dc/terms/license", + rightsHolder : "http://purl.org/dc/terms/rightsHolder" + ] +} + // paratoo / monitor paratoo.defaultPlotLayoutDataModels = [ @@ -1489,4 +1977,4 @@ paratoo.defaultPlotLayoutViewModels = [ ] ] ] -paratoo.species.specialCases = ["Other", "N/A"] +paratoo.species.specialCases = ["Other", "N/A"] \ No newline at end of file diff --git a/grails-app/conf/application.yml b/grails-app/conf/application.yml index 1c8a9b393..cb6f84725 100644 --- a/grails-app/conf/application.yml +++ b/grails-app/conf/application.yml @@ -220,7 +220,6 @@ grails: mongodb: codecs: - au.org.ala.ecodata.customcodec.AccessLevelCodec - cors: enabled: true allowedHeaders: diff --git a/grails-app/conf/data/eventcore/eml.template b/grails-app/conf/data/eventcore/eml.template new file mode 100644 index 000000000..3d76a251c --- /dev/null +++ b/grails-app/conf/data/eventcore/eml.template @@ -0,0 +1,15 @@ + + + ${name} + + ${organisationName} + + + Atlas of Living Australia + + ${dateCreated} + + ${description} + + + diff --git a/grails-app/conf/data/eventcore/meta.template b/grails-app/conf/data/eventcore/meta.template new file mode 100644 index 000000000..e13d45ce2 --- /dev/null +++ b/grails-app/conf/data/eventcore/meta.template @@ -0,0 +1,22 @@ + + + + ${core.location} + + + core.fields?.each { + + } + + extensions?.each { + + + ${it.location} + + + it.fields?.each { + + } + + } + diff --git a/grails-app/controllers/au/org/ala/ecodata/HarvestController.groovy b/grails-app/controllers/au/org/ala/ecodata/HarvestController.groovy index 6058e3c85..29647001b 100644 --- a/grails-app/controllers/au/org/ala/ecodata/HarvestController.groovy +++ b/grails-app/controllers/au/org/ala/ecodata/HarvestController.groovy @@ -2,6 +2,7 @@ package au.org.ala.ecodata import grails.converters.JSON import org.apache.http.HttpStatus +import org.apache.http.entity.ContentType import java.text.SimpleDateFormat; @@ -70,9 +71,11 @@ class HarvestController { * @param sort = asc | desc | default:asc * @param lastUpdated = date | dd/MM/yyyy | default:null * @param status = active | deleted | default:active - * + * @deprecated ALA's records harvester will use getDarwinCoreArchiveForProject once Events system is setup. + * To access it use archiveURL property from {@link HarvestController#listHarvestDataResource}. */ - def listRecordsForDataResourceId (){ + @Deprecated + def listRecordsForDataResourceId () { def result = [], error, project Date lastUpdated = null try { @@ -134,4 +137,25 @@ class HarvestController { response.setContentType("application/json") render result as JSON } + + /** + * Get Darwin Core Archive for a project that has ala harvest enabled. + * @param projectId + * @return + * At the moment, you need to add their IP address to whitelist. + */ + def getDarwinCoreArchiveForProject (String projectId) { + if (projectId) { + Project project = Project.findByProjectId(projectId) + if(project?.alaHarvest) { + // This is done to get the correct URL for documents. + String hostname = project.isMERIT ? grailsApplication.config.getProperty("fieldcapture.baseURL") : grailsApplication.config.getProperty("biocollect.baseURL") + DocumentHostInterceptor.documentHostUrlPrefix.set(hostname) + recordService.getDarwinCoreArchiveForProject(response.outputStream, project) + } else + response status: HttpStatus.SC_NOT_FOUND, text: [error: "project not found or ala harvest flag is switched off"] as JSON, contentType: ContentType.APPLICATION_JSON + } else { + response status: HttpStatus.SC_BAD_REQUEST, text: [error: "projectId is required"] as JSON, contentType: ContentType.APPLICATION_JSON + } + } } \ No newline at end of file diff --git a/grails-app/controllers/au/org/ala/ecodata/RecordController.groovy b/grails-app/controllers/au/org/ala/ecodata/RecordController.groovy index 07420c286..35841b9c2 100644 --- a/grails-app/controllers/au/org/ala/ecodata/RecordController.groovy +++ b/grails-app/controllers/au/org/ala/ecodata/RecordController.groovy @@ -425,6 +425,7 @@ class RecordController { } } + private def setResponseHeadersForRecord(response, record) { response.addHeader("content-location", grailsApplication.config.getProperty('grails.serverURL') + "/record/" + record.occurrenceID) response.addHeader("location", grailsApplication.config.getProperty('grails.serverURL') + "/record/" + record.occurrenceID) diff --git a/grails-app/controllers/au/org/ala/ecodata/UrlMappings.groovy b/grails-app/controllers/au/org/ala/ecodata/UrlMappings.groovy index 0c93fd480..a776342fe 100644 --- a/grails-app/controllers/au/org/ala/ecodata/UrlMappings.groovy +++ b/grails-app/controllers/au/org/ala/ecodata/UrlMappings.groovy @@ -197,6 +197,7 @@ class UrlMappings { "/ws/project/getDataCollectionWhiteList"(controller: "project"){ action = [GET:"getDataCollectionWhiteList"] } "/ws/project/getBiocollectFacets"(controller: "project"){ action = [GET:"getBiocollectFacets"] } "/ws/project/getDefaultFacets"(controller: "project", action: "getDefaultFacets") + "/ws/project/$projectId/archive"(controller: "harvest", action: "getDarwinCoreArchiveForProject") "/ws/project/$projectId/dataSet/$dataSetId/records"(controller: "project", action: "fetchDataSetRecords") "/ws/project/findStateAndElectorateForProject"(controller: "project", action: "findStateAndElectorateForProject") "/ws/admin/initiateSpeciesRematch"(controller: "admin", action: "initiateSpeciesRematch") diff --git a/grails-app/domain/au/org/ala/ecodata/Document.groovy b/grails-app/domain/au/org/ala/ecodata/Document.groovy index e4d74cb91..35c3d63c2 100644 --- a/grails-app/domain/au/org/ala/ecodata/Document.groovy +++ b/grails-app/domain/au/org/ala/ecodata/Document.groovy @@ -66,6 +66,7 @@ class Document { String identifier /* To be replaced by reportId */ String stage + String imageId boolean thirdPartyConsentDeclarationMade = false String thirdPartyConsentDeclarationText @@ -125,6 +126,10 @@ class Document { return '' } + if (imageId) { + return getImageURL() + } + if (isImageHostedOnPublicServer()) { return identifier } @@ -147,6 +152,12 @@ class Document { } + String getImageURL () { + if (imageId) { + Holders.getGrailsApplication().config.getProperty("imagesService.baseURL") + "/proxyImage?id=" + imageId + } + } + static constraints = { name nullable: true attribution nullable: true @@ -180,5 +191,6 @@ class Document { identifier nullable: true contentType nullable: true hubId nullable: true + imageId nullable: true } } diff --git a/grails-app/domain/au/org/ala/ecodata/ProjectActivity.groovy b/grails-app/domain/au/org/ala/ecodata/ProjectActivity.groovy index 6e045b595..a42045570 100644 --- a/grails-app/domain/au/org/ala/ecodata/ProjectActivity.groovy +++ b/grails-app/domain/au/org/ala/ecodata/ProjectActivity.groovy @@ -48,6 +48,7 @@ class ProjectActivity { MapLayersConfiguration mapLayersConfig String surveySiteOption boolean canEditAdminSelectedSites + boolean published Date dateCreated Date lastUpdated @@ -87,6 +88,7 @@ class ProjectActivity { mapLayersConfig nullable: true surveySiteOption nullable: true, inList: ['sitepick','sitecreate', 'sitepickcreate'] canEditAdminSelectedSites nullable: true + published nullable: true } static mapping = { diff --git a/grails-app/services/au/org/ala/ecodata/ActivityService.groovy b/grails-app/services/au/org/ala/ecodata/ActivityService.groovy index 0a66eb835..d28d695f4 100644 --- a/grails-app/services/au/org/ala/ecodata/ActivityService.groovy +++ b/grails-app/services/au/org/ala/ecodata/ActivityService.groovy @@ -649,6 +649,26 @@ class ActivityService { } } + /** + * An activity is embargoed if either of the below conditions are satisfied + * 1. embargoed flag is set to true on an activity + * 2. project activity is embargoed until a defined date + * @param activity + * @param projectActivity + * @return + */ + boolean isActivityEmbargoed(Activity activity, ProjectActivity projectActivity){ + if (activity.embargoed) { + return activity.embargoed + } + + if (projectActivity?.visibility?.embargoUntil) { + return projectActivity?.visibility?.embargoUntil.after(new Date()) + } + + return false + } + List findAllForOrganisationId(id, levelOfDetail = [], includeDeleted = false) { List activities if (includeDeleted) { @@ -659,5 +679,4 @@ class ActivityService { } activities } - } diff --git a/grails-app/services/au/org/ala/ecodata/MapService.groovy b/grails-app/services/au/org/ala/ecodata/MapService.groovy index 1158136de..b7c7ba2d7 100644 --- a/grails-app/services/au/org/ala/ecodata/MapService.groovy +++ b/grails-app/services/au/org/ala/ecodata/MapService.groovy @@ -5,14 +5,12 @@ import com.spatial4j.core.context.SpatialContext import com.spatial4j.core.io.GeohashUtils import com.spatial4j.core.shape.Rectangle import grails.converters.JSON -import groovy.json.JsonSlurper import grails.web.http.HttpHeaders +import groovy.json.JsonSlurper import org.elasticsearch.action.admin.indices.mapping.get.GetMappingsRequest import org.elasticsearch.action.admin.indices.mapping.get.GetMappingsResponse import org.elasticsearch.action.search.SearchRequest import org.elasticsearch.common.Strings -import org.elasticsearch.common.io.stream.OutputStreamStreamOutput -import org.elasticsearch.common.xcontent.XContentHelper import org.elasticsearch.index.query.QueryBuilder import org.elasticsearch.search.aggregations.bucket.geogrid.GeoGrid import org.springframework.beans.factory.annotation.Autowired @@ -625,16 +623,23 @@ class MapService { webService.doDelete(url, headers) } - String bindDataToXMLTemplate (String fileClassPath, Map data) { + String bindDataToXMLTemplate (String fileClassPath, Map data, boolean prettyPrint = false) { def files = resourceResolver.getResources(fileClassPath) def engine = new groovy.text.XmlTemplateEngine() - engine.setIndentation('') + if (!prettyPrint) + engine.setIndentation("") + else + engine.setIndentation(" ") + String content files?.each { Resource file -> content = engine.createTemplate(file.getURL()).make(data).toString() } - content?.replaceAll('\n', ''); + if (prettyPrint) + return content + else + return content?.replaceAll('\n', '') } def buildStyleForTermFacet(String field, List terms, String style, String dataStore) { diff --git a/grails-app/services/au/org/ala/ecodata/ProjectService.groovy b/grails-app/services/au/org/ala/ecodata/ProjectService.groovy index e27042771..b90b35292 100644 --- a/grails-app/services/au/org/ala/ecodata/ProjectService.groovy +++ b/grails-app/services/au/org/ala/ecodata/ProjectService.groovy @@ -145,7 +145,19 @@ class ProjectService { order(params.sort, params.order) } - [total: list?.totalCount, list: list?.collect { toMap(it, "basic") }] + def total = list?.totalCount + list = list?.collect { toMap(it, "basic") } + addArchiveLink(list) + [total: total, list: list] + } + + /** + * Adds archive URL to projects + * @param projects + * @return + */ + def addArchiveLink (List projects) { + projects?.each { it.archiveURL = grailsApplication.config.getProperty("grails.serverURL") + "/ws/project/${it.projectId}/archive" } } def listProjects(Map params) { diff --git a/grails-app/services/au/org/ala/ecodata/RecordService.groovy b/grails-app/services/au/org/ala/ecodata/RecordService.groovy index 02e51e888..df495ab0d 100644 --- a/grails-app/services/au/org/ala/ecodata/RecordService.groovy +++ b/grails-app/services/au/org/ala/ecodata/RecordService.groovy @@ -1,6 +1,8 @@ package au.org.ala.ecodata + import au.com.bytecode.opencsv.CSVWriter +import au.org.ala.ecodata.converter.RecordConverter import au.org.ala.web.AuthService import grails.converters.JSON import grails.util.Holders @@ -19,6 +21,9 @@ import org.joda.time.DateTimeZone import org.joda.time.format.DateTimeFormatter import org.joda.time.format.ISODateTimeFormat +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + import static au.org.ala.ecodata.Status.ACTIVE import static au.org.ala.ecodata.Status.DELETED import static grails.async.Promises.task @@ -41,10 +46,14 @@ class RecordService { SensitiveSpeciesService sensitiveSpeciesService DocumentService documentService CommonService commonService + MapService mapService AuthService authService + WebService webService final def ignores = ["action", "controller", "associatedMedia"] private static final List EXCLUDED_RECORD_PROPERTIES = ["_id", "activityId", "dateCreated", "json", "outputId", "projectActivityId", "projectId", "status", "dataResourceUid"] + static final List MULTIMEDIA_DATA_TYPES = ['image', 'audio', 'document', 'photoPoints'] + static final String DWC_EVENT = 'Event', DWC_MEDIA = 'Media', DWC_MEASUREMENT = 'MeasurementOrFact', DWC_OCCURRENCE = 'Occurrence' static final String COMMON_NAME = 'COMMONNAME' static final String SCIENTIFIC_NAME = 'SCIENTIFICNAME' static final String COMMON_NAME_SCIENTIFIC_NAME = 'COMMONNAME(SCIENTIFICNAME)' @@ -260,7 +269,7 @@ class RecordService { } def getAllRecordsByActivityList(List activityList) { - Record.findAllByActivityIdInList(activityList).collect { toMap(it) } + Record.findAllByActivityIdInListAndStatusNotEqual(activityList, Status.DELETED).collect { toMap(it) } } /** @@ -1124,6 +1133,417 @@ class RecordService { } } + /** + * Creates Darwin Core Archive for a project. Output of this function are + * | - meta.xml + * | - eml.xml + * | - Event.csv + * | - MeasurementOrFact.csv + * | - Media.csv + * | - Occurrence.csv + * @param outputStream + * @param project + */ + void getDarwinCoreArchiveForProject(outputStream, Project project) { + Map result + Map headersByDwcClass = [:].withDefault {[]} + Map dwcGroups = grailsApplication.config.getProperty("darwinCore.termsGroupedByClass", Map) + new ZipOutputStream(outputStream).withStream { zip -> + try { + Long start = System.currentTimeMillis(), end + + zip.putNextEntry(new ZipEntry("eml.xml")) + zip << getEmlXML(project) + zip.closeEntry() + + result = generateEventCoreFiles (project) + + result.each { dwcClass, rows -> + if (rows.size()) { + zip.putNextEntry(new ZipEntry("${dwcClass}.csv")) + CSVWriter csvWriter = new CSVWriter(new OutputStreamWriter(zip)) + List headers = getHeadersFromRows(rows) + List defaultOrder = getHeaderOrder(dwcGroups[dwcClass]) + headers = reorderHeaders(headers, defaultOrder) + headersByDwcClass[dwcClass] = headers + csvWriter.writeNext(headers as String[]) + rows.each { row -> + List line = headers.collect { + row[it] instanceof Collection ? row[it].join(" | ") : row[it] ?: "" + } + csvWriter.writeNext(line as String[]) + } + + csvWriter.flush() + log.info("finished writing ${dwcClass}.csv") + } + } + end = System.currentTimeMillis() + log.debug("Time in milliseconds to write event core CSVs- ${end - start}") + start = end + + zip.putNextEntry(new ZipEntry("meta.xml")) + zip << getMetaXML(headersByDwcClass) + zip.closeEntry() + + end = System.currentTimeMillis() + log.debug("Time in milliseconds to write event core XMLs- ${end - start}") + + log.debug("completed zip") + } catch (Exception e){ + log.error("error creating darwin core archive", e) + } finally { + zip.finish() + zip.flush() + zip.close() + } + } + } + + /** + * Creates a data structure that enables creation of Darwin Core Archive. Project activity is a survey type in Event table. + * [ + * 'Event' : [[a: 'b']], + * 'MeasurementOrFact' : [[a: 'b']], + * 'Media' : [[a: 'b']], + * 'Occurrence' : [[a: 'b']] + * ] + * @param project + * @return + */ + Map generateEventCoreFiles(Project project) { + Map result = [:].withDefault {[]} + Organisation organisation = project?.organisationName ? Organisation.findByName(project?.organisationName) : null + List projectActivitiesForProject = ProjectActivity.findAllByProjectIdAndStatusNotEqualAndPublished(project.projectId, Status.DELETED, true) + projectActivitiesForProject.each { ProjectActivity projectActivity -> + int batchSize = 100 + int offset = 0 + int size = batchSize + + // adds project activity to event table. + result[DWC_EVENT].add(convertProjectActivityToEvent(projectActivity, project)) + while ( batchSize == size ) { + List activities = Activity.findAllByProjectIdAndStatusNotEqualAndProjectActivityId(project.projectId, Status.DELETED, projectActivity.projectActivityId, [max: batchSize, offset: offset]) + activities.each { Activity activity -> + if(activityService.isActivityEmbargoed(activity, projectActivity)) + return + + Site site = activity.siteId ? Site.findBySiteId(activity.siteId) : null + List outputs = Output.findAllByActivityId(activity.activityId) + outputs.eachWithIndex { output, outputIndex -> + Map props = outputService.toMap(output) + Map outputMetadata = metadataService.getOutputDataModelByName(props.name) as Map + try { + List records = RecordConverter.convertRecords(project, organisation, site, projectActivity, activity, output, props.data, outputMetadata, false) + records.eachWithIndex { record, recordIndex -> + Map params = [ + activity: activity, + site: site, + output: output, + data: props.data, + metadata: outputMetadata, + organisation: organisation, + project: project, + recordService: this, + index: "${activity.activityId}-${outputIndex}-${recordIndex}" + ] + // groups dwc attributes to event core tables + Map normalised = normaliseRecords(record, params) + normalised.each { dwcClass, attributes -> + switch (dwcClass) { + case DWC_EVENT: + if (attributes && !result[dwcClass].find { it.eventID == attributes.eventID }) { + attributes.parentEventID = projectActivity.projectActivityId + result[dwcClass].add (attributes) + } + break + case DWC_MEDIA: + case DWC_MEASUREMENT: + if(attributes){ + result[dwcClass].addAll(attributes) + } + break + case DWC_OCCURRENCE: + default: + if(attributes && attributes.scientificName) { + result[dwcClass].add (attributes) + } + break + } + } + } + } catch (Exception ex) { + log.error("error converting record - activity id " + activity.activityId, ex) + } + } + } + offset += batchSize + size = activities.size() + } + } + + if (result[DWC_MEASUREMENT]?.size()) { + result[DWC_MEASUREMENT] = RecordConverter.removeDuplicates(result[DWC_MEASUREMENT]) + } + + if (result[DWC_MEDIA]?.size()) { + result[DWC_MEDIA] = RecordConverter.removeDuplicates(result[DWC_MEDIA]) + } + + result + } + + /** + * Get the order of header to write the CSV. By default, the order is determined by order of entry in config + * darwinCore.termsGroupedByClass[dwcClass]. However, it is possible to override this by an attribute called order. + * Check entry for MeasurementOrFact. + * @param configs + * @return + */ + List getHeaderOrder (List configs) { + Boolean isExplicitOrderAvailable = false + Integer orderIndex = null + List headers = [] + configs.eachWithIndex { config, index -> + headers.add(config.name) + + if (config.order) { + isExplicitOrderAvailable = true + orderIndex = index + } + } + + isExplicitOrderAvailable ? configs.get(orderIndex)?.order : headers + } + + /** + * Reorder content of header based on defaultOrder. + * @param headers + * @param defaultOrder + * @return + */ + List reorderHeaders (List headers, List defaultOrder) { + List reordered = [] + + defaultOrder.each { name -> + if ( headers.contains(name) ) { + reordered.add(name) + headers.remove(name) + } + } + + reordered + headers + } + + /** + * Groups darwin core fields to event core table. Various transformations are done on top of the value. + * @param record + * @param params + * @return + */ + Map normaliseRecords(Map record, Map params) { + Map trimmedGroups = darwinCoreTermsGroupedByClass() + Map attributeLinkedToClasses = invertAttributeToClass(trimmedGroups ) + Map result = [:].withDefault {[:]} + attributeLinkedToClasses.each { attribute, classes -> + classes.each { dwcClass -> + Map config = getAttributeConfig(dwcClass, attribute) + + transformToEventCoreTerm(config, record, params, result, dwcClass) + } + } + + result + } + + /** + * Transform a darwin core value before entering into event core. + * @param config + * @param record + * @param params + * @param result + * @param dwcClass + */ + void transformToEventCoreTerm(Map config, Object record, Map params, Map result, String dwcClass) { + String attribute = config.name + + if (config.code) { + result[dwcClass][attribute] = config.code(record, params) + } else if (config.substitute) { + result[dwcClass] = config.substitute(record, params) + } else if (config.ref) { + result[dwcClass][attribute] = record[config.ref] + } else if (record[attribute] != null) { + if (record[attribute] instanceof List) + result[dwcClass][attribute] = record[attribute]?.join(" | ") + else + result[dwcClass][attribute] = record[attribute] + } else if (config.default) { + result[dwcClass][attribute] = config.default + } + } + + Map getAttributeConfig(String dwcClass, String attribute) { + Map groups = grailsApplication.config.getProperty("darwinCore.termsGroupedByClass", Map) + groups[dwcClass].find { it.name == attribute } + } + + Map darwinCoreTermsGroupedByClass() { + Map groups = grailsApplication.config.getProperty("darwinCore.termsGroupedByClass", Map) + Map trimmedGroups = [:] + groups.each { String key, List values -> + trimmedGroups[key] = values?.collect { + it.name + } + + if (trimmedGroups[key]?.size() == 0 ) { + trimmedGroups.remove(key) + } + } + + trimmedGroups + } + + Map invertAttributeToClass (Map termsGroupedByClass) { + Map inverted = [:].withDefault {[]} + termsGroupedByClass.each {dwcClass, attributes -> + attributes.each { + inverted [it].add (dwcClass) + } + } + + inverted + } + + /** + * Iterates all rows to get union of all keys in the map. + * @param rows + * @return + */ + List getHeadersFromRows (List rows) { + Set headers = new HashSet() + rows.each { Map row -> + headers.addAll(row.keySet()) + } + + headers.asList() + } + + /** + * Generates meta.xml class. It describes the values in various associated CSV files. + * @param headersByDwcClass + * @return + */ + String getMetaXML(Map headersByDwcClass) { + Map dataBinding = getMetaXMLData(headersByDwcClass) + String xml = mapService.bindDataToXMLTemplate("classpath:data/eventcore/meta.template", dataBinding, true) + addXMLDeclaration(xml) + } + + /** + * Generates data that is used to bind to meta.template. + * @param headersByDwcClass + * @return + */ + Map getMetaXMLData(Map headersByDwcClass) { + Map dataBinding = [ + archiveNameSpace: grailsApplication.config.darwinCore.namespaces["Archive"], + emlFileName: "eml.xml", + core: [:], + extensions: [] + ] + + headersByDwcClass.each { String dwcClass, List headers -> + Map result = getFields(headers) + Map defaultValues = [ + encoding: "UTF-8", + fieldsTerminatedBy: CSVWriter.DEFAULT_SEPARATOR, + linesTerminatedBy: "\\n", + fieldsEnclosedBy: CSVWriter.DEFAULT_QUOTE_CHARACTER == '"' ? """ : "", + ignoreHeaderLines: 1 + ] + + switch (dwcClass) { + case DWC_EVENT: + defaultValues.rowType = grailsApplication.config.darwinCore.namespaces[dwcClass] + defaultValues.location = "${dwcClass}.csv" + defaultValues.fields = result.fields + defaultValues.coreIndex = result.coreIndex + dataBinding.core = defaultValues + break + default: + defaultValues.rowType = grailsApplication.config.darwinCore.namespaces[dwcClass] + defaultValues.location = "${dwcClass}.csv" + defaultValues.fields = result.fields + defaultValues.coreIndex = result.coreIndex + dataBinding.extensions.add(defaultValues) + break + } + } + + dataBinding + } + + /** + * Get darwin core namespace for various fields. Used to create meta.xml. + * @param headers + * @return + */ + Map getFields(List headers) { + int coreIndex = 0 + String coreID = "eventID" + List fields = [] + + headers?.eachWithIndex { header, hIndex -> + if (header == coreID) + coreIndex = hIndex + + fields.add([index: hIndex, term: grailsApplication.config.darwinCore.namespaces[header] ?: header]) + } + + [coreIndex: coreIndex, fields: fields] + } + + /** + * Creates eml.xml. Data bound to it is derived from Project domain object. + * @param project + * @return + */ + String getEmlXML(Project project) { + String url = grailsApplication.config.getProperty('collectory.baseURL') + "ws/eml/${project.dataResourceId}" + def resp = webService.get(url) + if (resp instanceof String) { + return resp + } + else { + Map proj = projectService.toMap(project) + String xml = mapService.bindDataToXMLTemplate("classpath:data/eventcore/eml.template", proj, true) + addXMLDeclaration(xml) + } + } + + String addXMLDeclaration (String xml) { + String declaration = "\n" + declaration + xml + } + + /** + * Convert project activity to darwin core records. This is ultimately ends up in Event table. + * @param pActivity + * @param project + * @return + */ + Map convertProjectActivityToEvent (pActivity, project) { + String dwcClass = DWC_EVENT + Map paDWC = grailsApplication.config.getProperty("darwinCore.projectActivityToDwC", Map) + List configs = paDWC[dwcClass] + Map result = [:].withDefault { [:] } + configs.each { config -> + transformToEventCoreTerm(config, pActivity, [project: project, pActivity: pActivity, recordService: this], result, dwcClass) + } + + result[dwcClass] + } /** format species by specific type **/ String formatTaxonName (Map data, String displayType) { String name = '' diff --git a/src/integration-test/groovy/au/org/ala/ecodata/HarvestControllerSpec.groovy b/src/integration-test/groovy/au/org/ala/ecodata/HarvestControllerSpec.groovy new file mode 100644 index 000000000..d0038bf8c --- /dev/null +++ b/src/integration-test/groovy/au/org/ala/ecodata/HarvestControllerSpec.groovy @@ -0,0 +1,248 @@ +package au.org.ala.ecodata + +import au.com.bytecode.opencsv.CSVReader +import com.mongodb.BasicDBObject +import grails.gorm.transactions.Rollback +import grails.testing.mixin.integration.Integration +import grails.util.GrailsWebMockUtil +import groovy.xml.XmlSlurper +import groovy.xml.slurpersupport.GPathResult +import org.grails.plugins.testing.GrailsMockHttpServletRequest +import org.grails.plugins.testing.GrailsMockHttpServletResponse +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.context.WebApplicationContext +import spock.lang.Specification + +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +@Integration +@Rollback +class HarvestControllerSpec extends Specification { + @Autowired + HarvestController harvestController + + @Autowired + WebApplicationContext ctx + + def setup() { + cleanup() + GrailsMockHttpServletRequest grailsMockHttpServletRequest = new GrailsMockHttpServletRequest() + GrailsMockHttpServletResponse grailsMockHttpServletResponse = new GrailsMockHttpServletResponse() + GrailsWebMockUtil.bindMockWebRequest(ctx, grailsMockHttpServletRequest, grailsMockHttpServletResponse) + } + + def cleanup() { + Project.collection.remove(new BasicDBObject()) + ProjectActivity.collection.remove(new BasicDBObject()) + Activity.collection.remove(new BasicDBObject()) + Output.collection.remove(new BasicDBObject()) + ActivityForm.collection.remove(new BasicDBObject()) + Organisation.collection.remove(new BasicDBObject()) + Document.collection.remove(new BasicDBObject()) + } + + // write integration test for harvestController#getDarwinCoreArchiveForProject + void "getDarwinCoreArchiveForProject should be able to generate event core zip file"() { + setup: + // create organization + Date now = new Date() + Date yesterday = now.minus(1) + String nowStr = DateUtil.formatWithMilliseconds(now) + String yesterdayStr = DateUtil.formatWithMilliseconds(yesterday) + def organisation = new Organisation(name: "Test Organisation", organisationId: "org1") + organisation.save(flush: true, failOnError: true) + def project = new Project ( + name: "Test Project", + alaHarvest: true, + projectId: "project1", + organisationId: organisation.organisationId, + organisationName: "Test Organisation", + description: "Test project description", + dataResourceId: "dr1", + dateCreated: yesterday + ) + project.save(flush: true, failOnError: true) + def activityForm = new ActivityForm( + name: 'testForm', + publicationStatus: "published", + status: "active", + type: "Activity", + formVersion: 1, + sections: [ + new FormSection( + name: "section1", + template: [ + dataModel: [ + [ + name : "testField", + dataType : "text", + description : "event remarks", + dwcAttribute: "eventRemarks", + ], + [ + name : "speciesName", + dataType : "species", + description: "species field" + ], + [ + dataType : "number", + name : "distance", + dwcAttribute : "measurementValue", + measurementType : "number", + measurementTypeID : "http://qudt.org/vocab/quantitykind/Number", + measurementAccuracy: "0.001", + measurementUnit : "m", + measurementUnitID : "http://qudt.org/vocab/unit/M", + description : "distance from source" + ], + [ + dataType : "image", + name : "images", + description: "photo of species" + ] + ] + ] + ) + ] + ) + activityForm.save(failOnError: true, flush: true) + def projectActivity = new ProjectActivity( + name: "Test Project Activity", + projectId: project.projectId, + projectActivityId: "pa1", + published: true, + endDate: now, startDate: yesterday, + status: "active", + dataSharingLicense: "CC BY 4.0", + description: "Test event remarks", + methodName: "opportunistic", + pActivityFormName: activityForm.name, + methodType: "opportunistic", + spatialAccuracy: "low", + speciesIdentification: "high", + temporalAccuracy: "moderate", + nonTaxonomicAccuracy: "high", + dataQualityAssuranceMethods: ["dataownercurated"], + dataAccessMethods: ["na"], + ) + projectActivity.save(flush: true, failOnError: true) + // create an activity + def activity = new Activity(name: "Test Activity", + projectId: project.projectId, + projectActivityId: projectActivity.projectActivityId, + activityId: 'activity1', + siteId: 'site1' + ) + activity.save(flush: true, failOnError: true) + def document = new Document( + documentId: "document1", + filename: 'z.png', + status: 'active', + imageId: 'image1', + contentType: 'image/png', + attribution: "John Doe", + licence : "CC BY 4.0" + ) + document.save(flush: true, failOnError: true) + // create an output + def output = new Output( + name: "section1", + activityId: activity.activityId, + outputId: 'output1' + ) + output.data = [ + testField : "Test event remarks", + speciesName: [name: "Anura (Frog)", scientificName: 'Anura', commonName: "Frog", outputSpeciesId: "outputSpecies1"], + distance : 1.0, + images : [[ + documentId : "document1", + filename : "z.png", + identifier : "http://example.com/image", + creator : "John Doe", + title : "Image of a frog", + rightHolder: "John Doe", + license : "CC BY 4.0", + ]] + ] + output.save(flush: true, failOnError: true) + // + when: + harvestController.getDarwinCoreArchiveForProject(project.projectId) + + then: + harvestController.response.status == 200 + ByteArrayInputStream zipStream = new ByteArrayInputStream(harvestController.response.contentAsByteArray) + ZipInputStream zipInputStream = new ZipInputStream(zipStream) + ZipEntry entry + int numberOfEntries = 0 + while ((entry = zipInputStream.getNextEntry()) != null) { + numberOfEntries ++ + StringBuffer buffer = new StringBuffer() + BufferedReader reader = new BufferedReader(new InputStreamReader(zipInputStream)) + String line + String content = reader.lines().collect().join("\n") + + switch (entry.name) { + case "eml.xml": + // read content of eml.xml and check if it contains the correct project + // and project activity information + GPathResult xml = new XmlSlurper().parseText(content) + assert xml?.dataset?.title.text().trim() == project.name + assert xml?.dataset?.creator?.organizationName.text().trim() == organisation.name + assert xml?.dataset?.abstract?.para.text().trim() == project.description + break + case "meta.xml": + List spokes = ["Occurrence.csv", "MeasurementOrFact.csv", "Media.csv"] + GPathResult xml = new XmlSlurper().parseText(content) + assert xml?.core?.files.location.text().trim() == "Event.csv" + assert xml?.core?.field?.size() > 1 + assert xml?.core?.id?.size() == 1 + assert spokes.contains(xml?.extension.files.location[0].text().trim()) + assert spokes.contains(xml?.extension.files.location[1].text().trim()) + assert spokes.contains(xml?.extension.files.location[2].text().trim()) + assert xml?.extension[0].coreid.size() == 1 + assert xml?.extension[1].coreid.size() == 1 + assert xml?.extension[2].coreid.size() == 1 + break + case "Event.csv": + CSVReader readerCSV = new CSVReader(new StringReader(content)) + List lines = readerCSV.readAll() + assert lines[0] == ["eventID","parentEventID","eventType","eventDate","eventRemarks","samplingProtocol","geodeticDatum", "locationID", "endDate", "dataSharingLicense", "license", "name", "pActivityFormName", "startDate"] + assert lines[1] == ["pa1", "", "Survey", "${yesterdayStr}/${nowStr}", "Test event remarks", "opportunistic", "","" , nowStr, "CC BY 4.0", "", "Test Project Activity", "testForm", yesterdayStr] + assert lines[2][0] == "activity1" + assert lines[2][1] == "pa1" + assert lines[2][2] == "SiteVisit" + assert lines[2][4] == "Test event remarks" + assert lines.size() == 3 + break + case "Media.csv": + CSVReader readerCSV = new CSVReader(new StringReader(content)) + List lines = readerCSV.readAll() + assert lines.size() == 2 + assert lines[0] == ["eventID","occurrenceID","type","identifier","format","creator","licence","rightsHolder"] + assert lines[1] == ["activity1","outputSpecies1","StillImage","https://images-test.ala.org.au/proxyImage?id=image1","image/png","John Doe","CC BY 4.0","John Doe"] + // check Media.csv + break + case "Occurrence.csv": + // check Occurrence.csv + CSVReader readerCSV = new CSVReader(new StringReader(content)) + List lines = readerCSV.readAll() + assert lines[0] == ["eventID","occurrenceID","basisOfRecord","scientificName","occurrenceStatus","individualCount"] + assert lines[1] == ["activity1","outputSpecies1","HumanObservation","Anura","present","1"] + assert lines.size() == 2 + break + case "MeasurementOrFact.csv": + // check MeasurementOrFact.csv + CSVReader readerCSV = new CSVReader(new StringReader(content)) + List lines = readerCSV.readAll() + assert lines[0] == ["eventID","occurrenceID","measurementValue","measurementAccuracy","measurementUnit","measurementUnitID","measurementType","measurementTypeID"] + assert lines[1] == ["activity1","outputSpecies1","1.0","0.001","m","http://qudt.org/vocab/unit/M","distance from source","http://qudt.org/vocab/quantitykind/Number"] + assert lines.size() ==2 + break + } + } + + assert numberOfEntries == 6 + } +} diff --git a/src/main/groovy/au/org/ala/ecodata/converter/AudioConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/AudioConverter.groovy index 9cb3b8c02..0dde9fef7 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/AudioConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/AudioConverter.groovy @@ -5,7 +5,7 @@ class AudioConverter implements RecordFieldConverter { private static final String DEFAULT_RIGHTS_STATEMENT = "The rights to all uploaded audio are held equally, under a Creative Commons Attribution (CC-BY v3.0) license, by the contributor of the audio and the primary organisation responsible for the project to which they are contributed." private static final String DEFAULT_LICENCE = "Creative Commons Attribution" - List convert(Map data, Map metadata = [:]) { + List convert(Map data, Map metadata = [:], Map context = [:]) { Map record = [:] record.multimedia = data[metadata.name] diff --git a/src/main/groovy/au/org/ala/ecodata/converter/FeatureConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/FeatureConverter.groovy index 3a4cae88a..c9f01f325 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/FeatureConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/FeatureConverter.groovy @@ -2,7 +2,7 @@ package au.org.ala.ecodata.converter class FeatureConverter implements RecordFieldConverter { - List convert(Map data, Map metadata = [:]) { + List convert(Map data, Map metadata = [:], Map context = [:]) { Map record = [:] if (data[metadata.name]) { Double latitude = getDecimalLatitude(data[metadata.name]) diff --git a/src/main/groovy/au/org/ala/ecodata/converter/GenericFieldConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/GenericFieldConverter.groovy index 23e07b096..67230045f 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/GenericFieldConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/GenericFieldConverter.groovy @@ -2,14 +2,14 @@ package au.org.ala.ecodata.converter class GenericFieldConverter implements RecordFieldConverter { - List convert(Map data, Map metadata = [:]) { + List convert(Map data, Map metadata = [:], Map context = [:]) { Map record = [:] Double latitude = getLatitude(data) Double longitude = getLongitude(data) - // Don't override decimalLongitud or decimalLatitude in case they are null, site info could've already set them + // Don't override decimalLongitude or decimalLatitude in case they are null, site info could've already set them if(latitude) { record.decimalLatitude = latitude } @@ -20,8 +20,9 @@ class GenericFieldConverter implements RecordFieldConverter { Map dwcMappings = extractDwcMapping(metadata) - - record << getDwcAttributes(data, dwcMappings) + context.record = record + Map dwcAttributes = getDwcAttributes(data, dwcMappings, metadata, context) + record = RecordConverter.overrideAllExceptLists(dwcAttributes, record) if (data?.dwcAttribute) { record[data.dwcAttribute] = data.value diff --git a/src/main/groovy/au/org/ala/ecodata/converter/ImageConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/ImageConverter.groovy index d419e67a0..e5eed0c98 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/ImageConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/ImageConverter.groovy @@ -4,13 +4,15 @@ class ImageConverter implements RecordFieldConverter { private static final String DEFAULT_RIGHTS_STATEMENT = "The rights to all uploaded images are held under the specified Creative Commons license, by the contributor of the image and the primary organisation responsible for the project to which they are contributed." private static final String DEFAULT_LICENCE = "CC BY 4.0" + private static final String TYPE = "StillImage" - List convert(Map data, Map metadata = [:]) { + List convert(Map data, Map metadata = [:], Map context = [:]) { Map record = [:] record.multimedia = data[metadata.name] record.multimedia?.each { if (it instanceof Map) { + it.type = TYPE it.identifier = it.identifier ?: it.url it.creator = it.creator ?: it.attribution it.title = it.title ?: it.filename diff --git a/src/main/groovy/au/org/ala/ecodata/converter/ListConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/ListConverter.groovy index 024d3a97d..884d96f53 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/ListConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/ListConverter.groovy @@ -7,7 +7,7 @@ import groovy.util.logging.Slf4j class ListConverter implements RecordFieldConverter { @Override - List convert(Map data, Map outputMetadata = [:]) { + List convert(Map data, Map outputMetadata = [:], Map context = [:]) { List records = [] int index = 0 @@ -44,22 +44,26 @@ class ListConverter implements RecordFieldConverter { // For each singleItemModel, get the appropriate field converter for the data type, generate the individual // Record fields and add them to the skeleton Record baseRecordModels?.each { Map dataModel -> +// context.record = baseRecord RecordFieldConverter converter = RecordConverter.getFieldConverter(dataModel.dataType) - List recordFieldSets = converter.convert(row, dataModel) - if (recordFieldSets[0]) - baseRecord << recordFieldSets[0] + List recordFieldSets = converter.convert(row, dataModel, context) + + Map recordFieldSet = recordFieldSets[0] + baseRecord = RecordConverter.overrideAllExceptLists(baseRecord, recordFieldSet) + RecordConverter.updateEventIdToMeasurements(baseRecord[PROP_MEASUREMENTS_OR_FACTS], baseRecord.activityId) } // For each species dataType, where present we will generate a new record speciesModels?.each { Map dataModel -> +// context.record = baseRecord RecordFieldConverter converter = RecordConverter.getFieldConverter(dataModel.dataType) - List recordFieldSets = converter.convert(row, dataModel) + List recordFieldSets = converter.convert(row, dataModel, context) if (recordFieldSets) { Map speciesRecord = RecordConverter.overrideFieldValues(baseRecord, recordFieldSets[0]) - // We want to create a record in the DB only if species information is present if (speciesRecord.outputSpeciesId) { speciesRecord.outputItemId = index++ + RecordConverter.updateSpeciesIdToMeasurements(speciesRecord[PROP_MEASUREMENTS_OR_FACTS], speciesRecord.occurrenceID) records << speciesRecord } else { log.warn("Record [${speciesRecord}] does not contain full species information. " + @@ -73,8 +77,9 @@ class ListConverter implements RecordFieldConverter { // sets which will be converted into Records. For each field set, add a copy of the skeleton Record so it has // all the common fields multiItemModels?.each { Map dataModel -> +// context.record = baseRecord RecordFieldConverter converter = RecordConverter.getFieldConverter(dataModel.dataType) - List recordFieldSets = converter.convert(row, dataModel) + List recordFieldSets = converter.convert(row, dataModel, context) recordFieldSets.each { Map rowRecord = RecordConverter.overrideFieldValues(baseRecord, it) @@ -87,6 +92,10 @@ class ListConverter implements RecordFieldConverter { } } } + + if (!speciesModels) { + records << baseRecord + } } records diff --git a/src/main/groovy/au/org/ala/ecodata/converter/MasterDetailConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/MasterDetailConverter.groovy index fb641529c..14ef4d21a 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/MasterDetailConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/MasterDetailConverter.groovy @@ -3,14 +3,14 @@ package au.org.ala.ecodata.converter class MasterDetailConverter implements RecordFieldConverter { @Override - List convert(Map data, Map outputMetadata = [:]) { + List convert(Map data, Map outputMetadata = [:], Map context = [:]) { // delegate the conversion to a specific converter for the DETAIL portion of the master/detail RecordFieldConverter converter = RecordConverter.getFieldConverter(outputMetadata.detail.dataType) List records = [] data[outputMetadata.name].each { - records.addAll converter.convert(it, outputMetadata.detail as Map) + records.addAll converter.convert(it, outputMetadata.detail as Map, context) } records diff --git a/src/main/groovy/au/org/ala/ecodata/converter/RecordConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/RecordConverter.groovy index c0482bd08..5429c37d2 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/RecordConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/RecordConverter.groovy @@ -1,14 +1,11 @@ package au.org.ala.ecodata.converter -import au.org.ala.ecodata.Activity -import au.org.ala.ecodata.Output -import au.org.ala.ecodata.Project -import au.org.ala.ecodata.ProjectActivity -import au.org.ala.ecodata.Organisation -import au.org.ala.ecodata.Site +import au.org.ala.ecodata.* import groovy.util.logging.Slf4j import org.apache.commons.lang.StringUtils +import static au.org.ala.ecodata.converter.ListConverter.PROP_MEASUREMENTS_OR_FACTS + /** * Utility for converting the data submitted for an Output into one or more Records with the correct Darwin Core fields * so that it can be exported to the Biocache. @@ -51,8 +48,25 @@ class RecordConverter { static final String DEFAULT_LICENCE_TYPE = "https://creativecommons.org/licenses/by/4.0/" static final String SYSTEMATIC_SURVEY = "systematic" static final List MACHINE_SURVEY_TYPES = ["Bat survey - Echolocation recorder","Fauna survey - Call playback","Fauna survey - Camera trapping"] + static final List LIST_COMBINE_EXCEPTION = ["multimedia"] - static List convertRecords(Project project, Organisation organisation, Site site, ProjectActivity projectActivity, Activity activity, Output output, Map data, Map outputMetadata) { + /** + * Creates darwin core fields from output data. It behaviour is controlled by recordGeneration attribute. + * By default this field is true and backward compatible with record creation logic for BioCollect Activity. It will only + * return value if species is present in data model. Setting recordGeneration is false is used by event core archive + * creation. It returns darwin core fields even if species is not present. + * @param project + * @param organisation + * @param site + * @param projectActivity + * @param activity + * @param output + * @param data + * @param outputMetadata + * @param recordGeneration + * @return + */ + static List convertRecords(Project project, Organisation organisation, Site site, ProjectActivity projectActivity, Activity activity, Output output, Map data, Map outputMetadata, boolean recordGeneration = true) { // Outputs are made up of multiple 'dataModels', where each dataModel could map to one or more Record fields // and/or one or more Records. For example, a dataModel with a type of 'list' will map to one Record per item in // the list. Further, each item in the list is a 'dataModel' on it's own, which will map to one or more fields. @@ -63,8 +77,12 @@ class RecordConverter { projectId : activity.projectId, projectActivityId: activity.projectActivityId, activityId : activity.activityId, - userId : activity.userId + userId : activity.userId, + multimedia : [] ] + Map context = ['project':project, 'organisation':organisation, 'site':site, 'projectActivity':projectActivity, 'activity':activity, 'output':output, outputMetadata: outputMetadata, rootData: data] + Long start = System.currentTimeMillis(), end + // Populate the skeleton with Record attributes which are derived from the Activity. These attributes are shared // by all Records that are generated from this Output. @@ -95,25 +113,35 @@ class RecordConverter { // Record fields and add them to the skeleton Record baseRecordModels?.each { Map dataModel -> RecordFieldConverter converter = getFieldConverter(dataModel.dataType) - List recordFieldSets = converter.convert(data, dataModel) - baseRecord << recordFieldSets[0] + List recordFieldSets = converter.convert(data, dataModel, context) + baseRecord = overrideAllExceptLists(baseRecord, recordFieldSets[0]) + updateEventIdToMeasurements(baseRecord[PROP_MEASUREMENTS_OR_FACTS], baseRecord.activityId) } List records = [] // For each species dataType, where present we will generate a new record speciesModels?.each { Map dataModel -> RecordFieldConverter converter = getFieldConverter(dataModel.dataType) - List recordFieldSets = converter.convert(data, dataModel) - Map speciesRecord = overrideFieldValues(baseRecord, recordFieldSets[0]) - + List recordFieldSets = converter.convert(data, dataModel, context) + Map speciesRecord = overrideAllExceptLists(baseRecord, recordFieldSets[0]) // We want to create a record in the DB only if species guid is present i.e. species is valid - if(speciesRecord.guid && speciesRecord.guid != "") { + if(recordGeneration){ + if(speciesRecord.guid && speciesRecord.guid != "") { + records << speciesRecord + } + else { + log.warn("Record [${speciesRecord}] does not contain full species information. " + + "This is most likely a bug.") + } + } + else { + updateSpeciesIdToMeasurements(speciesRecord[PROP_MEASUREMENTS_OR_FACTS], speciesRecord.occurrenceID) records << speciesRecord - } else { - log.warn("Record [${speciesRecord}] does not contain full species information. " + - "This is most likely a bug.") } } + end = System.currentTimeMillis() + log.debug("Time in milliseconds to convert root level data model - ${end - start}") + start = end if (multiItemModels) { // For each multiItemModel, get the appropriate field converter for the data type and generate the list of field @@ -121,29 +149,45 @@ class RecordConverter { // all the common fields multiItemModels?.each { Map dataModel -> RecordFieldConverter converter = getFieldConverter(dataModel.dataType) - List recordFieldSets = converter.convert(data, dataModel) + List recordFieldSets = converter.convert(data, dataModel, context) recordFieldSets.each { - Map rowRecord = overrideFieldValues(baseRecord, it) - if(rowRecord.guid && rowRecord.guid != "") { + Map rowRecord = overrideAllExceptLists(baseRecord, it) + if(recordGeneration) { + if (rowRecord.guid && rowRecord.guid != "") { + records << rowRecord + + } else { + log.warn("Multi item Record [${rowRecord}] does not contain species information, " + + "was the form intended to work like that?") + } + } + else { + updateEventIdToMeasurements(rowRecord[PROP_MEASUREMENTS_OR_FACTS], baseRecord.activityId) records << rowRecord - } else { - log.warn("Multi item Record [${rowRecord}] does not contain species information, " + - "was the form intended to work like that?") } } } } + else if(!speciesModels && !recordGeneration) { + records << baseRecord + } + // Add measurements or facts only when event core archive is being created. + if (recordGeneration) { + records.each { + it.remove(PROP_MEASUREMENTS_OR_FACTS) + } + } + end = System.currentTimeMillis() + log.debug("Time in milliseconds to convert nested data model - ${end - start}") // We are now left with a list of one or more Maps, where each Map contains all the fields for an individual Record. records } /** * Return a new Map with the union of source and additional giving precedence to values from additional - * - * - * If the same key already exists in target it will be overriden + * If the same key already exists in target it will be overridden * @param source the original entries * @param additional Entries with precedence * @return @@ -244,4 +288,69 @@ class RecordConverter { dwcFields } + + static void updateSpeciesIdToMeasurements(List measurements, String id) { + measurements?.each { + if(!it.occurrenceID) + it.occurrenceID = id + } + } + + static void updateEventIdToMeasurements(List measurements, String id) { + measurements?.each { + if(!it.eventID) + it.eventID = id + } + } + + static Map overrideAllExceptLists(Map source, Map additional){ + Map result = [:] + overrideValues(source, result) + overrideValues(additional, result) + } + + /** + * Replaces value in result with value in source except when the value is a list. + * If value is list, then it combines the result and source values. However, this is not applicable + * for key found in LIST_COMBINE_EXCEPTION. Therefore, `multimedia` values are replaced. But `measurementorfact` values + * are combined. + * @param source + * @param result + * @return + */ + static Map overrideValues(Map source, Map result) { + source.each { entry -> + if ((entry.value instanceof Collection) && (result[entry.key] instanceof Collection)) { + if (LIST_COMBINE_EXCEPTION.contains(entry.key)) { + result[entry.key] = entry.value + return + } + + if(result[entry.key] == null) { + result[entry.key] = [] + } + + result[entry.key].addAll(entry.value) + } + else { + result[entry.key] = entry.value + } + } + + result + } + + /** + * Removes for duplicate entries. A duplicate entry is when all values in a map are the same. + * [a: 'b', c: 'd'] & [a: 'b', c: 'd'] are duplicates. + * [a: 'b', c: 'd'] & [a: 'b', c: 'e'] are not duplicates. + * @param measurements + * @return + */ + static List removeDuplicates (List measurements) { + measurements?.groupBy { + it.values().toString() }.collect { key, values -> + values?.get(0) + } + } } diff --git a/src/main/groovy/au/org/ala/ecodata/converter/RecordFieldConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/RecordFieldConverter.groovy index 0301ed5fc..f02a08595 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/RecordFieldConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/RecordFieldConverter.groovy @@ -1,5 +1,10 @@ package au.org.ala.ecodata.converter +import org.apache.commons.logging.Log +import org.apache.commons.logging.LogFactory +import org.springframework.expression.Expression +import org.springframework.expression.ExpressionParser +import org.springframework.expression.spel.standard.SpelExpressionParser /** * Converts an Output's data model into one or more Records. * @@ -10,11 +15,22 @@ package au.org.ala.ecodata.converter * target Record attribute is different (the converter is responsible for mapping one to the other). */ trait RecordFieldConverter { - String DWC_ATTRIBUTE_NAME = "dwcAttribute" + static Log log = LogFactory.getLog(RecordFieldConverter.class) + static String DWC_ATTRIBUTE_NAME = "dwcAttribute" + static String DWC_EXPRESSION = "dwcExpression" + static String DWC_DEFAULT = "dwcDefault" + static String DWC_MEASUREMENT_VALUE = "measurementValue" + static String DWC_MEASUREMENT_TYPE = "measurementType" + static String DWC_MEASUREMENT_TYPE_ID = "measurementTypeID" + static String DWC_MEASUREMENT_ACCURACY = "measurementAccuracy" + static String DWC_MEASUREMENT_UNIT = "measurementUnit" + static String DWC_MEASUREMENT_UNIT_ID = "measurementUnitID" + static String PROP_MEASUREMENTS_OR_FACTS = "measurementsorfacts" abstract List convert(Map data) abstract List convert(Map data, Map outputMetadata) + abstract List convert(Map data, Map outputMetadata, Map context) Double toDouble(val) { Double result = null @@ -34,18 +50,95 @@ trait RecordFieldConverter { dwcMappings } - Map getDwcAttributes(Map dataModel, Map dwcMappings) { + // write test cases for this method + + Map getDwcAttributes(Map data, Map dwcMappings, Map metadata = null, Map context = [:]) { + Map fields = [:] dwcMappings.each { dwcAttribute, fieldName -> - fields[dwcAttribute] = dataModel[fieldName] + if(metadata?.containsKey(DWC_EXPRESSION)) { + data[fieldName] = evaluateExpression((String)metadata[DWC_EXPRESSION], data, metadata[DWC_DEFAULT], metadata, context) + } + + fields[dwcAttribute] = data[fieldName] } - if (dwcMappings.containsKey("species")){ - fields.name = dataModel[dwcMappings["species"]].name - fields.scientificName = dataModel[dwcMappings["species"]].scientificName - fields.guid = dataModel[dwcMappings["species"]].guid + if (dwcMappings.containsKey("species")) { + fields.name = data[dwcMappings["species"]].name + fields.scientificName = data[dwcMappings["species"]].scientificName + fields.guid = data[dwcMappings["species"]].guid + } + + // Add measurements of facts if metadata dwcAttribute value is 'measurementValue'. + // It dwcAttribute measurementValue is taken from output data. + // If measurementType is provided in metadata, its event core value is created by binding metadata value and + // output data. Rest of the attributes are provided by metadata. + String fieldName = dwcMappings[DWC_MEASUREMENT_VALUE] + def value = data[fieldName] + if (dwcMappings.containsKey(DWC_MEASUREMENT_VALUE) && ![null, ""].contains(value)) { + if (!fields[PROP_MEASUREMENTS_OR_FACTS]) + fields[PROP_MEASUREMENTS_OR_FACTS] = [] + + Map measurement = [:] + measurement[DWC_MEASUREMENT_VALUE] = value + measurement[DWC_MEASUREMENT_TYPE] = getMeasurementType(metadata, data, context) + + if (metadata?.containsKey(DWC_MEASUREMENT_TYPE_ID)) { + measurement[DWC_MEASUREMENT_TYPE_ID] = metadata[DWC_MEASUREMENT_TYPE_ID] + } + + if (metadata?.containsKey(DWC_MEASUREMENT_ACCURACY)) { + measurement[DWC_MEASUREMENT_ACCURACY] = metadata[DWC_MEASUREMENT_ACCURACY] + } + + if (metadata?.containsKey(DWC_MEASUREMENT_UNIT)) { + measurement[DWC_MEASUREMENT_UNIT] = metadata[DWC_MEASUREMENT_UNIT] + } + + if (metadata?.containsKey(DWC_MEASUREMENT_UNIT_ID)) { + measurement[DWC_MEASUREMENT_UNIT_ID] = metadata[DWC_MEASUREMENT_UNIT_ID] + } + + fields[PROP_MEASUREMENTS_OR_FACTS].add(measurement) } fields } + + /** + * Create metadata type value for event core's measurement or fact table. + * 1. If measurementType is provided by metadata i.e. dataModel, then bind output data to it. It uses groovy's SimpleTemplateEngine. + * 2. Otherwise, uses one of description, value or name found in metadata. + * @param metadata + * @param data + * @return + */ + def getMeasurementType(Map metadata, Map data, Map context = [:]) { + def defaultValue = metadata?.description ?: metadata?.value ?: metadata?.name + if (metadata?.measurementType && data) { + return evaluateExpression(metadata.measurementType, data, defaultValue, metadata, context) + } else { + return defaultValue + } + } + + def evaluateExpression (String expression, Map data = [:], def defaultValue, Map metadata = null, Map context = [:]) { + try { + context.metadata = metadata + String returnType = metadata.returnType ?: "java.lang.String" + Class returnClass = Class.forName(returnType) + data.context = context + ExpressionParser expressionParser = new SpelExpressionParser() + Expression expObject = expressionParser.parseExpression(expression) + def value = expObject.getValue(data, returnClass) + data.remove('context') + return value + } + catch (Exception ex) { + log.error(ex.message, ex) + data.remove('context') + return defaultValue == null ? expression : defaultValue + } + } + } \ No newline at end of file diff --git a/src/main/groovy/au/org/ala/ecodata/converter/SpeciesConverter.groovy b/src/main/groovy/au/org/ala/ecodata/converter/SpeciesConverter.groovy index 5cf5b43f5..1c0e5a706 100644 --- a/src/main/groovy/au/org/ala/ecodata/converter/SpeciesConverter.groovy +++ b/src/main/groovy/au/org/ala/ecodata/converter/SpeciesConverter.groovy @@ -5,24 +5,28 @@ class SpeciesConverter implements RecordFieldConverter { "(Unmatched taxon)" ] - List convert(Map data, Map metadata = [:]) { + List convert(Map data, Map metadata = [:], Map context = [:]) { if ((data == null) || (data[metadata.name] == null) ) { return [] } Map record = [:] + // if species data not found, return a list of empty map + if(data[metadata.name] == null){ + return [record] + } - record.scientificNameID = record.guid = data[metadata.name].guid + record.scientificNameID = record.guid = data[metadata.name]?.guid record.scientificName = getScientificName(data, metadata) - record.vernacularName = data[metadata.name].commonName + record.vernacularName = data[metadata.name]?.commonName record.name = record.scientificName ?: record.vernacularName // Force outputSpeciesId generation if not coming in the original data - if(!data[metadata.name].outputSpeciesId) { + if(data[metadata.name] && !data[metadata.name]?.outputSpeciesId) { data[metadata.name].outputSpeciesId = UUID.randomUUID().toString() } - record.outputSpeciesId = data[metadata.name].outputSpeciesId + record.occurrenceID = record.outputSpeciesId = data[metadata.name]?.outputSpeciesId [record] } @@ -36,7 +40,7 @@ class SpeciesConverter implements RecordFieldConverter { * @return */ String getScientificName(Map data, Map metadata) { - data[metadata.name].scientificName ?: cleanName(data[metadata.name].name) + data[metadata.name]?.scientificName ?: cleanName(data[metadata.name]?.name) } String cleanName (String name) { diff --git a/src/test/groovy/au/org/ala/ecodata/converter/GenericConverterSpec.groovy b/src/test/groovy/au/org/ala/ecodata/converter/GenericConverterSpec.groovy index 21d547f0b..d39be5291 100644 --- a/src/test/groovy/au/org/ala/ecodata/converter/GenericConverterSpec.groovy +++ b/src/test/groovy/au/org/ala/ecodata/converter/GenericConverterSpec.groovy @@ -1,5 +1,6 @@ package au.org.ala.ecodata.converter + import groovy.json.JsonSlurper import spock.lang.Specification @@ -75,6 +76,87 @@ class GenericConverterSpec extends Specification { result[0].decimalLongitude == 3.0 } + def "convert should handle expression to evaluate value of dwc attribute"() { + setup: + Map data = [ + "juvenile" : 2, + "adult": 3 + ] + + Map metadata = [ + name: "juvenile", + dwcAttribute: "individualCount", + dwcExpression: "['context']['metadata']['description'] + ' ' + (['juvenile'] + ['adult'])", + dwcDefault: 0, + description: "Total number of individuals" + ] + GenericFieldConverter converter = new GenericFieldConverter() + Map context = [:] + + when: + List result = converter.convert(data, metadata) + + then: + result.size() == 1 + result[0].individualCount == "Total number of individuals 5" + } + + def "convert should return expression if binding not found and no default value"() { + setup: + Map data = [ + "juvenile" : 2 + ] + + Map metadata = [ + name: "juvenile", + dwcAttribute: "individualCount", + dwcExpression: "['juvenile'] + ['adult']", + returnType: "java.lang.Integer", + description: "Total number of individuals" + ] + GenericFieldConverter converter = new GenericFieldConverter() + when: + List result = converter.convert(data, metadata) + + then: + result.size() == 1 + result[0].individualCount == "['juvenile'] + ['adult']" + when: + data = [ + "juvenile" : 2 + ] + metadata.dwcDefault = 1 + result = converter.convert(data, metadata) + + then: + result.size() == 1 + result[0].individualCount == 1 + } + + def "expression should check for null value"() { + setup: + Map data = [ + "juvenile" : 2, + adult: null + ] + + Map metadata = [ + name: "juvenile", + dwcAttribute: "individualCount", + dwcExpression: "(['juvenile'] != null ? ['juvenile'] : 0) + (['adult'] != null ? ['adult'] : 0)", + returnType: "java.lang.Integer", + dwcDefault: 0, + description: "Total number of individuals" + ] + GenericFieldConverter converter = new GenericFieldConverter() + + when: + List result = converter.convert(data, metadata) + + then: + result.size() == 1 + result[0].individualCount == 2 + } } diff --git a/src/test/groovy/au/org/ala/ecodata/converter/RecordConverterSpec.groovy b/src/test/groovy/au/org/ala/ecodata/converter/RecordConverterSpec.groovy index 70a2e9cc8..69bcae0d4 100644 --- a/src/test/groovy/au/org/ala/ecodata/converter/RecordConverterSpec.groovy +++ b/src/test/groovy/au/org/ala/ecodata/converter/RecordConverterSpec.groovy @@ -409,4 +409,166 @@ class RecordConverterSpec extends Specification { fieldsets[0].dwc == "firstValue" fieldsets[1].dwc == "secondValue" } + + + def "converter should add measurements or facts to the record field set"() { + setup: + Project project = new Project(projectId: "project1") + Organisation organisation = new Organisation(orgId: "org1") + Site site = new Site(siteId: "site1") + ProjectActivity projectActivity = new ProjectActivity(projectActivityId: "pa1") + Activity activity = new Activity(activityId: "act1") + Output output = new Output(outputId: "output1", activityId: "act1") + Map outputMetadata = [ + record: true, + dataModel: [ + [ + dataType: "text", + name: "field1", + dwcAttribute: "attribute1" + ], + [ + dataType: "number", + name: "distance", + dwcAttribute: "measurementValue", + measurementType: "number", + measurementTypeID: "http://qudt.org/vocab/quantitykind/Number", + measurementAccuracy: "0.001", + measurementUnit: "m", + measurementUnitID: "http://qudt.org/vocab/unit/M", + description: "distance from source" + ] + ] + ] + Map submittedData = [ + "field1": "fieldValue1", + "distance": 10.056 + ] + + when: + List fieldsets = RecordConverter.convertRecords(project, organisation, site, projectActivity, activity, output, submittedData, outputMetadata, false) + + then: + fieldsets.size() == 1 + fieldsets[0].attribute1 == "fieldValue1" + fieldsets[0].measurementsorfacts.size() == 1 + fieldsets[0].measurementsorfacts[0].measurementValue == 10.056 + fieldsets[0].measurementsorfacts[0].measurementType == "distance from source" + fieldsets[0].measurementsorfacts[0].measurementTypeID == "http://qudt.org/vocab/quantitykind/Number" + fieldsets[0].measurementsorfacts[0].measurementAccuracy == "0.001" + fieldsets[0].measurementsorfacts[0].measurementUnit == "m" + fieldsets[0].measurementsorfacts[0].measurementUnitID == "http://qudt.org/vocab/unit/M" + + when: "the measurement is associated with a species and non-species" + outputMetadata = [ + record: true, + dataModel: [ + [ + dataType: "number", + name: "field1", + dwcAttribute: "attribute1" + ], + [ + dataType: "text", + name: "field2", + dwcAttribute: "attribute2", + dwcExpression: "['field1'] == 0 ? \"absent\" : \"present\"" + ], + [ + dataType: "number", + name: "distance", + dwcAttribute: "measurementValue", + measurementType: "number", + measurementTypeID: "http://qudt.org/vocab/quantitykind/Number", + measurementAccuracy: "0.001", + measurementUnit: "m", + returnType: "java.lang.Double", + measurementUnitID: "http://qudt.org/vocab/unit/M", + description: "distance from source" + ], + [ + dataType: "list", + name: "speciesList", + columns: [ + [ + dataType: "species", + name: "speciesField", + dwcAttribute: "species" + ], + [ + dataType: "number", + name: "distance", + dwcAttribute: "measurementValue", + dwcExpression: "['context']['rootData']['field1'] + ['distance']", + measurementType: "number", + measurementTypeID: "http://qudt.org/vocab/quantitykind/Number", + measurementAccuracy: "0.001", + measurementUnit: "m", + returnType: "java.lang.Double", + measurementUnitID: "http://qudt.org/vocab/unit/M", + description: "distance from source" + ] + ] + ] + ] + ] + + + submittedData = [ + "field1": 1, + "distance": 10.056, + "speciesList": [ + [ + "speciesField": [commonName: "commonName1", scientificName: "scientificName1", "outputSpeciesId": "speciesFieldId1", "guid": "someguid1"], + "distance": 20.056 + ], + [ + "speciesField": [commonName: "commonName2", scientificName: "scientificName2", "outputSpeciesId": "speciesFieldId2", "guid": "someguid2"], + "distance": 22.056 + ] + ] + ] + + fieldsets = RecordConverter.convertRecords(project, organisation, site, projectActivity, activity, output, submittedData, outputMetadata, false) + + then: + fieldsets.size() == 2 + fieldsets[0].attribute1 == 1 + fieldsets[0].attribute2 == "present" + fieldsets[0].scientificName == "scientificName1" + fieldsets[0].vernacularName == "commonName1" + fieldsets[0].scientificNameID == "someguid1" + fieldsets[0].name == "scientificName1" + fieldsets[0].outputSpeciesId == "speciesFieldId1" + fieldsets[0].occurrenceID == "speciesFieldId1" + fieldsets[0].measurementsorfacts.size() == 3 + fieldsets[0].measurementsorfacts[0].eventID == "act1" + fieldsets[0].measurementsorfacts[0].measurementValue == 10.056 + fieldsets[0].measurementsorfacts[0].measurementType == "distance from source" + fieldsets[0].measurementsorfacts[0].measurementTypeID == "http://qudt.org/vocab/quantitykind/Number" + fieldsets[0].measurementsorfacts[0].measurementAccuracy == "0.001" + fieldsets[0].measurementsorfacts[0].measurementUnit == "m" + fieldsets[0].measurementsorfacts[0].measurementUnitID == "http://qudt.org/vocab/unit/M" + fieldsets[0].measurementsorfacts[1].measurementValue == 21.056 + fieldsets[0].measurementsorfacts[1].measurementType == "distance from source" + fieldsets[0].measurementsorfacts[1].measurementTypeID == "http://qudt.org/vocab/quantitykind/Number" + fieldsets[0].measurementsorfacts[1].measurementAccuracy == "0.001" + fieldsets[0].measurementsorfacts[1].measurementUnit == "m" + fieldsets[0].measurementsorfacts[1].measurementUnitID == "http://qudt.org/vocab/unit/M" + fieldsets[0].measurementsorfacts[2].measurementValue == 23.056 + fieldsets[0].measurementsorfacts[2].measurementType == "distance from source" + fieldsets[0].measurementsorfacts[2].measurementTypeID == "http://qudt.org/vocab/quantitykind/Number" + fieldsets[0].measurementsorfacts[2].measurementAccuracy == "0.001" + fieldsets[0].measurementsorfacts[2].measurementUnit == "m" + fieldsets[0].measurementsorfacts[2].measurementUnitID == "http://qudt.org/vocab/unit/M" + fieldsets[1].attribute1 == 1 + fieldsets[1].attribute2 == "present" + fieldsets[1].scientificName == "scientificName2" + fieldsets[1].vernacularName == "commonName2" + fieldsets[1].scientificNameID == "someguid2" + fieldsets[1].name == "scientificName2" + fieldsets[1].outputSpeciesId == "speciesFieldId2" + fieldsets[1].occurrenceID == "speciesFieldId2" + fieldsets[1].measurementsorfacts.size() == 3 + } }