Skip to content

Commit e4fbbff

Browse files
authored
SW-7695 Support updating biomass details (#3645)
Add an `ObservationStore` method to update biomass-specific details for observation plots, along with a persistent event to record the updates. Initially, it only supports updating qualitative data (plot description and soil assessment).
1 parent cca67d5 commit e4fbbff

File tree

8 files changed

+283
-0
lines changed

8 files changed

+283
-0
lines changed

src/main/kotlin/com/terraformation/backend/tracking/db/ObservationStore.kt

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import com.terraformation.backend.db.tracking.tables.records.RecordedTreesRecord
4646
import com.terraformation.backend.db.tracking.tables.references.MONITORING_PLOTS
4747
import com.terraformation.backend.db.tracking.tables.references.MONITORING_PLOT_HISTORIES
4848
import com.terraformation.backend.db.tracking.tables.references.OBSERVATIONS
49+
import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_BIOMASS_DETAILS
4950
import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_BIOMASS_QUADRAT_SPECIES
5051
import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_BIOMASS_SPECIES
5152
import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_PLOTS
@@ -69,6 +70,8 @@ import com.terraformation.backend.db.tracking.tables.references.RECORDED_TREES
6970
import com.terraformation.backend.log.perClassLogger
7071
import com.terraformation.backend.log.withMDC
7172
import com.terraformation.backend.tracking.event.BiomassDetailsCreatedEvent
73+
import com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEvent
74+
import com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEventValues
7275
import com.terraformation.backend.tracking.event.ObservationStateUpdatedEvent
7376
import com.terraformation.backend.tracking.event.RecordedTreeCreatedEvent
7477
import com.terraformation.backend.tracking.event.RecordedTreeUpdatedEvent
@@ -77,6 +80,7 @@ import com.terraformation.backend.tracking.event.T0PlotDataAssignedEvent
7780
import com.terraformation.backend.tracking.event.T0ZoneDataAssignedEvent
7881
import com.terraformation.backend.tracking.model.AssignedPlotDetails
7982
import com.terraformation.backend.tracking.model.BiomassSpeciesKey
83+
import com.terraformation.backend.tracking.model.EditableBiomassDetailsModel
8084
import com.terraformation.backend.tracking.model.ExistingObservationModel
8185
import com.terraformation.backend.tracking.model.ExistingRecordedTreeModel
8286
import com.terraformation.backend.tracking.model.NewBiomassDetailsModel
@@ -1211,6 +1215,74 @@ class ObservationStore(
12111215
}
12121216
}
12131217

1218+
fun updateBiomassDetails(
1219+
observationId: ObservationId,
1220+
plotId: MonitoringPlotId,
1221+
updateFunc: (EditableBiomassDetailsModel) -> EditableBiomassDetailsModel,
1222+
) {
1223+
requirePermissions { updateObservation(observationId) }
1224+
1225+
withLockedObservation(observationId) { _ ->
1226+
val existing =
1227+
with(OBSERVATION_BIOMASS_DETAILS) {
1228+
dslContext
1229+
.select(DESCRIPTION, SOIL_ASSESSMENT)
1230+
.from(OBSERVATION_BIOMASS_DETAILS)
1231+
.where(OBSERVATION_ID.eq(observationId))
1232+
.and(MONITORING_PLOT_ID.eq(plotId))
1233+
.fetchOne { EditableBiomassDetailsModel.of(it) }
1234+
?: throw ObservationPlotNotFoundException(observationId, plotId)
1235+
}
1236+
1237+
val updated = updateFunc(existing)
1238+
1239+
val changedFrom =
1240+
BiomassDetailsUpdatedEventValues(
1241+
description = existing.description.nullIfEquals(updated.description),
1242+
soilAssessment = existing.soilAssessment.nullIfEquals(updated.soilAssessment),
1243+
)
1244+
val changedTo =
1245+
BiomassDetailsUpdatedEventValues(
1246+
description = updated.description.nullIfEquals(existing.description),
1247+
soilAssessment = updated.soilAssessment.nullIfEquals(existing.soilAssessment),
1248+
)
1249+
1250+
if (changedFrom != changedTo) {
1251+
with(OBSERVATION_BIOMASS_DETAILS) {
1252+
dslContext
1253+
.update(OBSERVATION_BIOMASS_DETAILS)
1254+
.set(DESCRIPTION, updated.description)
1255+
.set(SOIL_ASSESSMENT, updated.soilAssessment)
1256+
.where(OBSERVATION_ID.eq(observationId))
1257+
.and(MONITORING_PLOT_ID.eq(plotId))
1258+
.execute()
1259+
1260+
val (plantingSiteId, organizationId) =
1261+
dslContext
1262+
.select(
1263+
monitoringPlots.plantingSites.ID.asNonNullable(),
1264+
monitoringPlots.plantingSites.ORGANIZATION_ID.asNonNullable(),
1265+
)
1266+
.from(OBSERVATION_BIOMASS_DETAILS)
1267+
.where(OBSERVATION_ID.eq(observationId))
1268+
.and(MONITORING_PLOT_ID.eq(plotId))
1269+
.fetchSingle()
1270+
1271+
eventPublisher.publishEvent(
1272+
BiomassDetailsUpdatedEvent(
1273+
changedFrom = changedFrom,
1274+
changedTo = changedTo,
1275+
monitoringPlotId = plotId,
1276+
observationId = observationId,
1277+
organizationId = organizationId,
1278+
plantingSiteId = plantingSiteId,
1279+
)
1280+
)
1281+
}
1282+
}
1283+
}
1284+
}
1285+
12141286
fun updateRecordedTree(
12151287
observationId: ObservationId,
12161288
recordedTreeId: RecordedTreeId,

src/main/kotlin/com/terraformation/backend/tracking/event/Events.kt

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,34 @@ data class BiomassDetailsCreatedEventV1(
329329

330330
typealias BiomassDetailsCreatedEvent = BiomassDetailsCreatedEventV1
331331

332+
data class BiomassDetailsUpdatedEventV1(
333+
val changedFrom: Values,
334+
val changedTo: Values,
335+
override val monitoringPlotId: MonitoringPlotId,
336+
override val observationId: ObservationId,
337+
override val organizationId: OrganizationId,
338+
override val plantingSiteId: PlantingSiteId,
339+
) : FieldsUpdatedPersistentEvent, BiomassDetailsPersistentEvent {
340+
data class Values(
341+
val description: String?,
342+
val soilAssessment: String?,
343+
)
344+
345+
override fun listUpdatedFields() =
346+
listOfNotNull(
347+
createUpdatedField("description", changedFrom.description, changedTo.description),
348+
createUpdatedField(
349+
"soilAssessment",
350+
changedFrom.soilAssessment,
351+
changedTo.soilAssessment,
352+
),
353+
)
354+
}
355+
356+
typealias BiomassDetailsUpdatedEvent = BiomassDetailsUpdatedEventV1
357+
358+
typealias BiomassDetailsUpdatedEventValues = BiomassDetailsUpdatedEventV1.Values
359+
332360
sealed interface RecordedTreePersistentEvent : PersistentEvent {
333361
val monitoringPlotId: MonitoringPlotId
334362
val observationId: ObservationId
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.terraformation.backend.tracking.model
2+
3+
import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_BIOMASS_DETAILS
4+
import org.jooq.Record
5+
6+
data class EditableBiomassDetailsModel(
7+
val description: String?,
8+
val soilAssessment: String,
9+
) {
10+
companion object {
11+
fun of(record: Record): EditableBiomassDetailsModel {
12+
return with(OBSERVATION_BIOMASS_DETAILS) {
13+
EditableBiomassDetailsModel(
14+
description = record[DESCRIPTION],
15+
soilAssessment = record[SOIL_ASSESSMENT]!!,
16+
)
17+
}
18+
}
19+
}
20+
}

src/main/resources/i18n/Messages_en.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ csvSubLocationNotFound=Sub-location does not exist
8080
csvWrongFieldCount=Expected row to have {0} fields, not {1}
8181
disabled=Disabled
8282
enabled=Enabled
83+
eventSubject.BiomassDetails.field.description=plot description
84+
eventSubject.BiomassDetails.field.soilAssessment=soil description/notes
8385
eventSubject.BiomassDetails.full=Biomass observation {0}
8486
eventSubject.BiomassDetails.short=Observation
8587
eventSubject.ObservationPlotMedia.field.caption=caption

src/main/resources/i18n/Messages_es.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ csvWrongFieldCount=Se espera que la fila tenga {0} campos, no {1}.
137137
disabled=Deshabilitado
138138
# 0cbb9a69
139139
enabled=Habilitado
140+
# 906d63eb
141+
eventSubject.BiomassDetails.field.description=descripción de la parcela
142+
# f5f2468c
143+
eventSubject.BiomassDetails.field.soilAssessment=descripción/notas del suelo
140144
# 0bf84458
141145
eventSubject.BiomassDetails.full=Observación de biomasa {0}
142146
# ac4f24f0

src/main/resources/i18n/Messages_fr.properties

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ csvWrongFieldCount=La ligne devait contenir {0} champs, pas {1}
137137
disabled=Désactivé
138138
# 0cbb9a69
139139
enabled=Activé
140+
# 906d63eb
141+
eventSubject.BiomassDetails.field.description=description de la parcelle
142+
# f5f2468c
143+
eventSubject.BiomassDetails.field.soilAssessment=description/remarques sur le sol
140144
# 0bf84458
141145
eventSubject.BiomassDetails.full=Observation de la biomasse {0}
142146
# ac4f24f0
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package com.terraformation.backend.tracking.db.observationStore
2+
3+
import com.terraformation.backend.db.tracking.ObservationType
4+
import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_BIOMASS_DETAILS
5+
import com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEvent
6+
import com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEventValues
7+
import io.mockk.every
8+
import org.junit.jupiter.api.BeforeEach
9+
import org.junit.jupiter.api.Test
10+
import org.junit.jupiter.api.assertThrows
11+
import org.springframework.security.access.AccessDeniedException
12+
13+
class ObservationStoreUpdateBiomassDetailsTest : BaseObservationStoreTest() {
14+
@BeforeEach
15+
fun setUpBiomassDetails() {
16+
insertPlantingZone()
17+
insertPlantingSubzone()
18+
insertMonitoringPlot(isAdHoc = true)
19+
insertObservation(isAdHoc = true, observationType = ObservationType.BiomassMeasurements)
20+
insertObservationPlot(completedBy = user.userId)
21+
insertObservationBiomassDetails(
22+
description = "Original description",
23+
soilAssessment = "Original soil assessment",
24+
)
25+
}
26+
27+
@Test
28+
fun `updates editable fields`() {
29+
val before = dslContext.fetchSingle(OBSERVATION_BIOMASS_DETAILS)
30+
31+
store.updateBiomassDetails(inserted.observationId, inserted.monitoringPlotId) {
32+
it.copy(
33+
description = "New description",
34+
soilAssessment = "New soil assessment",
35+
)
36+
}
37+
38+
val expected =
39+
before.copy().apply {
40+
description = "New description"
41+
soilAssessment = "New soil assessment"
42+
}
43+
44+
assertTableEquals(expected)
45+
46+
eventPublisher.assertEventPublished(
47+
BiomassDetailsUpdatedEvent(
48+
changedFrom =
49+
BiomassDetailsUpdatedEventValues(
50+
description = "Original description",
51+
soilAssessment = "Original soil assessment",
52+
),
53+
changedTo =
54+
BiomassDetailsUpdatedEventValues(
55+
description = "New description",
56+
soilAssessment = "New soil assessment",
57+
),
58+
monitoringPlotId = inserted.monitoringPlotId,
59+
observationId = inserted.observationId,
60+
organizationId = inserted.organizationId,
61+
plantingSiteId = inserted.plantingSiteId,
62+
)
63+
)
64+
}
65+
66+
@Test
67+
fun `updates only description when soil assessment unchanged`() {
68+
val before = dslContext.fetchSingle(OBSERVATION_BIOMASS_DETAILS)
69+
70+
store.updateBiomassDetails(inserted.observationId, inserted.monitoringPlotId) {
71+
it.copy(description = "New description")
72+
}
73+
74+
val expected = before.copy().apply { description = "New description" }
75+
76+
assertTableEquals(expected)
77+
78+
eventPublisher.assertEventPublished(
79+
BiomassDetailsUpdatedEvent(
80+
changedFrom =
81+
BiomassDetailsUpdatedEventValues(
82+
description = "Original description",
83+
soilAssessment = null,
84+
),
85+
changedTo =
86+
BiomassDetailsUpdatedEventValues(
87+
description = "New description",
88+
soilAssessment = null,
89+
),
90+
monitoringPlotId = inserted.monitoringPlotId,
91+
observationId = inserted.observationId,
92+
organizationId = inserted.organizationId,
93+
plantingSiteId = inserted.plantingSiteId,
94+
)
95+
)
96+
}
97+
98+
@Test
99+
fun `updates only soil assessment when description unchanged`() {
100+
val before = dslContext.fetchSingle(OBSERVATION_BIOMASS_DETAILS)
101+
102+
store.updateBiomassDetails(inserted.observationId, inserted.monitoringPlotId) {
103+
it.copy(soilAssessment = "New soil assessment")
104+
}
105+
106+
val expected = before.copy().apply { soilAssessment = "New soil assessment" }
107+
108+
assertTableEquals(expected)
109+
110+
eventPublisher.assertEventPublished(
111+
BiomassDetailsUpdatedEvent(
112+
changedFrom =
113+
BiomassDetailsUpdatedEventValues(
114+
description = null,
115+
soilAssessment = "Original soil assessment",
116+
),
117+
changedTo =
118+
BiomassDetailsUpdatedEventValues(
119+
description = null,
120+
soilAssessment = "New soil assessment",
121+
),
122+
monitoringPlotId = inserted.monitoringPlotId,
123+
observationId = inserted.observationId,
124+
organizationId = inserted.organizationId,
125+
plantingSiteId = inserted.plantingSiteId,
126+
)
127+
)
128+
}
129+
130+
@Test
131+
fun `does not publish event or modify database if nothing changed`() {
132+
val unmodifiedTable = dslContext.fetch(OBSERVATION_BIOMASS_DETAILS)
133+
134+
store.updateBiomassDetails(inserted.observationId, inserted.monitoringPlotId) { it }
135+
136+
assertTableEquals(unmodifiedTable)
137+
138+
eventPublisher.assertEventNotPublished<BiomassDetailsUpdatedEvent>()
139+
}
140+
141+
@Test
142+
fun `throws exception if no permission to update observation`() {
143+
every { user.canUpdateObservation(inserted.observationId) } returns false
144+
145+
assertThrows<AccessDeniedException> {
146+
store.updateBiomassDetails(inserted.observationId, inserted.monitoringPlotId) { it }
147+
}
148+
}
149+
}

src/test/resources/eventlog/eventProperties.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ com.terraformation.backend.tracking.event.BiomassDetailsCreatedEventV1/plantingS
4646
com.terraformation.backend.tracking.event.BiomassDetailsCreatedEventV1/smallTreesCountHigh: Int
4747
com.terraformation.backend.tracking.event.BiomassDetailsCreatedEventV1/smallTreesCountLow: Int
4848
com.terraformation.backend.tracking.event.BiomassDetailsCreatedEventV1/soilAssessment: String
49+
com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEventV1/monitoringPlotId: MonitoringPlotId
50+
com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEventV1/observationId: ObservationId
51+
com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEventV1/organizationId: OrganizationId
52+
com.terraformation.backend.tracking.event.BiomassDetailsUpdatedEventV1/plantingSiteId: PlantingSiteId
4953
com.terraformation.backend.tracking.event.RecordedTreeCreatedEventV1/biomassSpeciesId: BiomassSpeciesId
5054
com.terraformation.backend.tracking.event.RecordedTreeCreatedEventV1/isDead: Boolean
5155
com.terraformation.backend.tracking.event.RecordedTreeCreatedEventV1/monitoringPlotId: MonitoringPlotId

0 commit comments

Comments
 (0)