diff --git a/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt b/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt index 9c9918f08..44f0fcbf9 100644 --- a/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt +++ b/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt @@ -68,6 +68,17 @@ data class AccessionVersionsFilterWithDeletionScope( val scope: DeleteSequenceScope, ) +data class AccessionsToRevokeWithComment( + @Schema( + description = "List of accessions to revoke.", + ) + val accessions: List, + @Schema( + description = "Reason for revocation or other details", + ) + val versionComment: String? = null, +) + enum class ApproveDataScope { ALL, WITHOUT_WARNINGS, 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 b692ea81b..4f56038db 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt @@ -15,7 +15,7 @@ import org.jetbrains.exposed.sql.transactions.transaction import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.AccessionVersionsFilterWithApprovalScope import org.loculus.backend.api.AccessionVersionsFilterWithDeletionScope -import org.loculus.backend.api.Accessions +import org.loculus.backend.api.AccessionsToRevokeWithComment import org.loculus.backend.api.CompressionFormat import org.loculus.backend.api.DataUseTerms import org.loculus.backend.api.DataUseTermsType @@ -397,9 +397,10 @@ class SubmissionController( fun revoke( @PathVariable @Valid organism: Organism, - @RequestBody body: Accessions, + @RequestBody body: AccessionsToRevokeWithComment, @HiddenParam authenticatedUser: AuthenticatedUser, - ): List = submissionDatabaseService.revoke(body.accessions, authenticatedUser, organism) + ): List = + submissionDatabaseService.revoke(body.accessions, authenticatedUser, organism, body.versionComment) @Operation(description = DELETE_SEQUENCES_DESCRIPTION) @ResponseStatus(HttpStatus.OK) diff --git a/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt b/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt index e27dcae35..0e001782c 100644 --- a/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt +++ b/backend/src/main/kotlin/org/loculus/backend/model/ReleasedDataModel.kt @@ -71,7 +71,8 @@ class ReleasedDataModel( ("releasedDate" to TextNode(rawProcessedData.releasedAtTimestamp.toUtcDateString())) + ("versionStatus" to TextNode(siloVersionStatus.name)) + ("dataUseTerms" to TextNode(currentDataUseTerms.type.name)) + - ("dataUseTermsRestrictedUntil" to restrictedDataUseTermsUntil) + ("dataUseTermsRestrictedUntil" to restrictedDataUseTermsUntil) + + ("versionComment" to TextNode(rawProcessedData.versionComment)) if (backendConfig.dataUseTermsUrls != null) { val url = if (rawProcessedData.dataUseTerms == DataUseTerms.Open) { diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt index 1e6842b42..a17e996c7 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesTable.kt @@ -21,6 +21,7 @@ object SequenceEntriesTable : Table(SEQUENCE_ENTRIES_TABLE_NAME) { val accessionColumn = varchar("accession", 255) val versionColumn = long("version") + val versionCommentColumn = varchar("version_comment", 255).nullable() val organismColumn = varchar("organism", 255) val submissionIdColumn = varchar("submission_id", 255) val submitterColumn = varchar("submitter", 255) diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesView.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesView.kt index e736b5178..8a71ed4af 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesView.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/SequenceEntriesView.kt @@ -40,6 +40,7 @@ object SequenceEntriesView : Table(SEQUENCE_ENTRIES_VIEW_NAME) { val releasedAtTimestampColumn = datetime("released_at").nullable() val statusColumn = varchar("status", 255) val isRevocationColumn = bool("is_revocation").default(false) + val versionCommentColumn = varchar("version_comment", 255).nullable() val errorsColumn = jacksonSerializableJsonb>("errors").nullable() val warningsColumn = jacksonSerializableJsonb>("warnings").nullable() diff --git a/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt index 1b084d08c..b24cf6138 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/submission/SubmissionDatabaseService.kt @@ -29,6 +29,7 @@ import org.jetbrains.exposed.sql.not import org.jetbrains.exposed.sql.notExists import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.statements.StatementType +import org.jetbrains.exposed.sql.stringParam import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.update import org.loculus.backend.api.AccessionVersion @@ -538,6 +539,7 @@ class SubmissionDatabaseService( SequenceEntriesView.accessionColumn, SequenceEntriesView.versionColumn, SequenceEntriesView.isRevocationColumn, + SequenceEntriesView.versionCommentColumn, SequenceEntriesView.jointDataColumn, SequenceEntriesView.submitterColumn, SequenceEntriesView.groupIdColumn, @@ -577,6 +579,7 @@ class SubmissionDatabaseService( DataUseTermsType.fromString(it[DataUseTermsTable.dataUseTermsTypeColumn]), it[DataUseTermsTable.restrictedUntilColumn], ), + versionComment = it[SequenceEntriesView.versionCommentColumn], ) } @@ -673,6 +676,7 @@ class SubmissionDatabaseService( accessions: List, authenticatedUser: AuthenticatedUser, organism: Organism, + versionComment: String?, ): List { log.info { "revoking ${accessions.size} sequences" } @@ -682,30 +686,35 @@ class SubmissionDatabaseService( .andThatSequenceEntriesAreInStates(listOf(Status.APPROVED_FOR_RELEASE)) .andThatOrganismIs(organism) } - val now = Clock.System.now().toLocalDateTime(TimeZone.UTC) + SequenceEntriesTable.insert( - SequenceEntriesTable - .select( - SequenceEntriesTable.accessionColumn, - SequenceEntriesTable.versionColumn.plus(1), - SequenceEntriesTable.submissionIdColumn, - SequenceEntriesTable.submitterColumn, - SequenceEntriesTable.groupIdColumn, - dateTimeParam(now), - booleanParam(true), - SequenceEntriesTable.organismColumn, - ) - .where { - (SequenceEntriesTable.accessionColumn inList accessions) and - SequenceEntriesTable.isMaxVersion + SequenceEntriesTable.select( + SequenceEntriesTable.accessionColumn, SequenceEntriesTable.versionColumn.plus(1), + when (versionComment) { + null -> Op.nullOp() + else -> stringParam(versionComment) }, + SequenceEntriesTable.submissionIdColumn, + SequenceEntriesTable.submitterColumn, + SequenceEntriesTable.groupIdColumn, + dateTimeParam( + now, + ), + booleanParam(true), SequenceEntriesTable.organismColumn, + ).where { + ( + SequenceEntriesTable.accessionColumn inList + accessions + ) and + SequenceEntriesTable.isMaxVersion + }, columns = listOf( SequenceEntriesTable.accessionColumn, SequenceEntriesTable.versionColumn, + SequenceEntriesTable.versionCommentColumn, SequenceEntriesTable.submissionIdColumn, - SequenceEntriesTable.submitterColumn, - SequenceEntriesTable.groupIdColumn, + SequenceEntriesTable.submitterColumn, SequenceEntriesTable.groupIdColumn, SequenceEntriesTable.submittedAtTimestampColumn, SequenceEntriesTable.isRevocationColumn, SequenceEntriesTable.organismColumn, @@ -1017,6 +1026,7 @@ data class RawProcessedData( override val accession: Accession, override val version: Version, val isRevocation: Boolean, + val versionComment: String?, val submitter: String, val groupId: Int, val groupName: String, diff --git a/backend/src/main/resources/db/migration/V1.1__add_field.sql b/backend/src/main/resources/db/migration/V1.1__add_field.sql new file mode 100644 index 000000000..5fbdbfd69 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1.1__add_field.sql @@ -0,0 +1,30 @@ +alter table sequence_entries add column version_comment text; + +drop view if exists sequence_entries_view; + +create view sequence_entries_view as +select + se.*, + sepd.started_processing_at, + sepd.finished_processing_at, + sepd.processed_data as processed_data, + sepd.processed_data || em.joint_metadata as joint_metadata, + sepd.errors, + sepd.warnings, + case + when se.released_at is not null then 'APPROVED_FOR_RELEASE' + when se.is_revocation then 'AWAITING_APPROVAL' + when sepd.processing_status = 'IN_PROCESSING' then 'IN_PROCESSING' + when sepd.processing_status = 'HAS_ERRORS' then 'HAS_ERRORS' + when sepd.processing_status = 'FINISHED' then 'AWAITING_APPROVAL' + else 'RECEIVED' + end as status +from + sequence_entries se + left join sequence_entries_preprocessed_data sepd on + se.accession = sepd.accession + and se.version = sepd.version + and sepd.pipeline_version = (select version from current_processing_pipeline) + left join external_metadata_view em on + se.accession = em.accession + and se.version = em.version; \ No newline at end of file diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt index 5076ce038..ff3e556f1 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/submission/GetReleasedDataEndpointTest.kt @@ -106,6 +106,7 @@ class GetReleasedDataEndpointTest( "releasedDate" to TextNode(Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString()), "submittedDate" to TextNode(Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString()), "dataUseTermsRestrictedUntil" to NullNode.getInstance(), + "versionComment" to NullNode.getInstance(), "booleanColumn" to BooleanNode.TRUE, ) @@ -214,10 +215,17 @@ class GetReleasedDataEndpointTest( value, `is`(TextNode(Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString())), ) + "releasedDate" -> assertThat( value, `is`(TextNode(Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString())), ) + + "versionComment" -> assertThat( + value, + `is`(TextNode("This is a test revocation")), + ) + else -> assertThat("value for $key", value, `is`(NullNode.instance)) } } 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 db51dfdfe..c4e1f6752 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 @@ -178,10 +178,17 @@ class SubmissionControllerClient(private val mockMvc: MockMvc, private val objec listOfSequenceEntriesToRevoke: List, organism: String = DEFAULT_ORGANISM, jwt: String? = jwtForDefaultUser, + versionComment: String? = null, ): ResultActions = mockMvc.perform( post(addOrganismToPath("/revoke", organism = organism)) .contentType(MediaType.APPLICATION_JSON) - .content("""{"accessions":${objectMapper.writeValueAsString(listOfSequenceEntriesToRevoke)}}""") + .content( + """{"accessions":${ + objectMapper.writeValueAsString( + listOfSequenceEntriesToRevoke, + ) + }, "versionComment":${objectMapper.writeValueAsString(versionComment)}}""", + ) .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 597164a3f..155656c6f 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 @@ -196,7 +196,14 @@ class SubmissionConvenienceClient( fun prepareRevokedSequenceEntries(organism: String = DEFAULT_ORGANISM): List { val accessionVersions = prepareDataTo(Status.APPROVED_FOR_RELEASE, organism = organism) - val revocationVersions = revokeSequenceEntries(accessionVersions.map { it.accession }, organism = organism) + val revocationVersions = + revokeSequenceEntries( + accessionVersions.map { + it.accession + }, + organism = organism, + versionComment = "This is a test revocation", + ) return approveProcessedSequenceEntries(revocationVersions, organism = organism) } @@ -323,11 +330,13 @@ class SubmissionConvenienceClient( listOfAccessionsToRevoke: List, organism: String = DEFAULT_ORGANISM, username: String = DEFAULT_USER_NAME, + versionComment: String? = null, ): List = deserializeJsonResponse( client.revokeSequenceEntries( listOfAccessionsToRevoke, organism = organism, jwt = generateJwtFor(username), + versionComment = versionComment, ), ) diff --git a/kubernetes/loculus/values.yaml b/kubernetes/loculus/values.yaml index 9b911a2bd..1d12e1be7 100644 --- a/kubernetes/loculus/values.yaml +++ b/kubernetes/loculus/values.yaml @@ -1007,7 +1007,7 @@ defaultOrganismConfig: &defaultOrganismConfig rangeSearch: true preprocessing: inputs: {input: nextclade.coverage} - - name: version_comment + - name: versionComment displayName: Version Comment header: Submission details website: &website diff --git a/website/src/components/SequenceDetailsPage/RevocationEntryDataTable.astro b/website/src/components/SequenceDetailsPage/RevocationEntryDataTable.astro index ceb606ffe..12d15c255 100644 --- a/website/src/components/SequenceDetailsPage/RevocationEntryDataTable.astro +++ b/website/src/components/SequenceDetailsPage/RevocationEntryDataTable.astro @@ -8,6 +8,7 @@ import { RELEASED_AT_FIELD, IS_REVOCATION_FIELD, ACCESSION_FIELD, + VERSION_COMMENT_FIELD, VERSION_STATUS_FIELD, SUBMITTER_FIELD, GROUP_NAME_FIELD, @@ -31,6 +32,7 @@ const relevantFieldsForRevocationVersions = [ ACCESSION_FIELD, IS_REVOCATION_FIELD, RELEASED_AT_FIELD, + VERSION_COMMENT_FIELD, VERSION_STATUS_FIELD, SUBMITTED_AT_FIELD, SUBMITTER_FIELD, diff --git a/website/src/components/SequenceDetailsPage/RevokeButton.tsx b/website/src/components/SequenceDetailsPage/RevokeButton.tsx index f4e9ec2b6..747b2bdae 100644 --- a/website/src/components/SequenceDetailsPage/RevokeButton.tsx +++ b/website/src/components/SequenceDetailsPage/RevokeButton.tsx @@ -1,4 +1,5 @@ -import { type FC } from 'react'; +import { type FC, useState } from 'react'; +import { confirmAlert } from 'react-confirm-alert'; import { toast } from 'react-toastify'; import { routes } from '../../routes/routes'; @@ -6,7 +7,6 @@ import { backendClientHooks } from '../../services/serviceHooks'; import type { ClientConfig } from '../../types/runtimeConfig'; import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader'; import { stringifyMaybeAxiosError } from '../../utils/stringifyMaybeAxiosError'; -import { displayConfirmationDialog } from '../ConfirmationDialog'; import { withQueryProvider } from '../common/withQueryProvider'; type RevokeSequenceEntryProps = { @@ -26,7 +26,10 @@ const InnerRevokeButton: FC = ({ }) => { const hooks = backendClientHooks(clientConfig); const useRevokeSequenceEntries = hooks.useRevokeSequences( - { headers: createAuthorizationHeader(accessToken), params: { organism } }, + { + headers: createAuthorizationHeader(accessToken), + params: { organism }, + }, { onSuccess: () => { document.location = routes.userSequenceReviewPage(organism, groupId); @@ -39,15 +42,15 @@ const InnerRevokeButton: FC = ({ }, ); - const handleRevokeSequenceEntry = () => { - useRevokeSequenceEntries.mutate({ accessions: [accessionVersion] }); + const handleRevokeSequenceEntry = (inputValue: string) => { + useRevokeSequenceEntries.mutate({ accessions: [accessionVersion], versionComment: inputValue }); }; return ( + + +

{dialogText}

+ + setInputValue(e.target.value)} + placeholder='Enter reason for revocation' + className='mt-4 w-11/12 mx-auto block' + /> + +
+
+ +
+
+ +
+
+ + ); +}; + export const RevokeButton = withQueryProvider(InnerRevokeButton); function getRevokeSequenceEntryErrorMessage(error: unknown) { diff --git a/website/src/services/backendApi.ts b/website/src/services/backendApi.ts index 835b90fe6..d8240de0a 100644 --- a/website/src/services/backendApi.ts +++ b/website/src/services/backendApi.ts @@ -3,7 +3,6 @@ import z from 'zod'; import { authorizationHeader, notAuthorizedError, withOrganismPathSegment } from './commonApiTypes.ts'; import { - accessions, accessionVersion, accessionVersionsFilterWithApprovalScope, accessionVersionsFilterWithDeletionScope, @@ -13,6 +12,7 @@ import { getSequencesResponse, info, problemDetail, + revocationRequest, sequenceEntryToEdit, submissionIdMapping, submitFiles, @@ -80,9 +80,9 @@ const revokeSequencesEndpoint = makeEndpoint({ parameters: [ authorizationHeader, { - name: 'accessions', + name: 'data', type: 'Body', - schema: accessions, + schema: revocationRequest, }, ], response: z.array(submissionIdMapping), diff --git a/website/src/settings.ts b/website/src/settings.ts index 3fc71b9eb..2b843ad4b 100644 --- a/website/src/settings.ts +++ b/website/src/settings.ts @@ -11,5 +11,6 @@ export const SUBMITTER_FIELD = 'submitter'; export const GROUP_NAME_FIELD = 'groupName'; export const GROUP_ID_FIELD = 'groupId'; export const DATA_USE_TERMS_FIELD = 'dataUseTerms'; +export const VERSION_COMMENT_FIELD = 'versionComment'; export const metadataDefaultDownloadDataFormat = 'tsv'; diff --git a/website/src/types/backend.ts b/website/src/types/backend.ts index 407aa58e5..b6ac11de3 100644 --- a/website/src/types/backend.ts +++ b/website/src/types/backend.ts @@ -154,7 +154,14 @@ export const editedSequenceEntryData = accessionVersion.merge( }), }), ); -export type EditedSequenceEntryData = z.infer; +export type EditedSequenceEntryData = z.infer; + +export const revocationRequest = z.object({ + accessions: z.array(accession), + versionComment: z.string().nullable(), +}); + +export type RevocationRequest = z.infer; export const unprocessedData = accessionVersion.merge( z.object({ diff --git a/website/tests/util/backendCalls.ts b/website/tests/util/backendCalls.ts index 23aac12ed..36d8b1b0c 100644 --- a/website/tests/util/backendCalls.ts +++ b/website/tests/util/backendCalls.ts @@ -74,14 +74,16 @@ export const revokeReleasedData = async ( token: string, groupId: number, ): Promise => { - const body = { - accessions, - }; + const versionComment = 'Revoked by end-to-end test'; - const responseResult = await backendClient.call('revokeSequences', body, { - params: { organism: dummyOrganism.key }, - headers: createAuthorizationHeader(token), - }); + const responseResult = await backendClient.call( + 'revokeSequences', + { accessions, versionComment }, + { + params: { organism: dummyOrganism.key }, + headers: createAuthorizationHeader(token), + }, + ); const accessionVersions = responseResult.match( (accessionVersions) => accessionVersions,