app/controllers/ChannelTestsController.scala (218 lines of code) (raw):

package controllers import actions.AuthAndPermissionActions import controllers.ChannelTestsController.ChannelTestsResponse import io.circe.{Decoder, Encoder} import io.circe.syntax._ import io.circe.generic.auto._ import models.{Channel, ChannelTest, LockStatus} import models.DynamoErrors._ import play.api.libs.circe.Circe import play.api.mvc._ import services.S3Client.{S3ClientError, S3ObjectSettings} import services.{DynamoArchivedChannelTests, DynamoChannelTests, DynamoChannelTestsAudit, S3Json, VersionedS3Data} import utils.Circe.noNulls import zio.{IO, UIO, ZEnv, ZIO} import com.typesafe.scalalogging.LazyLogging import scala.concurrent.{ExecutionContext, Future} object ChannelTestsController { // The model returned by this controller for GET requests case class ChannelTestsResponse[T]( tests: List[T], status: LockStatus, userEmail: String ) } /** * Controller for managing channel tests config in Dynamodb. * Uses an S3 file for lock protection to prevent concurrent editing. */ abstract class ChannelTestsController[T <: ChannelTest[T] : Decoder : Encoder]( authActions: AuthAndPermissionActions, components: ControllerComponents, stage: String, lockFileName: String, channel: Channel, runtime: zio.Runtime[ZEnv], dynamoTests: DynamoChannelTests, dynamoArchivedTests: DynamoArchivedChannelTests, dynamoTestsAudit: DynamoChannelTestsAudit )(implicit ec: ExecutionContext) extends AbstractController(components) with Circe with LazyLogging { private val lockObjectSettings = S3ObjectSettings( bucket = "support-admin-console", key = s"$stage/locks/$lockFileName.lock", publicRead = false, cacheControl = None ) val s3Client = services.S3 private def run(f: => ZIO[ZEnv, Throwable, Result]): Future[Result] = runtime.unsafeRunToFuture { f.catchAll(error => { logger.error(s"Returning InternalServerError to client: ${error.getMessage}", error) IO.succeed(InternalServerError(error.getMessage)) }) } private def runWithLockStatus(f: VersionedS3Data[LockStatus] => ZIO[ZEnv, Throwable, Result]): Future[Result] = run { S3Json .getFromJson[LockStatus](s3Client) .apply(lockObjectSettings) .flatMap(f) } private def setLockStatus(lockStatus: VersionedS3Data[LockStatus]): ZIO[ZEnv, S3ClientError, Unit] = S3Json .updateAsJson(lockStatus)(s3Client) .apply(lockObjectSettings) def get = authActions.read.async { request => runWithLockStatus { case VersionedS3Data(lockStatus, _) => dynamoTests .getAllTests[T](channel) .map { channelTests => val response = ChannelTestsResponse( channelTests, lockStatus, request.user.email ) Ok(noNulls(response.asJson)) } } } /** * Handlers for test list ordering */ def lockList = authActions.write.async { request => runWithLockStatus { case VersionedS3Data(lockStatus, lockFileVersion) => logger.info(s"User ${request.user.email} is locking $channel test list") if (!lockStatus.locked) { val newLockStatus = LockStatus.locked(request.user.email) setLockStatus(VersionedS3Data(newLockStatus, lockFileVersion)).map(_ => Ok("locked")) } else { logger.info(s"User ${request.user.email} failed to take control of $channel test list because it was already locked") IO.succeed(Conflict(s"File $channel is already locked")) } } } def unlockList = authActions.write.async { request => runWithLockStatus { case VersionedS3Data(lockStatus, lockFileVersion) => logger.info(s"User ${request.user.email} is unlocking $channel test list") if (lockStatus.email.contains(request.user.email)) { setLockStatus(VersionedS3Data(LockStatus.unlocked, lockFileVersion)).map(_ => Ok("unlocked")) } else { logger.info(s"User ${request.user.email} tried to unlock $channel test list, but they did not have a lock") IO.succeed(BadRequest(s"$channel test list is not currently locked by this user")) } } } def takeControlOfList = authActions.write.async { request => runWithLockStatus { case VersionedS3Data(lockStatus, lockFileVersion) => logger.info(s"User ${request.user.email} is force-unlocking $channel test list, taking it from ${lockStatus.email}") setLockStatus(VersionedS3Data(LockStatus.locked(request.user.email), lockFileVersion)).map(_ => Ok("unlocked")) } } def reorderList = authActions.write.async(circe.json[List[String]]) { request => runWithLockStatus { case VersionedS3Data(lockStatus, lockFileVersion) => if (lockStatus.email.contains(request.user.email)) { logger.info(s"${request.user.email} is reordering $channel list") val testNames: List[String] = request.body val result = for { _ <- dynamoTests.setPriorities(testNames, channel) _ <- setLockStatus(VersionedS3Data(LockStatus.unlocked, lockFileVersion)) } yield Ok("updated") result.tapError(error => UIO(logger.error(s"Failed to update $channel test list (user ${request.user.email}: $error"))) } else { IO.succeed(Conflict(s"You do not currently have $channel test list open for edit")) } } } /** * Handlers for test editing */ def getTest(testName: String) = authActions.read.async { request => run { dynamoTests .getTest(testName, channel) .map(test => Ok(noNulls(test.asJson))) } } def updateTest = authActions.write.async(circe.json[T]) { request => run { val test = request.body logger.info(s"${request.user.email} is updating $channel/'${test.name}'") dynamoTests .updateTest(test, channel, request.user.email) .flatMap(_ => dynamoTestsAudit.createAudit(test, request.user.email)) .map(_ => Ok("updated")) .catchSome { case DynamoNoLockError(error) => logger.warn(s"Failed to save $channel/'${test.name}' because user ${request.user.email} does not have it locked: ${error.getMessage}") IO.succeed(Conflict(s"You do not currently have $channel test '${test.name}' open for edit")) } } } def createTest = authActions.write.async(circe.json[T]) { request => run { val test = request.body logger.info(s"${request.user.email} is creating $channel/'${test.name}'") dynamoTests .createTest(test, channel) .flatMap(newTest => dynamoTestsAudit.createAudit(newTest, request.user.email)) .map(_ => Ok("created")) .catchSome { case DynamoDuplicateNameError(error) => logger.warn(s"Failed to create $channel/'${test.name}' because name already exists: ${error.getMessage}") IO.succeed(BadRequest(s"Cannot create $channel test '${test.name}' because it already exists. Please use a different name")) } } } def lockTest(testName: String) = authActions.write.async { request => run { logger.info(s"${request.user.email} is locking $channel/'$testName'") dynamoTests.lockTest(testName, channel, request.user.email, force = false) .map(_ => Ok("locked")) .catchSome { case DynamoNoLockError(error) => logger.warn(s"Failed to lock $channel/'$testName' because it is already locked: ${error.getMessage}") IO.succeed(Conflict(s"$channel test '$testName' is already locked for edit by another user, or it doesn't exist")) } } } def unlockTest(testName: String) = authActions.write.async { request => run { logger.info(s"${request.user.email} is unlocking $channel/'$testName'") dynamoTests.unlockTest(testName, channel, request.user.email) .map(_ => Ok("unlocked")) .catchSome { case DynamoNoLockError(error) => logger.warn(s"Failed to unlock $channel/'$testName' because user ${request.user.email} does not have it locked: ${error.getMessage}") IO.succeed(Conflict(s"You do not currently have $channel test '$testName' open for edit")) } } } def forceLockTest(testName: String) = authActions.write.async { request => run { logger.info(s"${request.user.email} is force locking $channel/'$testName'") dynamoTests.lockTest(testName, channel, request.user.email, force = true) .map(_ => Ok("locked")) } } private def parseStatus(rawStatus: String): Option[models.Status] = rawStatus.toLowerCase match { case "live" => Some(models.Status.Live) case "draft" => Some(models.Status.Draft) case "archived" => Some(models.Status.Archived) case _ => None } def setStatus(rawStatus: String) = authActions.write.async(circe.json[List[String]]) { request => run { val testNames = request.body logger.info(s"${request.user.email} is changing status to $rawStatus on: $testNames") parseStatus(rawStatus) match { case Some(models.Status.Archived) => // Special handling for archiving of tests, which are moved to another table dynamoTests .getRawTests(channel, testNames) // write them to the archive table .flatMap(dynamoArchivedTests.putAllRaw) // now delete them from the main table .flatMap(_ => dynamoTests.deleteTests(testNames, channel)) .map { _ => logger.info(s"Archived and deleted ${testNames.length} $channel tests") Ok(rawStatus) } case Some(status) => dynamoTests.updateStatuses(testNames, channel, status) .flatMap(_ => { /** * Fetch the full data for all the updated tests and then write audits. * We use .forkDaemon here to avoid delaying the response to the client * and instead run this task in the background. */ dynamoTests .getTests(channel, testNames) .flatMap(tests => dynamoTestsAudit.createAudits(tests, request.user.email)) .tapError(error => { // Log the error but this will not affect the response to the client ZIO.succeed(logger.error(s"Error creating audits after status changes: ${error.getMessage}", error)) }) .forkDaemon }) .map(_ => Ok(status.toString)) case None => ZIO.succeed(BadRequest(s"Invalid status: $rawStatus")) } } } }