Skip to content

Commit

Permalink
feat(website): change creation of accession from sequence to transfor…
Browse files Browse the repository at this point in the history
…med sequence
  • Loading branch information
TobiasKampmann committed Feb 22, 2024
1 parent 4a22882 commit f188c11
Show file tree
Hide file tree
Showing 31 changed files with 916 additions and 541 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import org.loculus.backend.api.Organism

data class BackendConfig(
val instances: Map<String, InstanceConfig>,
val accessionPrefix: String,
) {
fun getInstanceConfig(organism: Organism) = instances[organism.name] ?: throw IllegalArgumentException(
"Organism: ${organism.name} not found in backend config. Available organisms: ${instances.keys}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ class SubmitModel(
submissionParams.organism,
submissionParams.username,
)
} else if (submissionParams is SubmissionParams.OriginalSubmissionParams) {
log.info { "Generating new accessions for uploaded sequence data with uploadId $uploadId" }
uploadDatabaseService.generateNewAccessionsForOriginalUpload(
uploadId,
submissionParams.organism,
submissionParams.username,
)
}

log.debug { "Persisting submission with uploadId $uploadId" }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package org.loculus.backend.service

import org.loculus.backend.config.BackendConfig
import org.loculus.backend.utils.Accession
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service

@Service
class GenerateAccessionFromNumberService(
@Autowired val backendConfig: BackendConfig,
) {

fun generateCustomId(sequenceNumber: Long): String {
val base34Digits: MutableList<Char> = mutableListOf()
var remainder: Long = sequenceNumber

do {
val digit = (remainder % 34).toInt()
base34Digits.addFirst(CODE_POINTS[digit])
remainder /= 34
} while (remainder > 0)

val serialAccessionPart = base34Digits
.joinToString("")
.padStart(6, '0')
return backendConfig.accessionPrefix + serialAccessionPart + generateCheckCharacter(serialAccessionPart)
}

fun validateAccession(accession: Accession): Boolean {
if (!accession.startsWith(backendConfig.accessionPrefix)) {
return false
}
return validateCheckCharacter(accession.removePrefix(backendConfig.accessionPrefix))
}

// See https://en.wikipedia.org/wiki/Luhn_mod_N_algorithm for details
private fun generateCheckCharacter(input: String): Char {
var factor = 2
var sum = 0

for (i in input.length - 1 downTo 0) {
var addend = factor * getCodePointFromCharacter(input[i])

factor = if (factor == 2) 1 else 2

addend = addend / NUMBER_OF_VALID_CHARACTERS + addend % NUMBER_OF_VALID_CHARACTERS
sum += addend
}

val remainder = sum % NUMBER_OF_VALID_CHARACTERS
val checkCodePoint = (NUMBER_OF_VALID_CHARACTERS - remainder) % NUMBER_OF_VALID_CHARACTERS
return CODE_POINTS[checkCodePoint]
}

private fun validateCheckCharacter(input: String): Boolean {
var factor = 1
var sum = 0

for (i in input.length - 1 downTo 0) {
val codePoint: Int = getCodePointFromCharacter(input[i])
var addend = factor * codePoint

factor = when (factor) {
2 -> 1
else -> 2
}

addend = addend / NUMBER_OF_VALID_CHARACTERS + addend % NUMBER_OF_VALID_CHARACTERS
sum += addend
}
val remainder = sum % NUMBER_OF_VALID_CHARACTERS
return remainder == 0
}

companion object {
const val CODE_POINTS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ"
fun getCodePointFromCharacter(character: Char): Int {
return CODE_POINTS.indexOf(character)
}
const val NUMBER_OF_VALID_CHARACTERS = CODE_POINTS.length
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.loculus.backend.service.submission

import kotlinx.datetime.LocalDateTime
import mu.KotlinLogging
import org.jetbrains.exposed.sql.IntegerColumnType
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.VarCharColumnType
import org.jetbrains.exposed.sql.and
Expand All @@ -17,6 +18,7 @@ import org.loculus.backend.api.SubmissionIdMapping
import org.loculus.backend.model.SubmissionId
import org.loculus.backend.model.SubmissionParams
import org.loculus.backend.model.UploadType
import org.loculus.backend.service.GenerateAccessionFromNumberService
import org.loculus.backend.service.datauseterms.DataUseTermsDatabaseService
import org.loculus.backend.service.submission.MetadataUploadAuxTable.accessionColumn
import org.loculus.backend.service.submission.MetadataUploadAuxTable.groupNameColumn
Expand Down Expand Up @@ -46,6 +48,7 @@ class UploadDatabaseService(
private val compressor: CompressionService,
private val accessionPreconditionValidator: AccessionPreconditionValidator,
private val dataUseTermsDatabaseService: DataUseTermsDatabaseService,
private val generateAccessionFromNumberService: GenerateAccessionFromNumberService,
) {

fun batchInsertMetadataInAuxTable(
Expand Down Expand Up @@ -157,8 +160,6 @@ class UploadDatabaseService(
}

fun associateRevisedDataWithExistingSequenceEntries(uploadId: String, organism: Organism, username: String) {
log.info { "associating revised data with existing sequence entries with UploadId $uploadId" }

val accessions =
MetadataUploadAuxTable
.slice(accessionColumn)
Expand All @@ -185,6 +186,45 @@ class UploadDatabaseService(
}
}

fun generateNewAccessionsForOriginalUpload(uploadId: String, organism: Organism, username: String) {
val submissionIds =
MetadataUploadAuxTable
.slice(submissionIdColumn)
.select { uploadIdColumn eq uploadId }
.map { it[submissionIdColumn] }

val nextAccessions = getNextSequenceNumbers(submissionIds.size).map {
generateAccessionFromNumberService.generateCustomId(it)
}

if (submissionIds.size != nextAccessions.size) {
throw IllegalStateException(
"Mismatched sizes: accessions=${submissionIds.size}, nextAccessions=${nextAccessions.size}",
)
}

val submissionIdToAccessionMap = submissionIds.zip(nextAccessions)

log.info {
"Generated ${submissionIdToAccessionMap.size} new accessions for original upload with UploadId " +
"$uploadId: ${submissionIdToAccessionMap.joinToString(
limit = 10,
){
it.toString()
} }"
}

submissionIdToAccessionMap.forEach { (submissionId, accession) ->
MetadataUploadAuxTable.update(
where = {
(submissionIdColumn eq submissionId) and (uploadIdColumn eq uploadId)
},
) {
it[accessionColumn] = accession
}
}
}

private fun generateMapAndCopyStatement(uploadType: UploadType): String {
val commonColumns = StringBuilder().apply {
append("accession,")
Expand All @@ -205,7 +245,9 @@ class UploadDatabaseService(
}.toString()

val specificColumns = if (uploadType == UploadType.ORIGINAL) {
"nextval('accession_sequence'),"
"""
m.accession,
""".trimIndent()
} else {
"""
m.accession,
Expand Down Expand Up @@ -244,4 +286,24 @@ class UploadDatabaseService(
RETURNING accession, version, submission_id;
""".trimIndent()
}

fun getNextSequenceNumbers(numberOfNewEntries: Int) = transaction {
val nextValues = exec(
"SELECT nextval('accession_sequence') FROM generate_series(1, ?)",
listOf(
Pair(IntegerColumnType(), numberOfNewEntries),
),
) { rs ->
val result = mutableListOf<Long>()
while (rs.next()) {
result += rs.getLong(1)
}
result.toList()
} ?: emptyList()

if (nextValues.size != numberOfNewEntries) {
throw IllegalStateException("Expected $numberOfNewEntries values, got ${nextValues.size}.")
}
nextValues
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,6 @@ fun revisionEntryStreamAsSequence(metadataInputStream: InputStream): Sequence<Re
)
}

if (accession.toLongOrNull() == null) {
throw UnprocessableEntityException(
"A row in metadata file contains no valid $ACCESSION_HEADER: $accession",
)
}

val metadata = record.toMap().filterKeys { column ->
column != HEADER_TO_CONNECT_METADATA_AND_SEQUENCES &&
column != ACCESSION_HEADER
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import org.loculus.backend.api.DataUseTermsType
import org.loculus.backend.controller.EndpointTest
import org.loculus.backend.controller.expectUnauthorizedResponse
import org.loculus.backend.controller.submission.SubmissionConvenienceClient
import org.loculus.backend.controller.submission.SubmitFiles.DefaultFiles.firstAccession
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.ResultActions
Expand All @@ -34,30 +33,34 @@ class DataUseTermsControllerTest(

@Test
fun `WHEN I get data use terms of non-existing accession THEN returns unprocessable entity`() {
client.getDataUseTerms(firstAccession)
val nonExistingAccession = "SomeNonExistingAccession"

client.getDataUseTerms(nonExistingAccession)
.andExpect(status().isNotFound)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(
jsonPath("\$.detail", containsString("Accession $firstAccession not found")),
jsonPath("\$.detail", containsString("Accession $nonExistingAccession not found")),
)
}

@Test
fun `GIVEN open submission WHEN getting data use terms THEN return history with one OPEN entry`() {
submissionConvenienceClient.submitDefaultFiles()
val firstAccession = submissionConvenienceClient.submitDefaultFiles().first().accession
client.getDataUseTerms(firstAccession)
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(jsonPath("\$").isArray)
.andExpect(jsonPath("\$").isNotEmpty)
.andExpect(jsonPath("\$[0].accession").value("1"))
.andExpect(jsonPath("\$[0].accession").value(firstAccession))
.andExpect(jsonPath("\$[0].changeDate", containsString(dateMonthsFromNow(0).toString())))
.andExpect(jsonPath("\$[0].dataUseTerms.type").value(DataUseTermsType.OPEN.name))
}

@Test
fun `GIVEN changes in data use terms WHEN getting data use terms THEN return full history`() {
submissionConvenienceClient.submitDefaultFiles(dataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)))
val firstAccession = submissionConvenienceClient.submitDefaultFiles(
dataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)),
).first().accession

client.changeDataUseTerms(
DataUseTermsChangeRequest(
Expand Down Expand Up @@ -100,10 +103,17 @@ class DataUseTermsControllerTest(

@ParameterizedTest
@MethodSource("dataUseTermsTestCases")
fun `test data use terms changes`(testCase: DataUseTermsTestCase) {
submissionConvenienceClient.submitDefaultFiles(dataUseTerms = testCase.setupDataUseTerms)
fun `WHEN changing data use terms THEN show success or error`(testCase: DataUseTermsTestCase) {
val accessions = submissionConvenienceClient
.submitDefaultFiles(dataUseTerms = testCase.setupDataUseTerms)
.map { it.accession }

val result = client.changeDataUseTerms(testCase.changeRequest)
val result = client.changeDataUseTerms(
DataUseTermsChangeRequest(
accessions = accessions,
newDataUseTerms = testCase.newDataUseTerms,
),
)
.andExpect(testCase.expectedStatus)

if (testCase.expectedContentType != null && testCase.expectedDetailContains != null) {
Expand Down Expand Up @@ -134,7 +144,7 @@ class DataUseTermsControllerTest(

data class DataUseTermsTestCase(
val setupDataUseTerms: DataUseTerms,
val changeRequest: DataUseTermsChangeRequest,
val newDataUseTerms: DataUseTerms,
val expectedStatus: ResultMatcher,
val expectedContentType: String?,
val expectedDetailContains: String?,
Expand All @@ -145,38 +155,29 @@ class DataUseTermsControllerTest(
return listOf(
DataUseTermsTestCase(
setupDataUseTerms = DataUseTerms.Open,
changeRequest = DEFAULT_DATA_USE_CHANGE_REQUEST,
newDataUseTerms = DataUseTerms.Open,
expectedStatus = status().isNoContent,
expectedContentType = null,
expectedDetailContains = null,
),
DataUseTermsTestCase(
setupDataUseTerms = DataUseTerms.Open,
changeRequest = DataUseTermsChangeRequest(
accessions = listOf("1", "2"),
newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)),
),
newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)),
expectedStatus = status().isUnprocessableEntity,
expectedContentType = MediaType.APPLICATION_JSON_VALUE,
expectedDetailContains = "Cannot change data use terms from OPEN to RESTRICTED.",
),
DataUseTermsTestCase(
setupDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)),
changeRequest = DataUseTermsChangeRequest(
accessions = listOf("1", "2"),
newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(-1)),
),
newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(-1)),
expectedStatus = status().isBadRequest,
expectedContentType = MediaType.APPLICATION_JSON_VALUE,
expectedDetailContains = "The date 'restrictedUntil' must be in the future, " +
"up to a maximum of 1 year from now.",
),
DataUseTermsTestCase(
setupDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(6)),
changeRequest = DataUseTermsChangeRequest(
accessions = listOf("1", "2"),
newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(7)),
),
newDataUseTerms = DataUseTerms.Restricted(dateMonthsFromNow(7)),
expectedStatus = status().isUnprocessableEntity,
expectedContentType = MediaType.APPLICATION_JSON_VALUE,
expectedDetailContains = "Cannot extend restricted data use period. " +
Expand Down
Loading

0 comments on commit f188c11

Please sign in to comment.