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 15dcaa2604..10662d664f 100644 --- a/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt +++ b/backend/src/main/kotlin/org/loculus/backend/api/SubmissionTypes.kt @@ -62,6 +62,7 @@ data class AccessionVersionsFilterWithDeletionScope( description = ACCESSION_VERSIONS_FILTER_DESCRIPTION, ) val accessionVersionsFilter: List? = null, + val groupIdsFilter: List? = null, @Schema( description = "Scope for deletion. If scope is set to 'ALL', all sequences are deleted. " + "If scope is set to 'PROCESSED_WITH_ERRORS', only processed sequences with errors are deleted. " + @@ -81,6 +82,7 @@ data class AccessionVersionsFilterWithApprovalScope( description = ACCESSION_VERSIONS_FILTER_DESCRIPTION, ) val accessionVersionsFilter: List? = null, + val groupIdsFilter: List? = null, @Schema( description = "Scope for approval. If scope is set to 'ALL', all sequences are approved. " + "If scope is set to 'WITHOUT_WARNINGS', only sequences without warnings are approved.", 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 c963204f8d..31c5be7f97 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/SubmissionController.kt @@ -294,6 +294,7 @@ class SubmissionController( ): List = submissionDatabaseService.approveProcessedData( authenticatedUser = authenticatedUser, accessionVersionsFilter = body.accessionVersionsFilter, + groupIdsFilter = body.groupIdsFilter, organism = organism, scope = body.scope, ) @@ -321,6 +322,7 @@ class SubmissionController( ): List = submissionDatabaseService.deleteSequenceEntryVersions( body.accessionVersionsFilter, authenticatedUser, + body.groupIdsFilter, organism, body.scope, ) 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 4906d9c3af..1efdbf37b1 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 @@ -276,9 +276,24 @@ class SubmissionDatabaseService( ) } + private fun getGroupCondition(groupIdsFilter: List?, authenticatedUser: AuthenticatedUser): Op { + return if (groupIdsFilter != null) { + groupManagementPreconditionValidator.validateUserIsAllowedToModifyGroups( + groupIdsFilter, + authenticatedUser, + ) + SequenceEntriesView.groupIsOneOf(groupIdsFilter) + } else if (authenticatedUser.isSuperUser) { + Op.TRUE + } else { + SequenceEntriesView.groupIsOneOf(groupManagementDatabaseService.getGroupIdsOfUser(authenticatedUser)) + } + } + fun approveProcessedData( authenticatedUser: AuthenticatedUser, accessionVersionsFilter: List?, + groupIdsFilter: List?, organism: Organism, scope: ApproveDataScope, ): List { @@ -315,8 +330,10 @@ class SubmissionDatabaseService( Op.TRUE } + val groupCondition = getGroupCondition(groupIdsFilter, authenticatedUser) + val accessionVersionsToUpdate = SequenceEntriesView - .select { statusCondition and accessionCondition and scopeCondition } + .select { statusCondition and accessionCondition and scopeCondition and groupCondition } .map { AccessionVersion(it[SequenceEntriesView.accessionColumn], it[SequenceEntriesView.versionColumn]) } if (accessionVersionsToUpdate.isEmpty()) { @@ -448,17 +465,7 @@ class SubmissionDatabaseService( val listOfStatuses = statusesFilter ?: Status.entries - val groupCondition = if (groupIdsFilter != null) { - groupManagementPreconditionValidator.validateUserIsAllowedToModifyGroups( - groupIdsFilter, - authenticatedUser, - ) - SequenceEntriesView.groupIsOneOf(groupIdsFilter) - } else if (authenticatedUser.isSuperUser) { - Op.TRUE - } else { - SequenceEntriesView.groupIsOneOf(groupManagementDatabaseService.getGroupIdsOfUser(authenticatedUser)) - } + val groupCondition = getGroupCondition(groupIdsFilter, authenticatedUser) val baseQuery = SequenceEntriesView .join( @@ -611,6 +618,7 @@ class SubmissionDatabaseService( fun deleteSequenceEntryVersions( accessionVersionsFilter: List?, authenticatedUser: AuthenticatedUser, + groupIdsFilter: List?, organism: Organism, scope: DeleteSequenceScope, ): List { @@ -655,9 +663,11 @@ class SubmissionDatabaseService( DeleteSequenceScope.ALL -> SequenceEntriesView.statusIsOneOf(listOfDeletableStatuses) } + val groupCondition = getGroupCondition(groupIdsFilter, authenticatedUser) + val sequenceEntriesToDelete = SequenceEntriesView .slice(SequenceEntriesView.accessionColumn, SequenceEntriesView.versionColumn) - .select { accessionCondition and scopeCondition } + .select { accessionCondition and scopeCondition and groupCondition } .map { AccessionVersion( it[SequenceEntriesView.accessionColumn], diff --git a/website/src/components/ReviewPage/ReviewPage.spec.tsx b/website/src/components/ReviewPage/ReviewPage.spec.tsx index 339e1a9ac0..e48c521094 100644 --- a/website/src/components/ReviewPage/ReviewPage.spec.tsx +++ b/website/src/components/ReviewPage/ReviewPage.spec.tsx @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'; import { ReviewPage } from './ReviewPage.tsx'; import { openDataUseTerms } from '../../../tests/e2e.fixture.ts'; -import { mockRequest, testAccessToken, testConfig, testOrganism } from '../../../vitest.setup.ts'; +import { mockRequest, testAccessToken, testConfig, testGroups, testOrganism } from '../../../vitest.setup.ts'; import { approvedForReleaseStatus, awaitingApprovalStatus, @@ -13,9 +13,16 @@ import { type SequenceEntryStatus, } from '../../types/backend.ts'; +const testGroup = testGroups[0]; + function renderReviewPage() { return render( - , + , ); } @@ -96,6 +103,22 @@ describe('ReviewPage', () => { }); }); + test('should request data from the right group', async () => { + let requestedGroupFilter: string | null = null; + mockRequest.backend.getSequences(200, generateGetSequencesResponse([]), (request) => { + const params = new URL(request.url).searchParams; + requestedGroupFilter = params.get('groupIdsFilter'); + }); + + const { getByText } = renderReviewPage(); + + await waitFor(() => { + expect(getByText('You do not currently have any unreleased sequences awaiting review.')).toBeDefined(); + }); + + expect(requestedGroupFilter).toBe(testGroup.groupId.toString()); + }); + test('should render the review page and show button to bulk delete/approve all erroneous sequences', async () => { mockRequest.backend.getSequences( 200, diff --git a/website/src/components/ReviewPage/ReviewPage.tsx b/website/src/components/ReviewPage/ReviewPage.tsx index c25010177e..ad3b9c893a 100644 --- a/website/src/components/ReviewPage/ReviewPage.tsx +++ b/website/src/components/ReviewPage/ReviewPage.tsx @@ -11,6 +11,7 @@ import { deleteAllDataScope, deleteProcessedDataWithErrorsScope, type GetSequencesResponse, + type Group, hasErrorsStatus, inProcessingStatus, type PageQuery, @@ -34,6 +35,7 @@ let oldSequenceData: GetSequencesResponse | null = null; type ReviewPageProps = { clientConfig: ClientConfig; organism: string; + group: Group; accessToken: string; }; @@ -66,12 +68,12 @@ const NumberAndVisibility = ({ ); }; -const InnerReviewPage: FC = ({ clientConfig, organism, accessToken }) => { +const InnerReviewPage: FC = ({ clientConfig, organism, group, accessToken }) => { const { errorMessage, isErrorOpen, openErrorFeedback, closeErrorFeedback } = useErrorFeedbackState(); const [pageQuery, setPageQuery] = useState({ page: 1, size: pageSizeOptions[2] }); - const hooks = useSubmissionOperations(organism, clientConfig, accessToken, openErrorFeedback, pageQuery); + const hooks = useSubmissionOperations(organism, group, clientConfig, accessToken, openErrorFeedback, pageQuery); const showErrors = hooks.includedStatuses.includes(hasErrorsStatus); const showUnprocessed = @@ -229,6 +231,7 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo 'Are you sure you want to discard all sequences with errors?', onConfirmation: () => { hooks.deleteSequenceEntries({ + groupIdsFilter: [group.groupId], scope: deleteProcessedDataWithErrorsScope.value, }); }, @@ -248,6 +251,7 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo dialogText: `Are you sure you want to discard all ${finishedCount} processed sequences?`, onConfirmation: () => { hooks.deleteSequenceEntries({ + groupIdsFilter: [group.groupId], scope: deleteAllDataScope.value, }); }, @@ -270,6 +274,7 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo dialogText: 'Are you sure you want to release all valid sequences?', onConfirmation: () => hooks.approveProcessedData({ + groupIdsFilter: [group.groupId], scope: approveAllDataScope.value, }), }) @@ -293,12 +298,14 @@ const InnerReviewPage: FC = ({ clientConfig, organism, accessTo approveAccessionVersion={() => hooks.approveProcessedData({ accessionVersionsFilter: [sequence], + groupIdsFilter: [group.groupId], scope: approveAllDataScope.value, }) } deleteAccessionVersion={() => hooks.deleteSequenceEntries({ accessionVersionsFilter: [sequence], + groupIdsFilter: [group.groupId], scope: deleteAllDataScope.value, }) } diff --git a/website/src/hooks/useSubmissionOperations.ts b/website/src/hooks/useSubmissionOperations.ts index 6db71234fa..6fe81368ee 100644 --- a/website/src/hooks/useSubmissionOperations.ts +++ b/website/src/hooks/useSubmissionOperations.ts @@ -6,6 +6,7 @@ import { backendApi } from '../services/backendApi.ts'; import { backendClientHooks } from '../services/serviceHooks.ts'; import { awaitingApprovalStatus, + type Group, hasErrorsStatus, inProcessingStatus, type PageQuery, @@ -17,6 +18,7 @@ import { stringifyMaybeAxiosError } from '../utils/stringifyMaybeAxiosError.ts'; export function useSubmissionOperations( organism: string, + group: Group, clientConfig: ClientConfig, accessToken: string, openErrorFeedback: (message: string) => void, @@ -32,6 +34,7 @@ export function useSubmissionOperations( organism, }, queries: { + groupIdsFilter: group.groupId.toString(), initialStatusesFilter: allRelevantStatuses.join(','), statusesFilter: includedStatuses.join(','), page: pageQuery.page - 1, diff --git a/website/src/pages/[organism]/submission/[groupId]/review.astro b/website/src/pages/[organism]/submission/[groupId]/review.astro index 399df19521..15f37dc46c 100644 --- a/website/src/pages/[organism]/submission/[groupId]/review.astro +++ b/website/src/pages/[organism]/submission/[groupId]/review.astro @@ -1,24 +1,30 @@ --- import { ReviewPage } from '../../../../components/ReviewPage/ReviewPage'; -import NeedToLogin from '../../../../components/common/NeedToLogin.astro'; +import SubmissionPageWrapper from '../../../../components/Submission/SubmissionPageWrapper.astro'; import { getRuntimeConfig } from '../../../../config'; -import BaseLayout from '../../../../layouts/BaseLayout.astro'; import { type ClientConfig } from '../../../../types/runtimeConfig'; import { getAccessToken } from '../../../../utils/getAccessToken'; +import { getGroupsAndCurrentGroup } from '../../../../utils/submissionPages'; + const organism = Astro.params.organism!; -const accessToken = getAccessToken(Astro.locals.session)!; +const groupsResult = await getGroupsAndCurrentGroup(Astro.params, Astro.locals.session); + const clientConfig: ClientConfig = getRuntimeConfig().public; --- -{ - accessToken ? ( - -

Current submissions

- -
- ) : ( - - - - ) -} + + { + groupsResult.match( + ({ currentGroup: group }) => ( + + ), + () => undefined, + ) + } + diff --git a/website/src/types/backend.ts b/website/src/types/backend.ts index 51c6738c02..92a01a3b2c 100644 --- a/website/src/types/backend.ts +++ b/website/src/types/backend.ts @@ -61,6 +61,7 @@ export const approveProcessedDataWithoutWarningsScope = z.literal('WITHOUT_WARNI export const accessionVersionsFilterWithApprovalScope = accessionVersionsFilter.merge( z.object({ + groupIdsFilter: z.array(z.number()), scope: z.union([approveAllDataScope, approveProcessedDataWithoutWarningsScope]), }), ); @@ -72,6 +73,7 @@ export const deleteProcessedDataWithWarningsScope = z.literal('PROCESSED_WITH_WA export const accessionVersionsFilterWithDeletionScope = accessionVersionsFilter.merge( z.object({ + groupIdsFilter: z.array(z.number()), scope: z.union([ deleteAllDataScope, deleteProcessedAndRevocationConfirmationDataScope, diff --git a/website/tests/util/backendCalls.ts b/website/tests/util/backendCalls.ts index 3488f12b02..23aac12ed2 100644 --- a/website/tests/util/backendCalls.ts +++ b/website/tests/util/backendCalls.ts @@ -48,9 +48,14 @@ export const submitRevisedDataViaApi = async (accessions: Accession[], token: st return response.value; }; -export const approveProcessedData = async (accessionVersions: AccessionVersion[], token: string): Promise => { +export const approveProcessedData = async ( + accessionVersions: AccessionVersion[], + token: string, + groupId: number, +): Promise => { const body = { accessionVersionsFilter: accessionVersions, + groupIdsFilter: [groupId], scope: 'ALL' as const, }; @@ -64,7 +69,11 @@ export const approveProcessedData = async (accessionVersions: AccessionVersion[] } }; -export const revokeReleasedData = async (accessions: Accession[], token: string): Promise => { +export const revokeReleasedData = async ( + accessions: Accession[], + token: string, + groupId: number, +): Promise => { const body = { accessions, }; @@ -83,7 +92,11 @@ export const revokeReleasedData = async (accessions: Accession[], token: string) const confirmationResponse = await backendClient.call( 'approveProcessedData', - { scope: 'ALL', accessionVersionsFilter: accessionVersions }, + { + scope: 'ALL', + accessionVersionsFilter: accessionVersions, + groupIdsFilter: [groupId], + }, { params: { organism: dummyOrganism.key }, headers: createAuthorizationHeader(token), diff --git a/website/tests/util/prepareDataToBe.ts b/website/tests/util/prepareDataToBe.ts index ec4e3d803f..006abc70ba 100644 --- a/website/tests/util/prepareDataToBe.ts +++ b/website/tests/util/prepareDataToBe.ts @@ -56,7 +56,7 @@ const prepareDataToBeAwaitingApproval = async (token: string, groupId: number) = const prepareDataToBeApprovedForRelease = async (token: string, groupId: number) => { const sequenceEntries = await prepareDataToBeAwaitingApproval(token, groupId); - await approveProcessedData(sequenceEntries, token); + await approveProcessedData(sequenceEntries, token, groupId); return sequenceEntries; }; @@ -67,6 +67,7 @@ const prepareDataToBeRevoked = async (token: string, groupId: number) => { return revokeReleasedData( sequenceEntries.map((entry) => entry.accession), token, + groupId, ); }; @@ -87,7 +88,7 @@ const prepareDataToBeRevisedForRelease = async (token: string, groupId: number) })); await fakeProcessingPipeline.submit(options); - await approveProcessedData(submittedRevisionAccessionVersion, token); + await approveProcessedData(submittedRevisionAccessionVersion, token, groupId); return submittedRevisionAccessionVersion; }; diff --git a/website/vitest.setup.ts b/website/vitest.setup.ts index 7ff86d3717..0a8bdbd608 100755 --- a/website/vitest.setup.ts +++ b/website/vitest.setup.ts @@ -6,7 +6,7 @@ import { http } from 'msw'; import { setupServer } from 'msw/node'; import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'; -import type { GetSequencesResponse, SequenceEntryToEdit, SubmissionIdMapping } from './src/types/backend.ts'; +import type { GetSequencesResponse, Group, SequenceEntryToEdit, SubmissionIdMapping } from './src/types/backend.ts'; import type { DetailsResponse, InsertionsResponse, LapisError, MutationsResponse } from './src/types/lapis.ts'; import type { RuntimeConfig } from './src/types/runtimeConfig.ts'; @@ -118,9 +118,13 @@ const backendRequestMocks = { getSequences: ( statusCode: number = 200, response: GetSequencesResponse = { sequenceEntries: [], statusCounts: {} }, + callback?: (request: Request) => void, ) => { testServer.use( - http.get(`${testConfig.serverSide.backendUrl}/${testOrganism}/get-sequences`, () => { + http.get(`${testConfig.serverSide.backendUrl}/${testOrganism}/get-sequences`, ({ request }) => { + if (callback !== undefined) { + callback(request); + } return new Response(JSON.stringify(response), { status: statusCode, }); @@ -254,12 +258,30 @@ const lapisRequestMocks = { }, }; -export const testGroups = [ +export const testGroups: Group[] = [ { + groupId: 1, groupName: 'Group1', + institution: 'Institution 1', + contactEmail: 'group1@institution1.org', + address: { + line1: '', + city: '', + postalCode: '', + country: 'Switzerland', + }, }, { + groupId: 1, groupName: 'Group2', + institution: 'Institution 2', + contactEmail: 'group2@institution2.org', + address: { + line1: '', + city: '', + postalCode: '', + country: 'Switzerland', + }, }, ];