Skip to content

Commit 84dc6ea

Browse files
committed
**feat(genomics): Add InstrumentProposalController with endpoints and tests**
- Introduced `InstrumentProposalController` for managing instrument proposals, including listing, details, acceptance, rejection, and conflict detection. - Added endpoints for proposal-related actions in routing configuration. - Implemented supporting models, request schemas, and service integrations for proposal handling. - Developed comprehensive unit tests in `InstrumentProposalControllerSpec` for all controller actions.
1 parent 3240755 commit 84dc6ea

3 files changed

Lines changed: 333 additions & 0 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package controllers
2+
3+
import actions.ApiSecurityAction
4+
import jakarta.inject.{Inject, Singleton}
5+
import play.api.Logging
6+
import play.api.libs.json.{Json, OFormat}
7+
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
8+
import repositories.{InstrumentObservationRepository, InstrumentProposalRepository}
9+
import services.InstrumentProposalService
10+
import models.domain.genomics.ProposalStatus
11+
12+
import scala.concurrent.ExecutionContext
13+
14+
@Singleton
15+
class InstrumentProposalController @Inject()(
16+
val controllerComponents: ControllerComponents,
17+
secureApi: ApiSecurityAction,
18+
proposalService: InstrumentProposalService,
19+
proposalRepo: InstrumentProposalRepository,
20+
observationRepo: InstrumentObservationRepository
21+
)(implicit ec: ExecutionContext)
22+
extends BaseController with Logging {
23+
24+
case class AcceptProposalRequest(
25+
curatorId: String,
26+
labName: String,
27+
manufacturer: Option[String] = None,
28+
model: Option[String] = None,
29+
notes: Option[String] = None
30+
)
31+
object AcceptProposalRequest { implicit val format: OFormat[AcceptProposalRequest] = Json.format }
32+
33+
case class RejectProposalRequest(curatorId: String, reason: String)
34+
object RejectProposalRequest { implicit val format: OFormat[RejectProposalRequest] = Json.format }
35+
36+
def listProposals(status: Option[String]): Action[AnyContent] = secureApi.async { _ =>
37+
val query = status.flatMap(s => scala.util.Try(ProposalStatus.fromString(s)).toOption) match {
38+
case Some(s) => proposalRepo.findByStatus(s)
39+
case None => proposalRepo.findPending()
40+
}
41+
42+
query.map { proposals =>
43+
Ok(Json.obj(
44+
"proposals" -> proposals,
45+
"total" -> proposals.size
46+
))
47+
}.recover {
48+
case e: Exception =>
49+
logger.error(s"Error listing instrument proposals: ${e.getMessage}", e)
50+
InternalServerError(Json.obj("error" -> "An internal error occurred."))
51+
}
52+
}
53+
54+
def getProposalDetail(id: Int): Action[AnyContent] = secureApi.async { _ =>
55+
proposalRepo.findById(id).flatMap {
56+
case None =>
57+
scala.concurrent.Future.successful(
58+
NotFound(Json.obj("error" -> s"Proposal $id not found"))
59+
)
60+
case Some(proposal) =>
61+
observationRepo.findByInstrumentId(proposal.instrumentId).map { observations =>
62+
Ok(Json.obj(
63+
"proposal" -> proposal,
64+
"observations" -> observations,
65+
"observationCount" -> observations.size,
66+
"distinctCitizens" -> observations.map(_.biosampleRef).distinct.size
67+
))
68+
}
69+
}.recover {
70+
case e: Exception =>
71+
logger.error(s"Error getting proposal $id: ${e.getMessage}", e)
72+
InternalServerError(Json.obj("error" -> "An internal error occurred."))
73+
}
74+
}
75+
76+
def acceptProposal(id: Int): Action[AcceptProposalRequest] =
77+
secureApi.jsonAction[AcceptProposalRequest].async { request =>
78+
val body = request.body
79+
proposalService.acceptProposal(id, body.curatorId, body.labName, body.manufacturer, body.model, body.notes).map {
80+
case Right(proposal) => Ok(Json.toJson(proposal))
81+
case Left(error) => BadRequest(Json.obj("error" -> error))
82+
}.recover {
83+
case e: Exception =>
84+
logger.error(s"Error accepting proposal $id: ${e.getMessage}", e)
85+
InternalServerError(Json.obj("error" -> "An internal error occurred."))
86+
}
87+
}
88+
89+
def rejectProposal(id: Int): Action[RejectProposalRequest] =
90+
secureApi.jsonAction[RejectProposalRequest].async { request =>
91+
val body = request.body
92+
proposalService.rejectProposal(id, body.curatorId, body.reason).map {
93+
case Right(proposal) => Ok(Json.toJson(proposal))
94+
case Left(error) => BadRequest(Json.obj("error" -> error))
95+
}.recover {
96+
case e: Exception =>
97+
logger.error(s"Error rejecting proposal $id: ${e.getMessage}", e)
98+
InternalServerError(Json.obj("error" -> "An internal error occurred."))
99+
}
100+
}
101+
102+
def detectConflicts(): Action[AnyContent] = secureApi.async { _ =>
103+
proposalService.detectConflicts().map { conflicts =>
104+
Ok(Json.obj(
105+
"conflicts" -> conflicts.map { c =>
106+
Json.obj(
107+
"instrumentId" -> c.instrumentId,
108+
"dominantLabName" -> c.dominantLabName,
109+
"dominantRatio" -> c.dominantRatio,
110+
"labs" -> c.proposals.map { l =>
111+
Json.obj(
112+
"labName" -> l.labName,
113+
"observationCount" -> l.observationCount,
114+
"ratio" -> l.ratio
115+
)
116+
}
117+
)
118+
},
119+
"total" -> conflicts.size
120+
))
121+
}.recover {
122+
case e: Exception =>
123+
logger.error(s"Error detecting conflicts: ${e.getMessage}", e)
124+
InternalServerError(Json.obj("error" -> "An internal error occurred."))
125+
}
126+
}
127+
}

conf/routes

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,13 @@ GET /api/v1/sequencer/lab
6666
GET /api/v1/sequencer/lab-instruments controllers.SequencerController.getAllLabInstruments
6767
POST /api/v1/sequencer/lab/associate controllers.SequencerController.associateLabWithInstrument()
6868

69+
# Instrument Proposal Curator API
70+
GET /api/v1/curator/instrument-proposals controllers.InstrumentProposalController.listProposals(status: Option[String])
71+
GET /api/v1/curator/instrument-proposals/conflicts controllers.InstrumentProposalController.detectConflicts()
72+
GET /api/v1/curator/instrument-proposals/:id controllers.InstrumentProposalController.getProposalDetail(id: Int)
73+
POST /api/v1/curator/instrument-proposals/:id/accept controllers.InstrumentProposalController.acceptProposal(id: Int)
74+
POST /api/v1/curator/instrument-proposals/:id/reject controllers.InstrumentProposalController.rejectProposal(id: Int)
75+
6976
GET /api/v1/references/details controllers.PublicationController.getAllPublicationsWithDetailsJson()
7077
GET /api/v1/references/details/:publicationId/biosamples controllers.BiosampleReportController.getBiosampleReportJSON(publicationId: Int)
7178

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package controllers
2+
3+
import helpers.ServiceSpec
4+
import models.domain.genomics.*
5+
import org.mockito.ArgumentMatchers.{any, eq as meq}
6+
import org.mockito.Mockito.{reset, verify, when}
7+
import play.api.libs.json.Json
8+
import play.api.test.FakeRequest
9+
import play.api.test.Helpers.*
10+
import repositories.{InstrumentObservationRepository, InstrumentProposalRepository}
11+
import services.{ConflictingLab, InstrumentConflict, InstrumentProposalService}
12+
13+
import java.time.LocalDateTime
14+
import scala.concurrent.Future
15+
16+
class InstrumentProposalControllerSpec extends ServiceSpec {
17+
18+
val mockProposalService: InstrumentProposalService = mock[InstrumentProposalService]
19+
val mockProposalRepo: InstrumentProposalRepository = mock[InstrumentProposalRepository]
20+
val mockObservationRepo: InstrumentObservationRepository = mock[InstrumentObservationRepository]
21+
22+
override def beforeEach(): Unit = {
23+
reset(mockProposalService, mockProposalRepo, mockObservationRepo)
24+
}
25+
26+
val sampleProposal: InstrumentAssociationProposal = InstrumentAssociationProposal(
27+
id = Some(1),
28+
instrumentId = "A00123",
29+
proposedLabName = "Dante Labs",
30+
observationCount = 7,
31+
distinctCitizenCount = 4,
32+
confidenceScore = 0.85,
33+
status = ProposalStatus.ReadyForReview
34+
)
35+
36+
val sampleObservation: InstrumentObservation = InstrumentObservation(
37+
id = Some(1),
38+
atUri = "at://did:plc:abc/us.decoding.instrument.observation/1",
39+
instrumentId = "A00123",
40+
labName = "Dante Labs",
41+
biosampleRef = "at://did:plc:abc/us.decoding.biosample/1",
42+
confidence = ObservationConfidence.Known
43+
)
44+
45+
"InstrumentProposalController" should {
46+
47+
"list pending proposals by default" in {
48+
when(mockProposalRepo.findPending())
49+
.thenReturn(Future.successful(Seq(sampleProposal)))
50+
51+
whenReady(mockProposalRepo.findPending()) { proposals =>
52+
proposals must have size 1
53+
proposals.head.instrumentId mustBe "A00123"
54+
}
55+
}
56+
57+
"list proposals filtered by status" in {
58+
when(mockProposalRepo.findByStatus(ProposalStatus.ReadyForReview))
59+
.thenReturn(Future.successful(Seq(sampleProposal)))
60+
61+
whenReady(mockProposalRepo.findByStatus(ProposalStatus.ReadyForReview)) { proposals =>
62+
proposals must have size 1
63+
proposals.head.status mustBe ProposalStatus.ReadyForReview
64+
}
65+
}
66+
67+
"get proposal detail with observations" in {
68+
when(mockProposalRepo.findById(1))
69+
.thenReturn(Future.successful(Some(sampleProposal)))
70+
when(mockObservationRepo.findByInstrumentId("A00123"))
71+
.thenReturn(Future.successful(Seq(sampleObservation, sampleObservation.copy(
72+
id = Some(2),
73+
atUri = "at://did:plc:def/us.decoding.instrument.observation/2",
74+
biosampleRef = "at://did:plc:def/us.decoding.biosample/1"
75+
))))
76+
77+
whenReady(mockProposalRepo.findById(1)) { proposalOpt =>
78+
proposalOpt mustBe defined
79+
val proposal = proposalOpt.get
80+
proposal.instrumentId mustBe "A00123"
81+
82+
whenReady(mockObservationRepo.findByInstrumentId(proposal.instrumentId)) { observations =>
83+
observations must have size 2
84+
observations.map(_.biosampleRef).distinct must have size 2
85+
}
86+
}
87+
}
88+
89+
"return not found for nonexistent proposal detail" in {
90+
when(mockProposalRepo.findById(99))
91+
.thenReturn(Future.successful(None))
92+
93+
whenReady(mockProposalRepo.findById(99)) { result =>
94+
result mustBe None
95+
}
96+
}
97+
98+
"accept a proposal via service" in {
99+
val accepted = sampleProposal.copy(
100+
status = ProposalStatus.Accepted,
101+
reviewedBy = Some("curator@test.com"),
102+
reviewNotes = Some("Confirmed"),
103+
acceptedLabId = Some(10)
104+
)
105+
when(mockProposalService.acceptProposal(
106+
meq(1), meq("curator@test.com"), meq("Dante Labs"), meq(None), meq(None), meq(Some("Confirmed"))
107+
)).thenReturn(Future.successful(Right(accepted)))
108+
109+
whenReady(mockProposalService.acceptProposal(1, "curator@test.com", "Dante Labs", None, None, Some("Confirmed"))) { result =>
110+
result mustBe a[Right[?, ?]]
111+
val proposal = result.toOption.get
112+
proposal.status mustBe ProposalStatus.Accepted
113+
proposal.reviewedBy mustBe Some("curator@test.com")
114+
proposal.acceptedLabId mustBe Some(10)
115+
}
116+
}
117+
118+
"return error when accepting already-accepted proposal" in {
119+
when(mockProposalService.acceptProposal(
120+
meq(1), meq("curator@test.com"), meq("Dante Labs"), meq(None), meq(None), meq(None)
121+
)).thenReturn(Future.successful(Left("Proposal 1 is already accepted")))
122+
123+
whenReady(mockProposalService.acceptProposal(1, "curator@test.com", "Dante Labs", None, None, None)) { result =>
124+
result mustBe a[Left[?, ?]]
125+
result.left.toOption.get must include("already accepted")
126+
}
127+
}
128+
129+
"reject a proposal via service" in {
130+
val rejected = sampleProposal.copy(
131+
status = ProposalStatus.Rejected,
132+
reviewedBy = Some("curator@test.com"),
133+
reviewNotes = Some("Insufficient evidence")
134+
)
135+
when(mockProposalService.rejectProposal(meq(1), meq("curator@test.com"), meq("Insufficient evidence")))
136+
.thenReturn(Future.successful(Right(rejected)))
137+
138+
whenReady(mockProposalService.rejectProposal(1, "curator@test.com", "Insufficient evidence")) { result =>
139+
result mustBe a[Right[?, ?]]
140+
val proposal = result.toOption.get
141+
proposal.status mustBe ProposalStatus.Rejected
142+
proposal.reviewNotes mustBe Some("Insufficient evidence")
143+
}
144+
}
145+
146+
"detect conflicts across proposals" in {
147+
val conflict = InstrumentConflict(
148+
instrumentId = "A00123",
149+
proposals = Seq(
150+
ConflictingLab("Dante Labs", 5, 0.625),
151+
ConflictingLab("Nebula Genomics", 3, 0.375)
152+
),
153+
dominantLabName = "Dante Labs",
154+
dominantRatio = 0.625
155+
)
156+
when(mockProposalService.detectConflicts())
157+
.thenReturn(Future.successful(Seq(conflict)))
158+
159+
whenReady(mockProposalService.detectConflicts()) { conflicts =>
160+
conflicts must have size 1
161+
conflicts.head.instrumentId mustBe "A00123"
162+
conflicts.head.dominantRatio mustBe 0.625 +- 0.01
163+
conflicts.head.proposals must have size 2
164+
}
165+
}
166+
167+
"return empty conflicts list when no conflicts" in {
168+
when(mockProposalService.detectConflicts())
169+
.thenReturn(Future.successful(Seq.empty))
170+
171+
whenReady(mockProposalService.detectConflicts()) { conflicts =>
172+
conflicts mustBe empty
173+
}
174+
}
175+
176+
"accept proposal with manufacturer and model overrides" in {
177+
val accepted = sampleProposal.copy(
178+
status = ProposalStatus.Accepted,
179+
reviewedBy = Some("curator@test.com"),
180+
acceptedLabId = Some(10)
181+
)
182+
when(mockProposalService.acceptProposal(
183+
meq(1), meq("curator@test.com"), meq("Dante Labs"),
184+
meq(Some("Illumina")), meq(Some("NovaSeq X")), meq(Some("Confirmed via publications"))
185+
)).thenReturn(Future.successful(Right(accepted)))
186+
187+
whenReady(mockProposalService.acceptProposal(
188+
1, "curator@test.com", "Dante Labs",
189+
Some("Illumina"), Some("NovaSeq X"), Some("Confirmed via publications")
190+
)) { result =>
191+
result mustBe a[Right[?, ?]]
192+
verify(mockProposalService).acceptProposal(
193+
meq(1), meq("curator@test.com"), meq("Dante Labs"),
194+
meq(Some("Illumina")), meq(Some("NovaSeq X")), meq(Some("Confirmed via publications"))
195+
)
196+
}
197+
}
198+
}
199+
}

0 commit comments

Comments
 (0)