Skip to content

Commit

Permalink
SW-4367 Allow batches to have photos (#1454)
Browse files Browse the repository at this point in the history
Add endpoints to create, list, read, and delete photos of seedling batches.
The read endpoint supports thumbnails via the usual `maxWidth` and `maxHeight`
query string parameters.

The batch's history will include records of photos being created and deleted.
In the case of a deleted photo, the creation event doesn't include a file ID
since the file no longer exists in the system, but it still includes the user
ID and time.
  • Loading branch information
sgrimm authored Nov 3, 2023
1 parent eded5a5 commit 60f5559
Show file tree
Hide file tree
Showing 12 changed files with 608 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ val ID_WRAPPERS =
"batch_withdrawals\\.destination_batch_id",
".*\\.batch_id",
".*\\.initial_batch_id")),
IdWrapper("BatchPhotoId", listOf("batch_photos\\.id")),
IdWrapper("BatchQuantityHistoryId", listOf("batch_quantity_history\\.id")),
IdWrapper(
"WithdrawalId",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,22 @@ import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.annotation.JsonSetter
import com.fasterxml.jackson.annotation.Nulls
import com.terraformation.backend.api.ApiResponse200
import com.terraformation.backend.api.ApiResponse200Photo
import com.terraformation.backend.api.ApiResponse404
import com.terraformation.backend.api.ApiResponse412
import com.terraformation.backend.api.ApiResponseSimpleSuccess
import com.terraformation.backend.api.NurseryEndpoint
import com.terraformation.backend.api.PHOTO_MAXHEIGHT_DESCRIPTION
import com.terraformation.backend.api.PHOTO_MAXWIDTH_DESCRIPTION
import com.terraformation.backend.api.PHOTO_OPERATION_DESCRIPTION
import com.terraformation.backend.api.RequestBodyPhotoFile
import com.terraformation.backend.api.SimpleSuccessResponsePayload
import com.terraformation.backend.api.SuccessResponsePayload
import com.terraformation.backend.api.getFilename
import com.terraformation.backend.api.getPlainContentType
import com.terraformation.backend.api.toResponseEntity
import com.terraformation.backend.db.default_schema.FacilityId
import com.terraformation.backend.db.default_schema.FileId
import com.terraformation.backend.db.default_schema.ProjectId
import com.terraformation.backend.db.default_schema.SeedTreatment
import com.terraformation.backend.db.default_schema.SpeciesId
Expand All @@ -20,29 +29,40 @@ import com.terraformation.backend.db.nursery.BatchId
import com.terraformation.backend.db.nursery.BatchQuantityHistoryType
import com.terraformation.backend.db.nursery.BatchSubstrate
import com.terraformation.backend.db.seedbank.AccessionId
import com.terraformation.backend.file.SUPPORTED_PHOTO_TYPES
import com.terraformation.backend.file.model.FileMetadata
import com.terraformation.backend.nursery.db.BatchPhotoService
import com.terraformation.backend.nursery.db.BatchStore
import com.terraformation.backend.nursery.model.ExistingBatchModel
import com.terraformation.backend.nursery.model.NewBatchModel
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import jakarta.validation.constraints.Min
import jakarta.ws.rs.QueryParam
import java.time.Instant
import java.time.LocalDate
import java.time.temporal.ChronoUnit
import org.springframework.core.io.InputStreamResource
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile

@NurseryEndpoint
@RestController
@RequestMapping("/api/v1/nursery/batches")
class BatchesController(
private val batchPhotoService: BatchPhotoService,
private val batchStore: BatchStore,
) {
@ApiResponse(responseCode = "200")
Expand Down Expand Up @@ -131,6 +151,70 @@ class BatchesController(

return getBatch(id)
}

@Operation(summary = "Creates a new photo of a seedling batch.")
@PostMapping("/{batchId}/photos", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@RequestBodyPhotoFile
fun createBatchPhoto(
@PathVariable batchId: BatchId,
@RequestPart("file") file: MultipartFile,
): CreateBatchPhotoResponsePayload {
val contentType = file.getPlainContentType(SUPPORTED_PHOTO_TYPES)
val filename = file.getFilename("photo")

val fileId =
batchPhotoService.storePhoto(
batchId, file.inputStream, FileMetadata.of(contentType, filename, file.size))

return CreateBatchPhotoResponsePayload(fileId)
}

@ApiResponse200Photo
@ApiResponse404("The batch does not exist, or does not have a photo with the requested ID.")
@GetMapping(
"/{batchId}/photos/{photoId}",
produces = [MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE])
@Operation(
summary = "Retrieves a specific photo from a seedling batch.",
description = PHOTO_OPERATION_DESCRIPTION)
@ResponseBody
fun getBatchPhoto(
@PathVariable batchId: BatchId,
@PathVariable photoId: FileId,
@QueryParam("maxWidth")
@Schema(description = PHOTO_MAXWIDTH_DESCRIPTION)
maxWidth: Int? = null,
@QueryParam("maxHeight")
@Schema(description = PHOTO_MAXHEIGHT_DESCRIPTION)
maxHeight: Int? = null,
): ResponseEntity<InputStreamResource> {
return batchPhotoService.readPhoto(batchId, photoId, maxWidth, maxHeight).toResponseEntity()
}

@ApiResponse(responseCode = "200")
@ApiResponse404("The batch does not exist.")
@GetMapping("/{batchId}/photos")
@Operation(summary = "Lists all the photos of a seedling batch.")
fun listBatchPhotos(
@PathVariable batchId: BatchId,
): ListBatchPhotosResponsePayload {
val fileIds = batchPhotoService.listPhotos(batchId).mapNotNull { it.fileId }

return ListBatchPhotosResponsePayload(fileIds.map { BatchPhotoPayload(it) })
}

@ApiResponse200
@ApiResponse404("The batch does not exist, or does not have a photo with the requested ID.")
@DeleteMapping("/{batchId}/photos/{photoId}")
@Operation(summary = "Deletes a photo from a seedling batch.")
fun deleteBatchPhoto(
@PathVariable batchId: BatchId,
@PathVariable photoId: FileId
): SimpleSuccessResponsePayload {
batchPhotoService.deletePhoto(batchId, photoId)

return SimpleSuccessResponsePayload()
}
}

@JsonInclude(JsonInclude.Include.NON_NULL)
Expand Down Expand Up @@ -295,4 +379,11 @@ data class ChangeBatchStatusRequestPayload(
}
}

data class BatchPhotoPayload(val id: FileId)

data class BatchResponsePayload(val batch: BatchPayload) : SuccessResponsePayload

data class CreateBatchPhotoResponsePayload(val id: FileId) : SuccessResponsePayload

data class ListBatchPhotosResponsePayload(val photos: List<BatchPhotoPayload>) :
SuccessResponsePayload
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.terraformation.backend.nursery.api

import com.fasterxml.jackson.annotation.JsonIgnore
import com.terraformation.backend.api.ApiResponse200
import com.terraformation.backend.api.ApiResponse404
import com.terraformation.backend.api.NurseryEndpoint
import com.terraformation.backend.api.SuccessResponsePayload
import com.terraformation.backend.customer.model.requirePermissions
import com.terraformation.backend.db.default_schema.FileId
import com.terraformation.backend.db.default_schema.ProjectId
import com.terraformation.backend.db.default_schema.SeedTreatment
import com.terraformation.backend.db.default_schema.SubLocationId
Expand All @@ -17,6 +19,7 @@ import com.terraformation.backend.db.nursery.WithdrawalId
import com.terraformation.backend.db.nursery.WithdrawalPurpose
import com.terraformation.backend.db.nursery.tables.daos.BatchDetailsHistoryDao
import com.terraformation.backend.db.nursery.tables.daos.BatchDetailsHistorySubLocationsDao
import com.terraformation.backend.db.nursery.tables.daos.BatchPhotosDao
import com.terraformation.backend.db.nursery.tables.daos.BatchQuantityHistoryDao
import com.terraformation.backend.db.nursery.tables.daos.BatchWithdrawalsDao
import com.terraformation.backend.db.nursery.tables.daos.WithdrawalsDao
Expand All @@ -42,6 +45,7 @@ import org.springframework.web.bind.annotation.RestController
class BatchesHistoryController(
private val batchDetailsHistoryDao: BatchDetailsHistoryDao,
private val batchDetailsHistorySubLocationsDao: BatchDetailsHistorySubLocationsDao,
private val batchPhotosDao: BatchPhotosDao,
private val batchQuantityHistoryDao: BatchQuantityHistoryDao,
private val batchWithdrawalsDao: BatchWithdrawalsDao,
private val withdrawalsDao: WithdrawalsDao,
Expand Down Expand Up @@ -114,7 +118,29 @@ class BatchesHistoryController(
}
}

val historyPayloads = (detailsPayloads + quantityPayloads).sortedBy { it.version }
val batchPhotos = batchPhotosDao.fetchByBatchId(batchId)
val photoCreatedPayloads =
batchPhotos.map { batchPhotosRow ->
BatchHistoryPhotoCreatedPayload(
batchPhotosRow.createdBy!!, batchPhotosRow.createdTime!!, batchPhotosRow.fileId)
}
val photoDeletedPayloads =
batchPhotos
.filter { it.deletedTime != null }
.map { batchPhotosRow ->
BatchHistoryPhotoDeletedPayload(
batchPhotosRow.deletedBy!!, batchPhotosRow.deletedTime!!)
}

val historyPayloads =
(detailsPayloads + quantityPayloads + photoCreatedPayloads + photoDeletedPayloads)
.sortedWith { a, b ->
if (a.version != null && b.version != null) {
a.version!! - b.version!!
} else {
a.createdTime.compareTo(b.createdTime)
}
}

return GetBatchHistoryResponsePayload(historyPayloads)
}
Expand All @@ -124,6 +150,8 @@ enum class BatchHistoryPayloadType {
DetailsEdited,
IncomingWithdrawal,
OutgoingWithdrawal,
PhotoCreated,
PhotoDeleted,
QuantityEdited,
StatusChanged,
}
Expand All @@ -139,6 +167,10 @@ enum class BatchHistoryPayloadType {
DiscriminatorMapping(
schema = BatchHistoryOutgoingWithdrawalPayload::class,
value = "OutgoingWithdrawal"),
DiscriminatorMapping(
schema = BatchHistoryPhotoCreatedPayload::class, value = "PhotoCreated"),
DiscriminatorMapping(
schema = BatchHistoryPhotoDeletedPayload::class, value = "PhotoDeleted"),
DiscriminatorMapping(
schema = BatchHistoryQuantityEditedPayload::class, value = "QuantityEdited"),
DiscriminatorMapping(
Expand All @@ -149,7 +181,7 @@ sealed interface BatchHistoryPayload {
val createdBy: UserId
val createdTime: Instant
val type: BatchHistoryPayloadType
val version: Int
val version: Int?
}

data class BatchHistorySubLocationPayload(
Expand Down Expand Up @@ -335,5 +367,27 @@ data class BatchHistoryOutgoingWithdrawalPayload(
get() = BatchHistoryPayloadType.OutgoingWithdrawal
}

data class BatchHistoryPhotoCreatedPayload(
override val createdBy: UserId,
override val createdTime: Instant,
@Schema(description = "ID of the photo if it exists. Null if the photo has been deleted.")
val fileId: FileId?,
) : BatchHistoryPayload {
override val type: BatchHistoryPayloadType
get() = BatchHistoryPayloadType.PhotoCreated
override val version
@JsonIgnore get() = null
}

data class BatchHistoryPhotoDeletedPayload(
override val createdBy: UserId,
override val createdTime: Instant,
) : BatchHistoryPayload {
override val type: BatchHistoryPayloadType
get() = BatchHistoryPayloadType.PhotoDeleted
override val version
@JsonIgnore get() = null
}

data class GetBatchHistoryResponsePayload(val history: List<BatchHistoryPayload>) :
SuccessResponsePayload
Loading

0 comments on commit 60f5559

Please sign in to comment.