Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6989d88
feat(genomics): Introduce Variant search API and YBrowse VCF ingestio…
JamesKane Dec 10, 2025
d0ba0df
feat(genomics): Add `GenomicsConfig` for reference genome configurati…
JamesKane Dec 10, 2025
e2fb576
feat(genomics): Add YBrowse variant update system with admin API and …
JamesKane Dec 10, 2025
ac7144b
feat(curation): Add TreeCurator role, permissions, audit logging, and…
JamesKane Dec 10, 2025
ef7c38e
feat(genomics): Add `VariantWithContig` model and integrate contig da…
JamesKane Dec 10, 2025
831360b
feat(curation): Add haplogroup-variant association management with UI…
JamesKane Dec 10, 2025
c615786
feat(genomics): Introduce `VariantGroup` model and integrate logical …
JamesKane Dec 10, 2025
41e4514
feat(curation): Implement haplogroup tree restructuring with merge an…
JamesKane Dec 10, 2025
084e41c
feat(curation): Enhance haplogroup creation form with tree placement …
JamesKane Dec 10, 2025
15236cc
feat(curation): Add support for Y-DNA/mtDNA contig selection and vari…
JamesKane Dec 10, 2025
dd9137c
feat(curation): Add support for editing Variant Groups with shared pr…
JamesKane Dec 10, 2025
05cf3ae
feat(curation): Mark regression tests as completed and document Phase…
JamesKane Dec 10, 2025
16da74b
feat(curation): Introduce `VariantAlias` support for managing alterna…
JamesKane Dec 10, 2025
74a6941
feat(service): Add alias support for variants in haplogroup tree logi…
JamesKane Dec 10, 2025
901927c
feat(tree): Add branch age estimates (Formed/TMRCA) to haplogroup tree
JamesKane Dec 10, 2025
21a9c6c
feat(curation): Add variant filtering and count display to haplogroup…
JamesKane Dec 10, 2025
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
20 changes: 19 additions & 1 deletion app/actions/AuthenticatedAction.scala
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class AuthenticatedAction @Inject()(
* Allows requiring specific roles on top of authentication.
*/
class RoleAction @Inject()(authService: AuthService)(implicit ec: ExecutionContext) {

def apply(requiredRoles: String*): ActionFilter[AuthenticatedRequest] = new ActionFilter[AuthenticatedRequest] {
override protected def executionContext: ExecutionContext = ec

Expand All @@ -59,3 +59,21 @@ class RoleAction @Inject()(authService: AuthService)(implicit ec: ExecutionConte
}
}
}

/**
* PermissionAction builder factory.
* Allows requiring specific permissions on top of authentication.
*/
class PermissionAction @Inject()(authService: AuthService)(implicit ec: ExecutionContext) {

def apply(permission: String): ActionFilter[AuthenticatedRequest] = new ActionFilter[AuthenticatedRequest] {
override protected def executionContext: ExecutionContext = ec

override protected def filter[A](request: AuthenticatedRequest[A]): Future[Option[Result]] = {
authService.hasPermission(request.user.id.get, permission).map { hasPermission =>
if (hasPermission) None
else Some(Results.Forbidden(s"Missing required permission: $permission"))
}
}
}
}
122 changes: 122 additions & 0 deletions app/actors/YBrowseVariantUpdateActor.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package actors

import config.GenomicsConfig
import org.apache.pekko.actor.Actor
import play.api.Logging
import services.genomics.YBrowseVariantIngestionService

import java.io.{BufferedInputStream, FileOutputStream}
import java.net.{HttpURLConnection, URI}
import java.nio.file.Files
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success, Try}

object YBrowseVariantUpdateActor {
case object RunUpdate
case class UpdateResult(success: Boolean, variantsIngested: Int, message: String)
}

/**
* Actor responsible for downloading and ingesting Y-DNA SNP data from YBrowse.
*
* This actor:
* 1. Downloads the VCF file from ybrowse.org
* 2. Stores it at the configured local path
* 3. Triggers variant ingestion via YBrowseVariantIngestionService
*/
class YBrowseVariantUpdateActor @javax.inject.Inject()(
genomicsConfig: GenomicsConfig,
ingestionService: YBrowseVariantIngestionService
)(implicit ec: ExecutionContext) extends Actor with Logging {

import YBrowseVariantUpdateActor.*

override def receive: Receive = {
case RunUpdate =>
logger.info("YBrowseVariantUpdateActor: Starting YBrowse variant update")
val senderRef = sender()

runUpdate().onComplete {
case Success(result) =>
logger.info(s"YBrowseVariantUpdateActor: Update completed - ${result.message}")
senderRef ! result
case Failure(ex) =>
logger.error(s"YBrowseVariantUpdateActor: Update failed - ${ex.getMessage}", ex)
senderRef ! UpdateResult(success = false, variantsIngested = 0, s"Update failed: ${ex.getMessage}")
}
}

private def runUpdate(): Future[UpdateResult] = {
Future {
downloadVcfFile()
}.flatMap {
case Success(_) =>
logger.info("VCF file downloaded successfully, starting ingestion")
ingestionService.ingestVcf(genomicsConfig.ybrowseVcfStoragePath).map { count =>
UpdateResult(success = true, variantsIngested = count, s"Successfully ingested $count variants")
}
case Failure(ex) =>
Future.successful(UpdateResult(success = false, variantsIngested = 0, s"Download failed: ${ex.getMessage}"))
}
}

private def downloadVcfFile(): Try[Unit] = Try {
val url = URI.create(genomicsConfig.ybrowseVcfUrl).toURL
val targetFile = genomicsConfig.ybrowseVcfStoragePath

// Ensure parent directory exists
val parentDir = targetFile.getParentFile
if (parentDir != null && !parentDir.exists()) {
Files.createDirectories(parentDir.toPath)
logger.info(s"Created directory: ${parentDir.getAbsolutePath}")
}

// Download to a temp file first, then rename (atomic operation)
val tempFile = new java.io.File(targetFile.getAbsolutePath + ".tmp")

logger.info(s"Downloading VCF from ${genomicsConfig.ybrowseVcfUrl} to ${tempFile.getAbsolutePath}")

val connection = url.openConnection().asInstanceOf[HttpURLConnection]
connection.setRequestMethod("GET")
connection.setConnectTimeout(30000) // 30 seconds
connection.setReadTimeout(300000) // 5 minutes for large file

try {
val responseCode = connection.getResponseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
throw new RuntimeException(s"HTTP request failed with status $responseCode")
}

val inputStream = new BufferedInputStream(connection.getInputStream)
val outputStream = new FileOutputStream(tempFile)

try {
val buffer = new Array[Byte](8192)
var bytesRead = 0
var totalBytes = 0L

while ({ bytesRead = inputStream.read(buffer); bytesRead != -1 }) {
outputStream.write(buffer, 0, bytesRead)
totalBytes += bytesRead
}

logger.info(s"Downloaded $totalBytes bytes")
} finally {
inputStream.close()
outputStream.close()
}

// Atomic rename
if (targetFile.exists()) {
targetFile.delete()
}
if (!tempFile.renameTo(targetFile)) {
throw new RuntimeException(s"Failed to rename temp file to ${targetFile.getAbsolutePath}")
}

logger.info(s"VCF file saved to ${targetFile.getAbsolutePath}")
} finally {
connection.disconnect()
}
}
}
20 changes: 20 additions & 0 deletions app/config/FeatureFlags.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package config

import jakarta.inject.{Inject, Singleton}
import play.api.Configuration

/**
* Configuration wrapper for feature flags.
* Allows features to be enabled/disabled via application.conf.
*/
@Singleton
class FeatureFlags @Inject()(config: Configuration) {

private val featuresConfig = config.getOptional[Configuration]("features").getOrElse(Configuration.empty)

/**
* Show branch age estimates (Formed/TMRCA dates) on tree nodes.
* Disabled by default until age data is populated.
*/
val showBranchAgeEstimates: Boolean = featuresConfig.getOptional[Boolean]("tree.showBranchAgeEstimates").getOrElse(false)
}
49 changes: 49 additions & 0 deletions app/config/GenomicsConfig.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package config

import jakarta.inject.{Inject, Singleton}
import play.api.Configuration

import java.io.File

/**
* Configuration wrapper for genomics-related settings.
*/
@Singleton
class GenomicsConfig @Inject()(config: Configuration) {

private val genomicsConfig = config.get[Configuration]("genomics")

val supportedReferences: Seq[String] = genomicsConfig.get[Seq[String]]("references.supported")

val referenceAliases: Map[String, String] = genomicsConfig.getOptional[Map[String, String]]("references.aliases").getOrElse(Map.empty)

val fastaPaths: Map[String, File] = genomicsConfig.get[Map[String, String]]("references.fasta_paths").map {
case (genome, path) => genome -> new File(path)
}

// YBrowse configuration
val ybrowseVcfUrl: String = genomicsConfig.get[String]("ybrowse.vcf_url")
val ybrowseVcfStoragePath: File = new File(genomicsConfig.get[String]("ybrowse.vcf_storage_path"))

/**
* Retrieves the path to a liftover chain file for a given source and target genome.
*
* @param source The source reference genome (e.g., "GRCh38").
* @param target The target reference genome (e.g., "GRCh37").
* @return An Option containing the File if the chain is configured and exists, otherwise None.
*/
def getLiftoverChainFile(source: String, target: String): Option[File] = {
val key = s"$source->$target"
genomicsConfig.getOptional[String](s"liftover.chains.\"$key\"").map(new File(_))
}

/**
* Resolves a genome name to its canonical form using the aliases configuration.
*
* @param name The input genome name.
* @return The canonical name if an alias exists, otherwise the input name.
*/
def resolveReferenceName(name: String): String = {
referenceAliases.getOrElse(name, name)
}
}
Loading
Loading