Skip to content
Merged
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
18 changes: 17 additions & 1 deletion .github/workflows/example-data-seeder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,24 @@ env:
DOCKER_IMAGE_NAME: ghcr.io/genspectrum/dashboards/example-data-seeder

jobs:
test:
name: Test Example Data Seeder
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: prefix-dev/setup-pixi@v0.9.6
with:
manifest-path: collection-seeding/pixi.toml
environments: test

- name: Run tests
working-directory: ./collection-seeding
run: pixi run -e test test

dockerImage:
name: Build Example Data Seeder Docker Image
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -37,7 +53,7 @@ jobs:
- name: Build and push image
uses: docker/build-push-action@v7
with:
context: ./example-data
context: ./collection-seeding
tags: ${{ steps.dockerMetadata.outputs.tags }}
cache-from: type=gha,scope=example-data-seeder-${{ github.ref }}
cache-to: type=gha,mode=max,scope=example-data-seeder-${{ github.ref }}
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,10 @@ logs
node_modules/

.env

# Python
__pycache__/
*.pyc

# pixi
.pixi/
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,19 @@ class CollectionsController(private val collectionModel: CollectionModel) {
@Operation(
summary = "Get collections",
description = "Returns collections filtered by optional userId and/or organism parameters. " +
"Set includeVariants=true to include the full variant list; by default only variantCount is returned.",
"Set includeVariants=true to include the full variant list; by default only variantCount is returned. " +
"Set excludeSystemCollections=true to exclude collections owned by the system user.",
)
fun getCollections(
@RequestParam(required = false) userId: Long?,
@RequestParam(required = false) organism: String?,
@RequestParam(required = false, defaultValue = "false") includeVariants: Boolean,
@RequestParam(required = false, defaultValue = "false") excludeSystemCollections: Boolean,
): List<Collection> = collectionModel.getCollections(
userId = userId,
organism = organism,
includeVariants = includeVariants,
excludeSystemCollections = excludeSystemCollections,
)

@GetMapping("/collections/{id}", produces = [MediaType.APPLICATION_JSON_VALUE])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ import org.genspectrum.dashboardsbackend.controller.BadRequestException
import org.genspectrum.dashboardsbackend.controller.ForbiddenException
import org.genspectrum.dashboardsbackend.controller.NotFoundException
import org.genspectrum.dashboardsbackend.model.user.UserEntity
import org.genspectrum.dashboardsbackend.model.user.UserModel
import org.genspectrum.dashboardsbackend.util.now
import org.jetbrains.exposed.v1.core.JoinType
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.count
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.neq
import org.jetbrains.exposed.v1.core.notInList
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.select
Expand All @@ -27,8 +29,13 @@ import kotlin.time.Instant

@Service
@Transactional
class CollectionModel(private val dashboardsConfig: DashboardsConfig) {
fun getCollections(userId: Long?, organism: String?, includeVariants: Boolean = false): List<Collection> {
class CollectionModel(private val dashboardsConfig: DashboardsConfig, private val userModel: UserModel) {
fun getCollections(
userId: Long?,
organism: String?,
includeVariants: Boolean = false,
excludeSystemCollections: Boolean = false,
): List<Collection> {
if (userId != null) {
UserEntity.findById(userId) ?: throw NotFoundException("User $userId not found")
}
Expand All @@ -44,6 +51,12 @@ class CollectionModel(private val dashboardsConfig: DashboardsConfig) {
if (organism != null) {
collectionConditions = collectionConditions and (CollectionTable.organism eq organism)
}
if (excludeSystemCollections) {
val systemUserId = userModel.getSystemUserId()
if (systemUserId != null) {
collectionConditions = collectionConditions and (CollectionTable.ownedBy neq systemUserId)
}
}

val join = CollectionTable.join(VariantTable, JoinType.LEFT)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,27 @@ package org.genspectrum.dashboardsbackend.model.user
import org.genspectrum.dashboardsbackend.api.PublicUser
import org.genspectrum.dashboardsbackend.api.User
import org.genspectrum.dashboardsbackend.api.UserSyncRequest
import org.genspectrum.dashboardsbackend.config.DashboardsConfig
import org.genspectrum.dashboardsbackend.controller.NotFoundException
import org.genspectrum.dashboardsbackend.util.now
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
@Transactional
class UserModel {
class UserModel(private val dashboardsConfig: DashboardsConfig) {
// Not `lazy {}` — needs @Transactional to run inside a DB transaction.
@Volatile
private var systemUserId: Long? = null

fun getSystemUserId(): Long? {
systemUserId?.let { return it }
val githubId = dashboardsConfig.systemUser?.githubId ?: return null
val id = UserEntity.findByGithubId(githubId)?.id?.value ?: return null
systemUserId = id
return id
}

fun syncUser(request: UserSyncRequest): User {
val now = now()
val existing = UserEntity.findByGithubId(request.githubId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper:
userId: Long? = null,
organism: String? = null,
includeVariants: Boolean = false,
excludeSystemCollections: Boolean = false,
): ResultActions {
val params = buildString {
val queryParams = mutableListOf<String>()
if (userId != null) queryParams.add("userId=$userId")
if (organism != null) queryParams.add("organism=$organism")
if (includeVariants) queryParams.add("includeVariants=true")
if (excludeSystemCollections) queryParams.add("excludeSystemCollections=true")
if (queryParams.isNotEmpty()) {
append("?")
append(queryParams.joinToString("&"))
Expand All @@ -49,8 +51,9 @@ class CollectionsClient(private val mockMvc: MockMvc, private val objectMapper:
userId: Long? = null,
organism: String? = null,
includeVariants: Boolean = false,
excludeSystemCollections: Boolean = false,
): List<Collection> = deserializeJsonResponse(
getCollectionsRaw(userId, organism, includeVariants)
getCollectionsRaw(userId, organism, includeVariants, excludeSystemCollections)
.andExpect(status().isOk),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package org.genspectrum.dashboardsbackend.controller

import org.genspectrum.dashboardsbackend.KnownTestOrganisms
import org.genspectrum.dashboardsbackend.dummyCollectionRequest
import org.genspectrum.dashboardsbackend.model.user.UserModel
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.hasItem
import org.hamcrest.Matchers.not
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.TestPropertySource

@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(
properties = [
"dashboards.system-user.github-id=test-system-user-exclude-test",
"dashboards.system-user.name=Test Bot",
],
)
@Import(CollectionsClient::class, UsersClient::class)
class CollectionsExcludeSystemCollectionsTest(
@param:Autowired private val collectionsClient: CollectionsClient,
@param:Autowired private val usersClient: UsersClient,
@param:Autowired private val userModel: UserModel,
) {
@Test
fun `GIVEN system and regular user collections WHEN excludeSystemCollections=true THEN only regular returned`() {
val systemUserId = requireNotNull(userModel.getSystemUserId())
val regularUserId = usersClient.createUser()

val systemCollection = collectionsClient.postCollection(
dummyCollectionRequest.copy(name = "System Collection", organism = KnownTestOrganisms.Covid.name),
systemUserId,
)
val regularCollection = collectionsClient.postCollection(
dummyCollectionRequest.copy(name = "Regular Collection", organism = KnownTestOrganisms.Covid.name),
regularUserId,
)

val collections = collectionsClient.getCollections(includeVariants = true, excludeSystemCollections = true)

assertThat(collections, hasItem(regularCollection))
assertThat(collections, not(hasItem(systemCollection)))
}

@Test
fun `GIVEN system user collection WHEN not excluding system collections THEN system collection is included`() {
val systemUserId = requireNotNull(userModel.getSystemUserId())

val systemCollection = collectionsClient.postCollection(
dummyCollectionRequest.copy(name = "System Collection Visible", organism = KnownTestOrganisms.Covid.name),
systemUserId,
)

val collections = collectionsClient.getCollections(includeVariants = true, excludeSystemCollections = false)

assertThat(collections, hasItem(systemCollection))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,20 @@ class CollectionsGetTest(
.andExpect(jsonPath("$[0].variants").doesNotExist())
}

@Test
fun `GIVEN no system user configured WHEN getting with excludeSystemCollections THEN returns all collections`() {
val userId = usersClient.createUser()
val collection = collectionsClient.postCollection(dummyCollectionRequest.copy(name = "No System User"), userId)

val collections = collectionsClient.getCollections(
userId = userId,
includeVariants = true,
excludeSystemCollections = true,
)

assertThat(collections, hasItem(collection))
}

@Test
fun `WHEN getting collections with includeVariants=true THEN variantCount and variants are both present`() {
val userId = usersClient.createUser()
Expand Down
7 changes: 7 additions & 0 deletions collection-seeding/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
*
!pixi.toml
!pixi.lock
!seed.py
!api.py
!models.py
!sources/
13 changes: 13 additions & 0 deletions collection-seeding/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Stage 1: use pixi to resolve and install dependencies
FROM ghcr.io/prefix-dev/pixi:0.58.0 AS builder
WORKDIR /app
COPY pixi.toml pixi.lock .
RUN pixi install --frozen

# Stage 2: slim runtime image — copy only the installed site-packages
FROM python:3.13-slim AS final
WORKDIR /app
COPY --from=builder /app/.pixi/envs/default/lib/python3.13/site-packages \
/usr/local/lib/python3.13/site-packages
COPY . .
CMD ["python", "seed.py"]
59 changes: 59 additions & 0 deletions collection-seeding/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# collection-seeding

Seeds the backend with example collections:

- **covid-resistance-mutations** — resistance mutation data for 3CLpro, RdRp, and Spike mAb
- **covid-pango-lineages** — one collection per pango lineage, with nucleotide substitutions as variants
- **covid-pango-lineages-sample** — same as above but limited to 10 lineages, for quick testing

The script is idempotent — re-running it will create new collections or update existing ones (matched by name). If a collection's name changes in the source, the old entry is orphaned and a new one is created.

Use `--repeat-interval-hours N` (or `$REPEAT_INTERVAL_HOURS`) to run on a loop — re-seeds every N hours. Without it, the script runs once and exits.

## Via Docker Compose

The seeder runs automatically as part of Docker Compose:

```bash
BACKEND_TAG=latest WEBSITE_TAG=latest SEEDER_TAG=latest docker compose up
```

## Running locally

Requires [pixi](https://pixi.sh). Install dependencies once:

```bash
pixi install
```

Then use the provided tasks:

```bash
pixi run seed # all sources
pixi run seed-resistance # resistance mutations only
pixi run seed-lineages # pango lineages only
pixi run seed-lineages-sample # first 10 pango lineages (quick test)
```

To target a different backend:

```bash
pixi run seed --url http://localhost:4321
```

Run `pixi run seed --help` for all options, including `--source`, `--list`, `--repeat-interval-hours`, and `--url`.

## Adding a new source

1. Create `sources/your_source.py` and implement the `Source` ABC:
```python
from sources import Source
from models import Collection

class YourSource(Source):
name = "your-source-name" # used with --source flag

def get_collections(self) -> list[Collection]:
...
```
2. Register it in `sources/registry.py` by adding it to `ALL_SOURCES`.
Loading
Loading