diff --git a/backend/src/main/kotlin/org/loculus/backend/config/Config.kt b/backend/src/main/kotlin/org/loculus/backend/config/Config.kt index a2ee9d98f4..a56907619b 100644 --- a/backend/src/main/kotlin/org/loculus/backend/config/Config.kt +++ b/backend/src/main/kotlin/org/loculus/backend/config/Config.kt @@ -23,8 +23,11 @@ data class Schema( val metadata: List, val externalMetadata: List = emptyList(), val earliestReleaseDate: EarliestReleaseDate = EarliestReleaseDate(false, emptyList()), + val submissionDataTypes: SubmissionDataTypes = SubmissionDataTypes(), ) +data class SubmissionDataTypes(val consensusSequences: Boolean = true) + // The Json property names need to be kept in sync with website config enum `metadataPossibleTypes` in `config.ts` // They also need to be in sync with SILO database config, as the Loculus config is a sort of superset of it // See https://lapis.cov-spectrum.org/gisaid/v2/docs/maintainer-docs/references/database-configuration#metadata-types diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt index 7420815501..88ba74b6ad 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt @@ -86,7 +86,7 @@ open class SubmissionController( @HiddenParam authenticatedUser: AuthenticatedUser, @Parameter(description = GROUP_ID_DESCRIPTION) @RequestParam groupId: Int, @Parameter(description = METADATA_FILE_DESCRIPTION) @RequestParam metadataFile: MultipartFile, - @Parameter(description = SEQUENCE_FILE_DESCRIPTION) @RequestParam sequenceFile: MultipartFile, + @Parameter(description = SEQUENCE_FILE_DESCRIPTION) @RequestParam sequenceFile: MultipartFile?, @Parameter(description = "Data Use terms under which data is released.") @RequestParam dataUseTermsType: DataUseTermsType, @Parameter( @@ -118,7 +118,7 @@ open class SubmissionController( ) @RequestParam metadataFile: MultipartFile, @Parameter( description = SEQUENCE_FILE_DESCRIPTION, - ) @RequestParam sequenceFile: MultipartFile, + ) @RequestParam sequenceFile: MultipartFile?, ): List { val params = SubmissionParams.RevisionSubmissionParams( organism, @@ -172,7 +172,9 @@ open class SubmissionController( } val lastDatabaseWriteETag = releasedDataModel.getLastDatabaseWriteETag() - if (ifNoneMatch == lastDatabaseWriteETag) return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build() + if (ifNoneMatch == lastDatabaseWriteETag) { + return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build() + } val headers = HttpHeaders() headers.contentType = MediaType.parseMediaType(MediaType.APPLICATION_NDJSON_VALUE) diff --git a/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt b/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt index 3c17f0157e..b2e1fcfb28 100644 --- a/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt +++ b/backend/src/main/kotlin/org/loculus/backend/model/SubmitModel.kt @@ -8,6 +8,7 @@ import org.loculus.backend.api.DataUseTerms import org.loculus.backend.api.Organism import org.loculus.backend.api.SubmissionIdMapping import org.loculus.backend.auth.AuthenticatedUser +import org.loculus.backend.config.BackendConfig import org.loculus.backend.controller.BadRequestException import org.loculus.backend.controller.DuplicateKeyException import org.loculus.backend.controller.UnprocessableEntityException @@ -41,14 +42,14 @@ interface SubmissionParams { val organism: Organism val authenticatedUser: AuthenticatedUser val metadataFile: MultipartFile - val sequenceFile: MultipartFile + val sequenceFile: MultipartFile? val uploadType: UploadType data class OriginalSubmissionParams( override val organism: Organism, override val authenticatedUser: AuthenticatedUser, override val metadataFile: MultipartFile, - override val sequenceFile: MultipartFile, + override val sequenceFile: MultipartFile?, val groupId: Int, val dataUseTerms: DataUseTerms, ) : SubmissionParams { @@ -59,7 +60,7 @@ interface SubmissionParams { override val organism: Organism, override val authenticatedUser: AuthenticatedUser, override val metadataFile: MultipartFile, - override val sequenceFile: MultipartFile, + override val sequenceFile: MultipartFile?, ) : SubmissionParams { override val uploadType: UploadType = UploadType.REVISION } @@ -76,6 +77,7 @@ class SubmitModel( private val groupManagementPreconditionValidator: GroupManagementPreconditionValidator, private val dataUseTermsPreconditionValidator: DataUseTermsPreconditionValidator, private val dateProvider: DateProvider, + private val backendConfig: BackendConfig, ) { companion object AcceptedFileTypes { @@ -106,9 +108,11 @@ class SubmitModel( batchSize, ) - log.debug { "Validating submission with uploadId $uploadId" } - val (metadataSubmissionIds, sequencesSubmissionIds) = uploadDatabaseService.getUploadSubmissionIds(uploadId) - validateSubmissionIdSets(metadataSubmissionIds.toSet(), sequencesSubmissionIds.toSet()) + if (requiresConsensusSequenceFile(submissionParams.organism)) { + log.debug { "Validating submission with uploadId $uploadId" } + val (metadataSubmissionIds, sequencesSubmissionIds) = uploadDatabaseService.getUploadSubmissionIds(uploadId) + validateSubmissionIdSets(metadataSubmissionIds.toSet(), sequencesSubmissionIds.toSet()) + } if (submissionParams is SubmissionParams.RevisionSubmissionParams) { log.info { "Associating uploaded sequence data with existing sequence entries with uploadId $uploadId" } @@ -150,17 +154,32 @@ class SubmitModel( metadataTempFileToDelete.delete() } - val sequenceTempFileToDelete = MaybeFile() - try { - val sequenceStream = getStreamFromFile( - submissionParams.sequenceFile, - uploadId, - sequenceFileTypes, - sequenceTempFileToDelete, - ) - uploadSequences(uploadId, sequenceStream, batchSize, submissionParams.organism) - } finally { - sequenceTempFileToDelete.delete() + val sequenceFile = submissionParams.sequenceFile + if (sequenceFile == null) { + if (requiresConsensusSequenceFile(submissionParams.organism)) { + throw BadRequestException( + "Submissions for organism ${submissionParams.organism.name} require a sequence file.", + ) + } + } else { + if (!requiresConsensusSequenceFile(submissionParams.organism)) { + throw BadRequestException( + "Sequence uploads are not allowed for organism ${submissionParams.organism.name}.", + ) + } + + val sequenceTempFileToDelete = MaybeFile() + try { + val sequenceStream = getStreamFromFile( + sequenceFile, + uploadId, + sequenceFileTypes, + sequenceTempFileToDelete, + ) + uploadSequences(uploadId, sequenceStream, batchSize, submissionParams.organism) + } finally { + sequenceTempFileToDelete.delete() + } } } @@ -324,4 +343,9 @@ class SubmitModel( SequenceUploadAuxTable.select(SequenceUploadAuxTable.sequenceSubmissionIdColumn).count() > 0 return metadataInAuxTable || sequencesInAuxTable } + + private fun requiresConsensusSequenceFile(organism: Organism) = backendConfig.getInstanceConfig(organism) + .schema + .submissionDataTypes + .consensusSequences } diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt index 17cff685b4..900ce64ead 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/UploadDatabaseService.kt @@ -149,14 +149,17 @@ class UploadDatabaseService( jsonb_build_object( 'metadata', metadata_upload_aux_table.metadata, 'unalignedNucleotideSequences', - jsonb_object_agg( - sequence_upload_aux_table.segment_name, - sequence_upload_aux_table.compressed_sequence_data::jsonb + COALESCE( + jsonb_object_agg( + sequence_upload_aux_table.segment_name, + sequence_upload_aux_table.compressed_sequence_data::jsonb + ) FILTER (WHERE sequence_upload_aux_table.segment_name IS NOT NULL), + '{}'::jsonb ) ) FROM metadata_upload_aux_table - JOIN + LEFT JOIN sequence_upload_aux_table ON metadata_upload_aux_table.upload_id = sequence_upload_aux_table.upload_id AND metadata_upload_aux_table.submission_id = sequence_upload_aux_table.submission_id diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt b/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt index 6cf573c562..d28c7ed1f9 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/TestHelpers.kt @@ -31,6 +31,7 @@ import org.testcontainers.shaded.org.awaitility.Awaitility.await const val DEFAULT_ORGANISM = "dummyOrganism" const val OTHER_ORGANISM = "otherOrganism" +const val ORGANISM_WITHOUT_CONSENSUS_SEQUENCES = "dummyOrganismWithoutConsensusSequences" const val DEFAULT_PIPELINE_VERSION = 1L const val DEFAULT_EXTERNAL_METADATA_UPDATER = "ena" diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt index 52bbfa62ce..85a1470306 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ExtractUnprocessedDataEndpointTest.kt @@ -5,6 +5,7 @@ import org.hamcrest.CoreMatchers.hasItem import org.hamcrest.CoreMatchers.`is` import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anEmptyMap import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.empty import org.hamcrest.Matchers.greaterThan @@ -12,6 +13,8 @@ import org.hamcrest.Matchers.hasProperty import org.hamcrest.Matchers.hasSize import org.hamcrest.Matchers.matchesRegex import org.junit.jupiter.api.Test +import org.loculus.backend.api.GeneticSequence +import org.loculus.backend.api.OriginalData import org.loculus.backend.api.Status.IN_PROCESSING import org.loculus.backend.api.Status.RECEIVED import org.loculus.backend.api.UnprocessedData @@ -19,6 +22,7 @@ import org.loculus.backend.config.BackendSpringProperty import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.DEFAULT_USER_NAME import org.loculus.backend.controller.EndpointTest +import org.loculus.backend.controller.ORGANISM_WITHOUT_CONSENSUS_SEQUENCES import org.loculus.backend.controller.OTHER_ORGANISM import org.loculus.backend.controller.assertStatusIs import org.loculus.backend.controller.expectForbiddenResponse @@ -27,7 +31,6 @@ import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.getAccessionVersions import org.loculus.backend.controller.jwtForDefaultUser import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles -import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.NUMBER_OF_SEQUENCES import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpHeaders.ETAG import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header @@ -181,4 +184,37 @@ class ExtractUnprocessedDataEndpointTest( `is`(empty()), ) } + + @Test + fun `GIVEN entries for organism without consensus sequences THEN only returns metadata`() { + val submissionResult = convenienceClient.submitDefaultFiles(organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES) + val accessionVersions = submissionResult.submissionIdMappings + + val result = client.extractUnprocessedData( + numberOfSequenceEntries = DefaultFiles.NUMBER_OF_SEQUENCES, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + val responseBody = result.expectNdjsonAndGetContent() + assertThat(responseBody, hasSize(DefaultFiles.NUMBER_OF_SEQUENCES)) + assertThat( + responseBody, + hasItem( + allOf( + hasProperty("accession", `is`(accessionVersions[0].accession)), + hasProperty("version", `is`(1L)), + hasProperty( + "data", + allOf( + hasProperty>("metadata", `is`(defaultOriginalData.metadata)), + hasProperty("unalignedNucleotideSequences", `is`(anEmptyMap())), + ), + ), + hasProperty("submissionId", matchesRegex("custom[0-9]")), + hasProperty("submitter", `is`(DEFAULT_USER_NAME)), + hasProperty("groupId", `is`(submissionResult.groupId)), + hasProperty("submittedAt", greaterThan(1_700_000_000L)), + ), + ), + ) + } } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt index 7f4a57a931..0939aafcaf 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/PreparedProcessedData.kt @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.node.IntNode import com.fasterxml.jackson.databind.node.NullNode import com.fasterxml.jackson.databind.node.TextNode import org.loculus.backend.api.GeneName +import org.loculus.backend.api.GeneticSequence import org.loculus.backend.api.Insertion import org.loculus.backend.api.PreprocessingAnnotation import org.loculus.backend.api.PreprocessingAnnotationSource @@ -99,6 +100,21 @@ val defaultProcessedDataMultiSegmented = ProcessedData( ), ) +val defaultProcessedDataWithoutSequences = ProcessedData( + metadata = mapOf( + "date" to TextNode("2002-12-15"), + "host" to TextNode("google.com"), + "region" to TextNode("Europe"), + "country" to TextNode("Spain"), + "division" to NullNode.instance, + ), + unalignedNucleotideSequences = emptyMap(), + alignedNucleotideSequences = emptyMap(), + nucleotideInsertions = emptyMap(), + alignedAminoAcidSequences = emptyMap(), + aminoAcidInsertions = emptyMap(), +) + private val defaultSuccessfulSubmittedData = SubmittedProcessedData( accession = "If a test result shows this, processed data was not prepared correctly.", version = 1, diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt index 4d9c361c3d..a689e65fa3 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/ReviseEndpointTest.kt @@ -18,6 +18,7 @@ import org.loculus.backend.api.UnprocessedData import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.DEFAULT_USER_NAME import org.loculus.backend.controller.EndpointTest +import org.loculus.backend.controller.ORGANISM_WITHOUT_CONSENSUS_SEQUENCES import org.loculus.backend.controller.OTHER_ORGANISM import org.loculus.backend.controller.SUPER_USER_NAME import org.loculus.backend.controller.assertStatusIs @@ -29,7 +30,7 @@ import org.loculus.backend.controller.groupmanagement.andGetGroupId import org.loculus.backend.controller.jwtForSuperUser import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles import org.springframework.beans.factory.annotation.Autowired -import org.springframework.http.MediaType +import org.springframework.http.MediaType.APPLICATION_JSON_VALUE import org.springframework.mock.web.MockMultipartFile import org.springframework.test.web.servlet.ResultMatcher import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content @@ -67,7 +68,7 @@ class ReviseEndpointTest( jwt = jwtForSuperUser, ) .andExpect(status().isOk) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(DefaultFiles.NUMBER_OF_SEQUENCES)) .andExpect(jsonPath("\$[0].submissionId").value("custom0")) .andExpect(jsonPath("\$[0].accession").value(accessions.first())) @@ -86,7 +87,7 @@ class ReviseEndpointTest( DefaultFiles.sequencesFile, ) .andExpect(status().isOk) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.length()").value(DefaultFiles.NUMBER_OF_SEQUENCES)) .andExpect(jsonPath("\$[0].submissionId").value("custom0")) .andExpect(jsonPath("\$[0].accession").value(accessions.first())) @@ -129,7 +130,7 @@ class ReviseEndpointTest( ), SubmitFiles.sequenceFileWith(), ).andExpect(status().isUnprocessableEntity) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect( jsonPath("\$.detail").value( "Accessions 123 do not exist", @@ -149,7 +150,7 @@ class ReviseEndpointTest( organism = OTHER_ORGANISM, ) .andExpect(status().isUnprocessableEntity) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect( jsonPath("\$.detail").value( containsString("accession versions are not of organism otherOrganism:"), @@ -168,7 +169,7 @@ class ReviseEndpointTest( jwt = generateJwtFor(notSubmitter), ) .andExpect(status().isForbidden) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect( jsonPath( "\$.detail", @@ -186,7 +187,7 @@ class ReviseEndpointTest( DefaultFiles.sequencesFile, ) .andExpect(status().isUnprocessableEntity) - .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) .andExpect( jsonPath( "\$.detail", @@ -198,6 +199,64 @@ class ReviseEndpointTest( ) } + @Test + fun `GIVEN no consensus sequences file for organism that requires one THEN throws bad request error`() { + val accessions = convenienceClient.prepareDataTo(APPROVED_FOR_RELEASE).map { it.accession } + + client.reviseSequenceEntries( + metadataFile = DefaultFiles.getRevisedMetadataFile(accessions), + sequencesFile = null, + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) + .andExpect( + jsonPath("\$.detail").value("Submissions for organism $DEFAULT_ORGANISM require a sequence file."), + ) + } + + @Test + fun `GIVEN sequence file for organism without consensus sequences THEN returns bad request`() { + val accessions = convenienceClient.prepareDataTo( + status = APPROVED_FOR_RELEASE, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + .map { it.accession } + + client.reviseSequenceEntries( + DefaultFiles.getRevisedMetadataFile(accessions), + DefaultFiles.sequencesFile, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) + .andExpect( + jsonPath( + "\$.detail", + ).value("Sequence uploads are not allowed for organism $ORGANISM_WITHOUT_CONSENSUS_SEQUENCES."), + ) + } + + @Test + fun `GIVEN no sequence file for organism without consensus sequences THEN data is accepted`() { + val accessions = convenienceClient.prepareDataTo( + status = APPROVED_FOR_RELEASE, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + .map { it.accession } + + client.reviseSequenceEntries( + metadataFile = DefaultFiles.getRevisedMetadataFile(accessions), + sequencesFile = null, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + .andExpect(status().isOk) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$.length()").value(DefaultFiles.NUMBER_OF_SEQUENCES)) + .andExpect(jsonPath("\$[0].submissionId").value("custom0")) + .andExpect(jsonPath("\$[0].accession").value(accessions.first())) + .andExpect(jsonPath("\$[0].version").value(2)) + } + @ParameterizedTest(name = "GIVEN {0} THEN throws error \"{5}\"") @MethodSource("badRequestForRevision") fun `GIVEN invalid data THEN throws bad request`( @@ -231,7 +290,7 @@ class ReviseEndpointTest( SubmitFiles.sequenceFileWith(name = "notSequencesFile"), status().isBadRequest, "Bad Request", - "Required part 'sequenceFile' is not present.", + "Submissions for organism $DEFAULT_ORGANISM require a sequence file.", ), Arguments.of( "wrong extension for metadata file", diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt index cc97aa4fcc..eba76f6dd8 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionControllerClient.kt @@ -33,14 +33,16 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post class SubmissionControllerClient(private val mockMvc: MockMvc, private val objectMapper: ObjectMapper) { fun submit( metadataFile: MockMultipartFile, - sequencesFile: MockMultipartFile, + sequencesFile: MockMultipartFile? = null, organism: String = DEFAULT_ORGANISM, groupId: Int, dataUseTerm: DataUseTerms = DataUseTerms.Open, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( multipart(addOrganismToPath("/submit", organism = organism)) - .file(sequencesFile) + .apply { + sequencesFile?.let { file(sequencesFile) } + } .file(metadataFile) .param("groupId", groupId.toString()) .param("dataUseTermsType", dataUseTerm.type.name) @@ -175,9 +177,11 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec .content( """{ "accessionVersionsFilter": ${serialize(accessionVersionsFilter)}, - ${submitterNamesFilter?.let { - """"submitterNamesFilter": [${it.joinToString(",") { name -> "\"$name\"" }}],""" - } ?: ""} + ${ + submitterNamesFilter?.let { + """"submitterNamesFilter": [${it.joinToString(",") { name -> "\"$name\"" }}],""" + } ?: "" + } "scope": "$scope" }""", ) @@ -243,12 +247,14 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec fun reviseSequenceEntries( metadataFile: MockMultipartFile, - sequencesFile: MockMultipartFile, + sequencesFile: MockMultipartFile?, organism: String = DEFAULT_ORGANISM, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( multipart(addOrganismToPath("/revise", organism = organism)) - .file(sequencesFile) + .apply { + sequencesFile?.let { file(sequencesFile) } + } .file(metadataFile) .withAuth(jwt), ) diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt index 1cc5ab2299..61062f479c 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionConvenienceClient.kt @@ -11,6 +11,7 @@ import org.loculus.backend.api.EditedSequenceEntryData import org.loculus.backend.api.GeneticSequence import org.loculus.backend.api.GetSequenceResponse import org.loculus.backend.api.Organism +import org.loculus.backend.api.OriginalData import org.loculus.backend.api.ProcessedData import org.loculus.backend.api.ProcessingResult import org.loculus.backend.api.SequenceEntryStatus @@ -24,6 +25,7 @@ import org.loculus.backend.controller.DEFAULT_GROUP import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.DEFAULT_PIPELINE_VERSION import org.loculus.backend.controller.DEFAULT_USER_NAME +import org.loculus.backend.controller.ORGANISM_WITHOUT_CONSENSUS_SEQUENCES import org.loculus.backend.controller.OTHER_ORGANISM import org.loculus.backend.controller.expectNdjsonAndGetContent import org.loculus.backend.controller.generateJwtFor @@ -57,14 +59,21 @@ class SubmissionConvenienceClient( .createNewGroup(group = DEFAULT_GROUP, jwt = generateJwtFor(username)) .andGetGroupId() - val isMultiSegmented = backendConfig - .getInstanceConfig(Organism(organism)) + val instanceConfig = backendConfig.getInstanceConfig(Organism(organism)) + + val isMultiSegmented = instanceConfig .referenceGenomes .nucleotideSequences.size > 1 + val doesNotAllowConsensusSequenceFile = !instanceConfig.schema + .submissionDataTypes + .consensusSequences + val submit = client.submit( DefaultFiles.metadataFile, - if (isMultiSegmented) { + if (doesNotAllowConsensusSequenceFile) { + null + } else if (isMultiSegmented) { DefaultFiles.sequencesFileMultiSegmented } else { DefaultFiles.sequencesFile @@ -153,6 +162,11 @@ class SubmissionConvenienceClient( accession = it.accession, ) + ORGANISM_WITHOUT_CONSENSUS_SEQUENCES -> PreparedProcessedData.successfullyProcessed( + accession = it.accession, + ) + .copy(data = defaultProcessedDataWithoutSequences) + else -> throw Exception("Test issue: There is no mapping of processed data for organism $organism") } }.toTypedArray(), @@ -303,15 +317,33 @@ class SubmissionConvenienceClient( ), ).processingResultCounts[processingResult]!!.toInt() - fun submitDefaultEditedData(accessions: List, userName: String = DEFAULT_USER_NAME) { + fun submitEditedData( + accessions: List, + organism: String = DEFAULT_ORGANISM, + userName: String = DEFAULT_USER_NAME, + editedData: OriginalData, + ) { accessions.forEach { accession -> client.submitEditedSequenceEntryVersion( - EditedSequenceEntryData(accession, 1L, defaultOriginalData), + EditedSequenceEntryData(accession, 1L, editedData), jwt = generateJwtFor(userName), + organism = organism, ) + .andExpect(status().isNoContent) } } + fun submitDefaultEditedData( + accessions: List, + organism: String = DEFAULT_ORGANISM, + userName: String = DEFAULT_USER_NAME, + ) = submitEditedData( + accessions, + organism = organism, + userName = userName, + editedData = defaultOriginalData, + ) + fun approveProcessedSequenceEntries( accessionVersionsFilter: List, organism: String = DEFAULT_ORGANISM, diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt index aa501cf1f8..7ef673d7f2 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmissionJourneyTest.kt @@ -1,6 +1,7 @@ package org.loculus.backend.controller.submission import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.anEmptyMap import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.empty import org.hamcrest.Matchers.`is` @@ -8,6 +9,7 @@ import org.junit.jupiter.api.Test import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.AccessionVersionInterface import org.loculus.backend.api.GeneticSequence +import org.loculus.backend.api.OriginalData import org.loculus.backend.api.ProcessedData import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE import org.loculus.backend.api.Status.IN_PROCESSING @@ -15,6 +17,7 @@ import org.loculus.backend.api.Status.PROCESSED import org.loculus.backend.api.Status.RECEIVED import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.EndpointTest +import org.loculus.backend.controller.ORGANISM_WITHOUT_CONSENSUS_SEQUENCES import org.loculus.backend.controller.OTHER_ORGANISM import org.loculus.backend.controller.assertHasError import org.loculus.backend.controller.assertStatusIs @@ -170,6 +173,75 @@ class SubmissionJourneyTest(@Autowired val convenienceClient: SubmissionConvenie ) } + @Test + fun `Entries without consensus sequences - submission, edit, approval`() { + val accessions = convenienceClient.submitDefaultFiles(organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES) + .submissionIdMappings + .map { it.accession } + + val getSequenceEntry = { + convenienceClient.getSequenceEntry( + accession = accessions.first(), + version = 1, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + } + + getSequenceEntry().assertStatusIs(RECEIVED) + + convenienceClient.extractUnprocessedData(organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES) + convenienceClient.submitProcessedData( + accessions.map { + PreparedProcessedData.withErrors(accession = it) + .copy(data = defaultProcessedDataWithoutSequences) + }, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + + getSequenceEntry().assertStatusIs(PROCESSED) + .assertHasError(true) + + convenienceClient.submitEditedData( + accessions, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + editedData = OriginalData( + metadata = defaultOriginalData.metadata, + unalignedNucleotideSequences = emptyMap(), + ), + ) + getSequenceEntry().assertStatusIs(RECEIVED) + + convenienceClient.extractUnprocessedData(organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES) + getSequenceEntry().assertStatusIs(IN_PROCESSING) + + convenienceClient.submitProcessedData( + accessions.map { + PreparedProcessedData.successfullyProcessed(accession = it) + .copy(data = defaultProcessedDataWithoutSequences) + }, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + getSequenceEntry().assertStatusIs(PROCESSED) + .assertHasError(false) + + convenienceClient.approveProcessedSequenceEntries( + accessions.map { + AccessionVersion(it, 1) + }, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + ) + getSequenceEntry().assertStatusIs(APPROVED_FOR_RELEASE) + + val releasedData = convenienceClient.getReleasedData(organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES) + assertThat(releasedData.size, `is`(DefaultFiles.NUMBER_OF_SEQUENCES)) + val releasedDatum = releasedData.first() + assertThat(releasedDatum.unalignedNucleotideSequences, `is`(anEmptyMap())) + assertThat(releasedDatum.alignedNucleotideSequences, `is`(anEmptyMap())) + assertThat(releasedDatum.alignedAminoAcidSequences, `is`(anEmptyMap())) + assertThat(releasedDatum.nucleotideInsertions, `is`(anEmptyMap())) + assertThat(releasedDatum.aminoAcidInsertions, `is`(anEmptyMap())) + } + private fun getAccessionVersionsOfProcessedData(processedData: List>) = processedData .map { it.metadata } .map { it["accessionVersion"]!!.asText() } diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt index ecc5b55c2c..57cbddab60 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/SubmitEndpointTest.kt @@ -17,6 +17,7 @@ import org.loculus.backend.api.Organism import org.loculus.backend.config.BackendConfig import org.loculus.backend.controller.DEFAULT_ORGANISM import org.loculus.backend.controller.EndpointTest +import org.loculus.backend.controller.ORGANISM_WITHOUT_CONSENSUS_SEQUENCES import org.loculus.backend.controller.OTHER_ORGANISM import org.loculus.backend.controller.expectUnauthorizedResponse import org.loculus.backend.controller.generateJwtFor @@ -195,6 +196,54 @@ class SubmitEndpointTest( .andExpect(jsonPath("\$.detail", containsString(expectedMessage))) } + @Test + fun `GIVEN no sequence file for organism that requires one THEN returns bad request`() { + submissionControllerClient.submit( + metadataFile = DefaultFiles.metadataFile, + sequencesFile = null, + organism = DEFAULT_ORGANISM, + groupId = groupId, + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) + .andExpect( + jsonPath("\$.detail").value("Submissions for organism $DEFAULT_ORGANISM require a sequence file."), + ) + } + + @Test + fun `GIVEN sequence file for organism without consensus sequences THEN returns bad request`() { + submissionControllerClient.submit( + metadataFile = DefaultFiles.metadataFile, + sequencesFile = DefaultFiles.sequencesFile, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + groupId = groupId, + ) + .andExpect(status().isBadRequest) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) + .andExpect( + jsonPath( + "\$.detail", + ).value("Sequence uploads are not allowed for organism $ORGANISM_WITHOUT_CONSENSUS_SEQUENCES."), + ) + } + + @Test + fun `GIVEN no sequence file for organism without consensus sequences THEN data is accepted`() { + submissionControllerClient.submit( + metadataFile = DefaultFiles.metadataFile, + sequencesFile = null, + organism = ORGANISM_WITHOUT_CONSENSUS_SEQUENCES, + groupId = groupId, + ) + .andExpect(status().isOk) + .andExpect(content().contentType(APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$.length()").value(NUMBER_OF_SEQUENCES)) + .andExpect(jsonPath("\$[0].submissionId").value("custom0")) + .andExpect(jsonPath("\$[0].accession", containsString(backendConfig.accessionPrefix))) + .andExpect(jsonPath("\$[0].version").value(1)) + } + companion object { @JvmStatic @@ -244,7 +293,7 @@ class SubmitEndpointTest( SubmitFiles.sequenceFileWith(name = "notSequencesFile"), status().isBadRequest, "Bad Request", - "Required part 'sequenceFile' is not present.", + "Submissions for organism $DEFAULT_ORGANISM require a sequence file.", DEFAULT_ORGANISM, DataUseTerms.Open, ), diff --git a/backend/src/test/resources/backend_config.json b/backend/src/test/resources/backend_config.json index 9f8c3a59d5..3729e7a6bf 100644 --- a/backend/src/test/resources/backend_config.json +++ b/backend/src/test/resources/backend_config.json @@ -22,6 +22,7 @@ }, "schema": { "organismName": "Test", + "allowSubmissionOfConsensusSequences": true, "metadata": [ { "name": "date", @@ -124,6 +125,7 @@ }, "schema": { "organismName": "Test", + "allowSubmissionOfConsensusSequences": true, "metadata": [ { "name": "date", @@ -181,6 +183,47 @@ } ] } + }, + "dummyOrganismWithoutConsensusSequences": { + "referenceGenomes": { + "nucleotideSequences": [], + "genes": [] + }, + "schema": { + "organismName": "Test without consensus sequences", + "submissionDataTypes": { + "consensusSequences": false + }, + "metadata": [ + { + "name": "date", + "type": "date", + "required": true + }, + { + "name": "region", + "type": "string", + "autocomplete": true, + "required": true + }, + { + "name": "country", + "type": "string", + "autocomplete": true, + "required": true + }, + { + "name": "division", + "type": "string", + "autocomplete": true + }, + { + "name": "host", + "type": "string", + "autocomplete": true + } + ] + } } } } diff --git a/backend/src/test/resources/backend_config_single_segment.json b/backend/src/test/resources/backend_config_single_segment.json index f1d2c5dd6d..e61b1c5c72 100644 --- a/backend/src/test/resources/backend_config_single_segment.json +++ b/backend/src/test/resources/backend_config_single_segment.json @@ -76,4 +76,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/src/content/docs/reference/helm-chart-config.mdx b/docs/src/content/docs/reference/helm-chart-config.mdx index 842441a25a..2bfbf416b8 100644 --- a/docs/src/content/docs/reference/helm-chart-config.mdx +++ b/docs/src/content/docs/reference/helm-chart-config.mdx @@ -205,7 +205,10 @@ The configuration for the Helm chart is provided as a YAML file. It has the foll `website.websiteConfig.enableSubmissionPages` Boolean true - Whether to completely disable submission related pages. Setting this to false is useful when hosting Loculus for analysis-only purposes. + + Whether to completely disable submission related pages. Setting this to false is useful when hosting + Loculus for analysis-only purposes. + `website.runtimeConfig.public` @@ -229,7 +232,10 @@ The configuration for the Helm chart is provided as a YAML file. It has the foll `website.runtimeConfig.public.lapisUrlTemplate` String true - Overwrite the URLs where the client-side website code expects the LAPIS instances. Must contain `%organism%` as a placeholder. + + Overwrite the URLs where the client-side website code expects the LAPIS instances. Must contain + `%organism%` as a placeholder. + @@ -568,6 +574,15 @@ Each organism object has the following fields: press a button to do so. (For small genomes, this should probably be true.) + + `submissionDataTypes.consensusSequences` + Boolean + true + + If `false`, the submission form will not allow submission of consensus sequences (i.e. the sequences + file must be omitted). All consensus sequence related parts on the website will be hidden. + + `description` String @@ -584,7 +599,9 @@ Each organism object has the following fields: `metadataTemplate` Array of String - Which input fields to add to the downloadable metadata template on the submission and revision page. + + Which input fields to add to the downloadable metadata template on the submission and revision page. + `earliestReleaseDate` diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index f0c99b2081..efe49d4de6 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -169,6 +169,7 @@ organisms: {{- with ($instance.schema | include "loculus.patchMetadataSchema" | fromYaml) }} organismName: {{ quote .organismName }} loadSequencesAutomatically: {{ .loadSequencesAutomatically | default false }} + {{- include "loculus.submissionDataTypes" . | nindent 6 }} {{- $nucleotideSequences := .nucleotideSequences | default (list "main")}} {{ if .image }} image: {{ .image }} @@ -294,6 +295,7 @@ organisms: {{- with $instance.schema }} {{- $nucleotideSequences := .nucleotideSequences | default (list "main")}} organismName: {{ quote .organismName }} + {{- include "loculus.submissionDataTypes" . | nindent 6 }} metadata: {{- $args := dict "metadata" (include "loculus.patchMetadataSchema" . | fromYaml).metadata "nucleotideSequences" $nucleotideSequences}} {{ $metadata := include "loculus.generateBackendMetadata" $args | fromYaml }} @@ -317,9 +319,13 @@ organisms: {{- end }} {{- define "loculus.generateReferenceGenome" }} +{{ if .nucleotideSequences }} nucleotideSequences: {{ $nucleotideSequences := include "loculus.generateSequences" .nucleotideSequences | fromYaml }} {{ $nucleotideSequences.fields | toYaml | nindent 8 }} +{{ else }} +nucleotideSequences: [] +{{ end }} {{ if .genes }} genes: {{ $genes := include "loculus.generateSequences" .genes | fromYaml }} diff --git a/kubernetes/loculus/templates/_submission-data-types.tpl b/kubernetes/loculus/templates/_submission-data-types.tpl new file mode 100644 index 0000000000..64094b336d --- /dev/null +++ b/kubernetes/loculus/templates/_submission-data-types.tpl @@ -0,0 +1,10 @@ +{{- define "loculus.submissionDataTypes" -}} +submissionDataTypes: + {{- if (hasKey . "submissionDataTypes") }} + {{- with .submissionDataTypes }} + consensusSequences: {{ (hasKey . "consensusSequences") | ternary .consensusSequences "true" }} + {{- end }} + {{- else }} + consensusSequences: true + {{- end}} +{{- end }} diff --git a/kubernetes/loculus/templates/loculus-preprocessing-config.yaml b/kubernetes/loculus/templates/loculus-preprocessing-config.yaml index f53745448e..423bd64155 100644 --- a/kubernetes/loculus/templates/loculus-preprocessing-config.yaml +++ b/kubernetes/loculus/templates/loculus-preprocessing-config.yaml @@ -1,7 +1,8 @@ {{- range $organism, $organismConfig := (.Values.organisms | default .Values.defaultOrganisms) }} {{- $metadata := ($organismConfig.schema | include "loculus.patchMetadataSchema" | fromYaml).metadata }} -{{- $nucleotideSequences := (($organismConfig.schema | include "loculus.patchMetadataSchema" | fromYaml).nucleotideSequences | default "" ) }} -{{- $nucleotideSequencesList := ($organismConfig.schema | include "loculus.patchMetadataSchema" | fromYaml).nucleotideSequences | default (list "main")}} +{{- $rawNucleotideSequences := (($organismConfig.schema | include "loculus.patchMetadataSchema" | fromYaml).nucleotideSequences) }} +{{- $nucleotideSequences := ($rawNucleotideSequences | default "" ) }} +{{- $nucleotideSequencesList := (eq $rawNucleotideSequences nil | ternary (list "main") $rawNucleotideSequences) }} {{- range $processingIndex, $processingConfig := $organismConfig.preprocessing }} {{- if $processingConfig.configFile }} --- @@ -13,7 +14,7 @@ data: preprocessing-config.yaml: | organism: {{ $organism }} {{- $processingConfig.configFile | toYaml | nindent 4 }} - nucleotideSequences: {{- $nucleotideSequencesList | toYaml | nindent 4 }} + {{- (dict "nucleotideSequences" $nucleotideSequencesList) | toYaml | nindent 4 }} processing_spec: {{- $args := dict "metadata" $metadata "nucleotideSequences" $nucleotideSequences }} {{- include "loculus.preprocessingSpecs" $args | nindent 6 }} @@ -24,4 +25,4 @@ data: args: {{- end }} {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/kubernetes/loculus/values.yaml b/kubernetes/loculus/values.yaml index ce333d0501..b300b8207f 100644 --- a/kubernetes/loculus/values.yaml +++ b/kubernetes/loculus/values.yaml @@ -1393,6 +1393,67 @@ defaultOrganisms: sequence: "[[URL:https://corneliusroemer.github.io/seqs/artefacts/sars-cov-2/ORF9b.fasta]]" - name: "S" sequence: "[[URL:https://corneliusroemer.github.io/seqs/artefacts/sars-cov-2/S.fasta]]" + dummy-organism-without-seqs: + schema: + image: "https://cdn.who.int/media/images/default-source/mca/mca-covid-19/coronavirus-2.tmb-1920v.jpg?sfvrsn=4dba955c_19" + organismName: "Test organism (without consensus sequences)" + nucleotideSequences: [] + submissionDataTypes: + consensusSequences: false + metadata: + - name: date + type: date + initiallyVisible: true + header: "Collection Details" + required: true + preprocessing: + function: parse_and_assert_past_date + inputs: + date: date + - name: region + type: string + initiallyVisible: true + generateIndex: true + autocomplete: true + header: "Collection Details" + - name: country + initiallyVisible: true + type: string + generateIndex: true + autocomplete: true + header: "Collection Details" + - name: division + initiallyVisible: true + type: string + generateIndex: true + autocomplete: true + header: "Collection Details" + - name: host + initiallyVisible: true + type: string + autocomplete: true + header: "Collection Details" + website: + tableColumns: + - country + - division + - date + defaultOrder: descending + defaultOrderBy: date + silo: + dateToSortBy: date + preprocessing: + - version: 1 + image: ghcr.io/loculus-project/preprocessing-nextclade + args: + - "prepro" + configFile: + log_level: DEBUG + genes: [] + batch_size: 100 + referenceGenomes: + nucleotideSequences: [] + genes: [] not-aligned-organism: schema: image: "https://cdn.who.int/media/images/default-source/mca/mca-covid-19/coronavirus-2.tmb-1920v.jpg?sfvrsn=4dba955c_19" diff --git a/preprocessing/nextclade/tests/test_processing_functions.py b/preprocessing/nextclade/tests/test_processing_functions.py index 853d9c891a..cb2ec6edca 100644 --- a/preprocessing/nextclade/tests/test_processing_functions.py +++ b/preprocessing/nextclade/tests/test_processing_functions.py @@ -10,7 +10,7 @@ ) from loculus_preprocessing.config import Config, get_config -from loculus_preprocessing.datatypes import ProcessedEntry, ProcessingAnnotation +from loculus_preprocessing.datatypes import ProcessedEntry, ProcessingAnnotation, UnprocessedData, UnprocessedEntry from loculus_preprocessing.prepro import process_all from loculus_preprocessing.processing_functions import ( ProcessingFunctions, @@ -452,6 +452,36 @@ def test_preprocessing(test_case_def: Case, config: Config, factory_custom: Proc verify_processed_entry(processed_entry, test_case.expected_output, test_case.name) +def test_preprocessing_without_consensus_sequences(): + sequence_name = "entry without sequences" + sequence_entery_data = UnprocessedEntry( + accessionVersion=f"LOC_01.1", + data=UnprocessedData( + submitter="test_submitter", + metadata={ + "ncbi_required_collection_date": "2024-01-01", + "name_required": sequence_name + }, + unalignedNucleotideSequences={}, + ), + ) + + config = get_config(test_config_file) + config.nucleotideSequences = [] + + result = process_all([sequence_entery_data], "temp_dataset_dir", config) + processed_entry = result[0] + + assert processed_entry.errors == [] + assert processed_entry.warnings == [] + assert processed_entry.data.metadata["name_required"] == sequence_name + assert processed_entry.data.unalignedNucleotideSequences == {} + assert processed_entry.data.alignedNucleotideSequences == {} + assert processed_entry.data.nucleotideInsertions == {} + assert processed_entry.data.alignedAminoAcidSequences == {} + assert processed_entry.data.aminoAcidInsertions == {} + + def test_format_frameshift(): # Test case 1: Empty input assert not format_frameshift("[]") diff --git a/website/src/components/Edit/EditPage.spec.tsx b/website/src/components/Edit/EditPage.spec.tsx index 97ae43f265..2a5c1f7d06 100644 --- a/website/src/components/Edit/EditPage.spec.tsx +++ b/website/src/components/Edit/EditPage.spec.tsx @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, test } from 'vitest'; import { EditPage } from './EditPage.tsx'; import { defaultReviewData, editableEntry, metadataKey, testAccessToken, testOrganism } from '../../../vitest.setup.ts'; -import type { SequenceEntryToEdit, UnprocessedMetadataRecord } from '../../types/backend.ts'; +import type { UnprocessedMetadataRecord } from '../../types/backend.ts'; import type { ClientConfig } from '../../types/runtimeConfig.ts'; const queryClient = new QueryClient(); @@ -19,7 +19,11 @@ const inputFields = [ }, ]; -function renderEditPage(editedData: SequenceEntryToEdit = defaultReviewData, clientConfig: ClientConfig = dummyConfig) { +function renderEditPage({ + editedData = defaultReviewData, + clientConfig = dummyConfig, + allowSubmissionOfConsensusSequences = true, +} = {}) { render( , ); @@ -51,6 +56,13 @@ describe('EditPage', () => { await userEvent.click(submitButton); }); + test('should render without allowed submission of consensus sequences', () => { + renderEditPage({ allowSubmissionOfConsensusSequences: false }); + + expect(screen.getByText(/Original Data/i)).toBeInTheDocument(); + expectTextInSequenceData.originalMetadata(defaultReviewData.originalData.metadata); + }); + test('should show original data and processed data', () => { renderEditPage(); diff --git a/website/src/components/Edit/EditPage.tsx b/website/src/components/Edit/EditPage.tsx index 991996e78b..c4c80636c2 100644 --- a/website/src/components/Edit/EditPage.tsx +++ b/website/src/components/Edit/EditPage.tsx @@ -13,7 +13,7 @@ import { type SequenceEntryToEdit, approvedForReleaseStatus, } from '../../types/backend.ts'; -import { type InputField } from '../../types/config.ts'; +import { type InputField, type SubmissionDataTypes } from '../../types/config.ts'; import type { ClientConfig } from '../../types/runtimeConfig.ts'; import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader.ts'; import { getAccessionVersionString } from '../../utils/extractAccessionVersion.ts'; @@ -28,6 +28,7 @@ type EditPageProps = { dataToEdit: SequenceEntryToEdit; accessToken: string; inputFields: InputField[]; + submissionDataTypes: SubmissionDataTypes; }; const logger = getClientLogger('EditPage'); @@ -75,7 +76,8 @@ const InnerEditPage: FC = ({ clientConfig, accessToken, inputFields, -}: EditPageProps) => { + submissionDataTypes, +}) => { const [editedMetadata, setEditedMetadata] = useState(mapMetadataToRow(dataToEdit)); const [editedSequences, setEditedSequences] = useState(mapSequencesToRow(dataToEdit)); const [processedSequenceTab, setProcessedSequenceTab] = useState(0); @@ -102,7 +104,9 @@ const InnerEditPage: FC = ({ if (isCreatingRevision) { submitRevision({ metadataFile: createMetadataTsv(editedMetadata, dataToEdit.submissionId, dataToEdit.accession), - sequenceFile: createSequenceFasta(editedSequences, dataToEdit.submissionId), + sequenceFile: submissionDataTypes.consensusSequences + ? createSequenceFasta(editedSequences, dataToEdit.submissionId) + : undefined, }); } else { submitEdit({ @@ -141,25 +145,31 @@ const InnerEditPage: FC = ({ setEditedMetadata={setEditedMetadata} inputFields={inputFields} /> - + {submissionDataTypes.consensusSequences && ( + + )} - - - + {submissionDataTypes.consensusSequences && ( + <> + + + + + )} - {processedSequences.length > 0 && ( + {submissionDataTypes.consensusSequences && processedSequences.length > 0 && (
{processedSequences.map(({ label }, i) => ( @@ -199,16 +209,18 @@ const InnerEditPage: FC = ({ Submit - + {submissionDataTypes.consensusSequences && ( + + )}
); diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.spec.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.spec.tsx index c05c37219f..e94b28eeaf 100644 --- a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.spec.tsx +++ b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.spec.tsx @@ -21,12 +21,19 @@ const defaultReferenceGenome: ReferenceGenomesSequenceNames = { const defaultLapisUrl = 'https://lapis'; const defaultOrganism = 'ebola'; -async function renderDialog(downloadParams: SequenceFilter = new SelectFilter(new Set())) { +async function renderDialog({ + downloadParams = new SelectFilter(new Set()), + allowSubmissionOfConsensusSequences = true, +}: { + downloadParams?: SequenceFilter; + allowSubmissionOfConsensusSequences?: boolean; +} = {}) { render( , ); @@ -48,8 +55,21 @@ describe('DownloadDialog', () => { expect(getDownloadHref()).toMatch(new RegExp(`^${defaultLapisUrl}`)); }); + const olderVersionsLabel = /Yes, include older versions/; + const rawNucleotideSequencesLabel = /Raw nucleotide sequences/; + const gzipCompressionLabel = /Gzip/; + test('should generate the right download link from filters', async () => { - await renderDialog(new FieldFilter({ accession: ['accession1', 'accession2'], field1: 'value1' }, {}, [])); + await renderDialog({ + downloadParams: new FieldFilter( + { + accession: ['accession1', 'accession2'], + field1: 'value1', + }, + {}, + [], + ), + }); await checkAgreement(); let [path, query] = getDownloadHref()?.split('?') ?? []; @@ -58,9 +78,9 @@ describe('DownloadDialog', () => { /downloadAsFile=true&downloadFileBasename=ebola_metadata_\d{4}-\d{2}-\d{2}T\d{4}&versionStatus=LATEST_VERSION&isRevocation=false&dataUseTerms=OPEN&dataFormat=tsv&accession=accession1&accession=accession2&field1=value1/, ); - await userEvent.click(screen.getByLabelText(/Yes, include older versions/)); - await userEvent.click(screen.getByLabelText(/Raw nucleotide sequences/)); - await userEvent.click(screen.getByLabelText(/Gzip/)); + await userEvent.click(screen.getByLabelText(olderVersionsLabel)); + await userEvent.click(screen.getByLabelText(rawNucleotideSequencesLabel)); + await userEvent.click(screen.getByLabelText(gzipCompressionLabel)); [path, query] = getDownloadHref()?.split('?') ?? []; expect(path).toBe(`${defaultLapisUrl}/sample/unalignedNucleotideSequences`); @@ -79,7 +99,7 @@ describe('DownloadDialog', () => { }); test('should generate the right download link from selected sequences', async () => { - await renderDialog(new SelectFilter(new Set(['SEQID1', 'SEQID2']))); + await renderDialog({ downloadParams: new SelectFilter(new Set(['SEQID1', 'SEQID2'])) }); await checkAgreement(); let [path, query] = getDownloadHref()?.split('?') ?? []; @@ -88,9 +108,9 @@ describe('DownloadDialog', () => { /downloadAsFile=true&downloadFileBasename=ebola_metadata_\d{4}-\d{2}-\d{2}T\d{4}&versionStatus=LATEST_VERSION&isRevocation=false&dataUseTerms=OPEN&dataFormat=tsv&accessionVersion=SEQID1&accessionVersion=SEQID2/, ); - await userEvent.click(screen.getByLabelText(/Yes, include older versions/)); - await userEvent.click(screen.getByLabelText(/Raw nucleotide sequences/)); - await userEvent.click(screen.getByLabelText(/Gzip/)); + await userEvent.click(screen.getByLabelText(olderVersionsLabel)); + await userEvent.click(screen.getByLabelText(rawNucleotideSequencesLabel)); + await userEvent.click(screen.getByLabelText(gzipCompressionLabel)); [path, query] = getDownloadHref()?.split('?') ?? []; expect(path).toBe(`${defaultLapisUrl}/sample/unalignedNucleotideSequences`); @@ -107,6 +127,18 @@ describe('DownloadDialog', () => { /downloadAsFile=true&downloadFileBasename=ebola_nuc_\d{4}-\d{2}-\d{2}T\d{4}&compression=zstd&accessionVersion=SEQID1&accessionVersion=SEQID2/, ); }); + + test('should render with allowSubmissionOfConsensusSequences = false', async () => { + await renderDialog({ allowSubmissionOfConsensusSequences: false }); + await checkAgreement(); + + const [path] = getDownloadHref()?.split('?') ?? []; + expect(path).toBe(`${defaultLapisUrl}/sample/details`); + + expect(screen.queryByLabelText(rawNucleotideSequencesLabel)).not.toBeInTheDocument(); + expect(screen.getByLabelText(olderVersionsLabel)).toBeInTheDocument(); + expect(screen.getByLabelText(gzipCompressionLabel)).toBeInTheDocument(); + }); }); async function checkAgreement() { diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx index 6af4911d02..9987795cc5 100644 --- a/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx +++ b/website/src/components/SearchPage/DownloadDialog/DownloadDialog.tsx @@ -14,12 +14,14 @@ type DownloadDialogProps = { downloadUrlGenerator: DownloadUrlGenerator; sequenceFilter: SequenceFilter; referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; + allowSubmissionOfConsensusSequences: boolean; }; export const DownloadDialog: FC = ({ downloadUrlGenerator, sequenceFilter, referenceGenomesSequenceNames, + allowSubmissionOfConsensusSequences, }) => { const [isOpen, setIsOpen] = useState(false); @@ -38,6 +40,7 @@ export const DownloadDialog: FC = ({