Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ data class SeqSetCitation(
val contributors: List<CitationContributor>,
)

data class SeqSetCitingSequence(val seqSetAccession: String, val sequenceAccession: String)

data class SequenceCitation(
val sourceDOI: String,
val title: String,
val year: Int,
val contributors: List<CitationContributor>,
val seqSets: List<SeqSetCitingSequence>,
)

data class ResponseSeqSet(val seqSetId: String, val seqSetVersion: Long)

data class CitedBy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class SecurityConfig {
"/get-seqset",
"/get-seqset-records",
"/get-seqset-cited-by-publication",
"/get-sequence-cited-by-publication",
"/get-author",
"/*/get-released-data",
"/files/get/**",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package org.loculus.backend.controller

import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import org.loculus.backend.api.AccessionVersion
import org.loculus.backend.api.AuthorProfile
import org.loculus.backend.api.CitedBy
import org.loculus.backend.api.ResponseSeqSet
import org.loculus.backend.api.SeqSet
import org.loculus.backend.api.SeqSetCitation
import org.loculus.backend.api.SeqSetRecord
import org.loculus.backend.api.SequenceCitation
import org.loculus.backend.api.SubmittedSeqSet
import org.loculus.backend.api.SubmittedSeqSetRecord
import org.loculus.backend.api.SubmittedSeqSetUpdate
Expand Down Expand Up @@ -109,6 +111,13 @@ class SeqSetCitationsController(
fun getSeqSetCitedByPublication(@RequestParam seqSetId: String, @RequestParam version: Long): List<SeqSetCitation> =
seqSetCitationsService.getSeqSetCitedByPublication(seqSetId, version)

@Operation(description = "Get sequence citations from publications")
@GetMapping("/get-sequence-cited-by-publication")
fun getSequenceCitedByPublication(
@RequestParam accession: String,
@RequestParam version: Long,
): List<SequenceCitation> = seqSetCitationsService.getSequenceCitedByPublication(accession, version)

@Operation(description = "Get an author")
@GetMapping("/get-author")
fun getAuthor(@RequestParam username: String): AuthorProfile {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ import org.loculus.backend.api.ResponseSeqSet
import org.loculus.backend.api.SeqSet
import org.loculus.backend.api.SeqSetCitation
import org.loculus.backend.api.SeqSetCitationsConstants
import org.loculus.backend.api.SeqSetCitingSequence
import org.loculus.backend.api.SeqSetCitingSource
import org.loculus.backend.api.SeqSetRecord
import org.loculus.backend.api.SequenceCitation
import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE
import org.loculus.backend.api.SubmittedSeqSetRecord
import org.loculus.backend.auth.AuthenticatedUser
Expand Down Expand Up @@ -517,6 +519,38 @@ class SeqSetCitationsDatabaseService(
}
}

fun getSequenceCitedByPublication(accession: String, version: Long): List<SequenceCitation> {
val accessionVersion = AccessionVersion(accession, version)
log.info { "Get sequence cited by publication for accession ${accessionVersion.displayAccessionVersion()}" }
val accessions = setOf(accessionVersion.accession, accessionVersion.displayAccessionVersion())

return SeqSetCitingSourceTable.innerJoin(
SeqSetToCitingSourceTable,
).innerJoin(
SeqSetsTable,
).innerJoin(SeqSetToRecordsTable).innerJoin(SeqSetRecordsTable).selectAll()
.where { SeqSetRecordsTable.accession inList accessions }
.groupBy { it[SeqSetCitingSourceTable.citingSourceId] }
.map { (_, rows) ->
val first = rows.first()
SequenceCitation(
sourceDOI = first[SeqSetCitingSourceTable.sourceDOI],
title = first[SeqSetCitingSourceTable.title],
year = first[SeqSetCitingSourceTable.year],
contributors = first[SeqSetCitingSourceTable.contributors],
seqSets = rows.map {
SeqSetCitingSequence(
seqSetAccession = AccessionVersion(
it[SeqSetsTable.seqSetId],
it[SeqSetsTable.seqSetVersion],
).displayAccessionVersion(),
sequenceAccession = it[SeqSetRecordsTable.accession],
)
},
)
}
}

fun validateSeqSetRecords(seqSetRecords: List<SubmittedSeqSetRecord>) {
if (seqSetRecords.isEmpty()) {
throw UnprocessableEntityException(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,39 @@ class CitationEndpointsTest(
)
}

@Test
fun `WHEN get sequence cited by publication for sequence in cited seqSet THEN returns citations`() {
val seqSetResult = client.createSeqSet().andExpect(status().isOk).andReturn()
val seqSetId = JsonPath.read<String>(seqSetResult.response.contentAsString, "$.seqSetId")
val seqSetVersion =
JsonPath.read<Int>(seqSetResult.response.contentAsString, "$.seqSetVersion").toLong()
client.createSeqSetDOI(seqSetId = seqSetId, seqSetVersion = seqSetVersion).andExpect(status().isOk)
val seqSetDOI = "${MOCK_DOI_PREFIX}/$seqSetId.$seqSetVersion"

val seqSetCitingSource = SeqSetCitingSource(
sourceDOI = "10.5678/citing-paper",
title = "A paper citing the seqSet",
year = 2024,
contributors = listOf(CitationContributor(givenName = "Jane", surname = "Doe")),
seqSetDOIs = setOf(seqSetDOI),
)
every { crossRefService.isActive } returns true
every { crossRefService.getCrossRefCitedBy(MOCK_DOI_PREFIX) } returns listOf(seqSetCitingSource)
seqSetCrossRefCitationsTask.task()

client.getSequenceCitedByPublication(accession = MOCK_SEQ_ACCESSION, version = MOCK_SEQ_VERSION)
.andExpect(status().isOk)
.andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
.andExpect(jsonPath("\$").isArray)
.andExpect(jsonPath("\$.length()").value(1))
.andExpect(jsonPath("\$[0].sourceDOI").value(seqSetCitingSource.sourceDOI))
.andExpect(jsonPath("\$[0].title").value(seqSetCitingSource.title))
.andExpect(jsonPath("\$[0].year").value(seqSetCitingSource.year))
.andExpect(jsonPath("\$[0].seqSets.length()").value(1))
.andExpect(jsonPath("\$[0].seqSets[0].seqSetAccession").value("$seqSetId.$seqSetVersion"))
.andExpect(jsonPath("\$[0].seqSets[0].sequenceAccession").value(MOCK_ACCESSION_VERSION))
}

@Test
fun `WHEN multiple crossref citation runs link the same citing source THEN all citations are recorded`() {
val seqSetAResult = client.createSeqSet().andExpect(status().isOk).andReturn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ class SeqSetCitationsControllerClient(private val mockMvc: MockMvc) {
.param("version", seqSetVersion.toString()),
)

fun getSequenceCitedByPublication(
accession: String = MOCK_SEQ_ACCESSION,
version: Long = MOCK_SEQ_VERSION,
): ResultActions = mockMvc.perform(
get("/get-sequence-cited-by-publication")
.param("accession", accession)
.param("version", version.toString()),
)

fun getAuthor(username: String): ResultActions = mockMvc.perform(
get("/get-author")
.param("username", username),
Expand Down
1 change: 1 addition & 0 deletions website/src/components/SearchPage/SearchFullUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export const InnerSearchFullUI = ({
referenceGenomesInfo={referenceGenomesInfo}
/>
<SeqPreviewModal
clientConfig={clientConfig}
key={previewedSeqId ?? 'seq-modal'}
seqId={previewedSeqId ?? ''}
accessToken={accessToken}
Expand Down
5 changes: 5 additions & 0 deletions website/src/components/SearchPage/SeqPreviewModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { type Group } from '../../types/backend';
import type { SequenceFlaggingConfig } from '../../types/config.ts';
import { type DetailsJson, detailsJsonSchema } from '../../types/detailsJson.ts';
import { type ReferenceGenomesInfo } from '../../types/referencesGenomes';
import type { ClientConfig } from '../../types/runtimeConfig.ts';
import { getSegmentNames } from '../../utils/sequenceTypeHelpers.ts';
import { SequenceCitations } from '../SequenceDetailsPage/SequenceCitations.tsx';
import { SequenceDataUI } from '../SequenceDetailsPage/SequenceDataUI';
import { SequenceEntryHistoryMenu } from '../SequenceDetailsPage/SequenceEntryHistoryMenu';
import SequencesBanner from '../SequenceDetailsPage/SequencesBanner.tsx';
Expand All @@ -23,6 +25,7 @@ const BUTTONCLASS =
'inline-flex justify-center px-4 py-2 text-sm font-medium text-gray-900 border border-transparent rounded-md hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500';

interface SeqPreviewModalProps {
clientConfig: ClientConfig;
seqId: string;
accessToken?: string;
isOpen: boolean;
Expand All @@ -38,6 +41,7 @@ interface SeqPreviewModalProps {
const logger = getClientLogger('SeqPreviewModal');

export const SeqPreviewModal: React.FC<SeqPreviewModalProps> = ({
clientConfig,
seqId,
accessToken,
isOpen,
Expand Down Expand Up @@ -113,6 +117,7 @@ export const SeqPreviewModal: React.FC<SeqPreviewModalProps> = ({
setPreviewedSeqId={setPreviewedSeqId}
/>
)}
<SequenceCitations clientConfig={clientConfig} accessionVersion={seqId} />
<Button
type='button'
className={BUTTONCLASS}
Expand Down
91 changes: 91 additions & 0 deletions website/src/components/SeqSetCitations/CitationsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { type FC } from 'react';

import { routes } from '../../routes/routes';
import { type SeqSetCitation, type SequenceCitation, type SeqSetCitingSequence } from '../../types/seqSetCitation';

interface CitationsListProps {
isLoading: boolean;
error: Error | null;
citations: SeqSetCitation[] | SequenceCitation[];
}

interface CitationItemProps {
citation: SeqSetCitation | SequenceCitation;
}

interface CitationSeqSetsProps {
seqSets: SeqSetCitingSequence[];
}

const CitationDOI: FC<CitationItemProps> = ({ citation }) => {
return (
<span>
DOI:
<a
className='text-primary-600 mx-1'
href={`https://doi.org/${citation.sourceDOI}`}
target='_blank'
rel='noopener noreferrer'
>
{citation.sourceDOI}
</a>
</span>
);
};

const CitationSeqSets: FC<CitationSeqSetsProps> = ({ seqSets }) => {
return (
<span>
Via SeqSet{seqSets.length > 1 ? 's' : ''}:
{seqSets.map((seqSet) => (
<span key={seqSet.seqSetAccession} className='mx-1'>
<a className='text-primary-600' href={routes.seqSetPage(seqSet.seqSetAccession)}>
{seqSet.seqSetAccession}
</a>
<span className='text-gray-500 text-sm ml-1'>(references {seqSet.sequenceAccession})</span>
</span>
))}
</span>
);
};

const CitationItem: FC<CitationItemProps> = ({ citation }) => {
return (
<div className='flex flex-col gap-2'>
<div>
{citation.contributors
.map((contributor) => `${contributor.givenName} ${contributor.surname}`)
.join(', ')}
. <i>{citation.title}</i>, {citation.year}.
</div>
<div>
<CitationDOI citation={citation} />
</div>
{'seqSets' in citation && citation.seqSets.length > 0 && <CitationSeqSets seqSets={citation.seqSets} />}
</div>
);
};

export const CitationsList: FC<CitationsListProps> = ({ isLoading, error, citations }) => {
return (
<div className='w-full pt-2'>
<div className='overflow-y-auto max-h-[60vh]'>
{isLoading ? (
<span className='loading loading-spinner'></span>
) : error ? (
<span>Failed to load citations.</span>
) : citations.length > 0 ? (
<ul className='max-w-3xl space-y-4'>
{citations.map((citation) => (
<li key={citation.sourceDOI} className='border border-gray-200 p-4 rounded-lg'>
<CitationItem citation={citation} />
</li>
))}
</ul>
) : (
<span>No citations found.</span>
)}
</div>
</div>
);
};
72 changes: 0 additions & 72 deletions website/src/components/SeqSetCitations/SeqSetCitationsList.tsx

This file was deleted.

Loading
Loading