article/app/controllers/LiveBlogController.scala (362 lines of code) (raw):

package controllers import com.gu.contentapi.client.model.v1.{Block, Blocks, ItemResponse, Content => ApiContent} import common.`package`.{convertApiExceptions => _, renderFormat => _} import common._ import contentapi.ContentApiClient import implicits.{AmpFormat, AppsFormat, HtmlFormat} import model.Cached.WithoutRevalidationResult import model.LiveBlogHelpers._ import model.ParseBlockId.{InvalidFormat, ParsedBlockId} import model.dotcomrendering.{DotcomRenderingDataModel, PageType} import model.liveblog.BodyBlock import model.liveblog.BodyBlock.{KeyEvent, SummaryEvent} import model._ import pages.{ArticleEmailHtmlPage, LiveBlogHtmlPage, MinuteHtmlPage} import play.api.libs.ws.WSClient import play.api.mvc._ import play.twirl.api.Html import renderers.DotcomRenderingService import services.{CAPILookup, NewsletterService} import utils.DotcomponentsLogger import views.support.RenderOtherStatus import scala.concurrent.Future case class MinutePage(article: Article, related: RelatedContent) extends PageWithStoryPackage class LiveBlogController( contentApiClient: ContentApiClient, val controllerComponents: ControllerComponents, ws: WSClient, remoteRenderer: renderers.DotcomRenderingService = DotcomRenderingService(), newsletterService: NewsletterService, )(implicit context: ApplicationContext) extends BaseController with GuLogging with ImplicitControllerExecutionContext { val capiLookup: CAPILookup = new CAPILookup(contentApiClient) // we support liveblogs and also articles, so that minutes work private def isSupported(c: ApiContent) = c.isLiveBlog || c.isArticle // Main entry points def renderEmail(path: String): Action[AnyContent] = { Action.async { implicit request => mapModel(path, ArticleBlocks) { case (minute: MinutePage, _) => Future.successful(common.renderEmail(ArticleEmailHtmlPage.html(minute), minute)) case (blog: LiveBlogPage, _) => Future.successful(common.renderEmail(LiveBlogHtmlPage.html(blog), blog)) case _ => Future.successful(NotFound) } } } def renderArticle( path: String, page: Option[String] = None, filterKeyEvents: Option[Boolean], ): Action[AnyContent] = { Action.async { implicit request => val filter = shouldFilter(filterKeyEvents) page.map(ParseBlockId.fromPageParam) match { case Some(ParsedBlockId(id)) => renderWithRange( path, PageWithBlock(id), filter, ) // we know the id of a block case Some(InvalidFormat) => Future.successful( Cached(10)(WithoutRevalidationResult(NotFound)), ) // page param there but couldn't extract a block id case None => { renderWithRange( path, CanonicalLiveBlog, filter, ) // no page param } } } } def renderJson( path: String, page: Option[String], lastUpdate: Option[String], rendered: Option[Boolean], isLivePage: Option[Boolean], filterKeyEvents: Option[Boolean], ): Action[AnyContent] = { Action.async { implicit request: Request[AnyContent] => val filter = shouldFilter(filterKeyEvents) val range = getRange(lastUpdate, page) mapModel(path, range, filter) { case (blog: LiveBlogPage, _) if rendered.contains(false) => getJsonForFronts(blog) /** When DCR requests new blocks from the client, it will add a `lastUpdate` parameter. If no such parameter is * present, we should return a JSON representation of the whole payload that would be sent to DCR when * initially server side rendering the LiveBlog page. */ case (blog: LiveBlogPage, blocks) if request.forceDCR && lastUpdate.isEmpty => Future.successful(renderDCRJson(blog, blocks, filter)) case (blog: LiveBlogPage, blocks) => getJson( blog, range, isLivePage, filter, blocks.requestedBodyBlocks.getOrElse(Map.empty).map(entry => (entry._1, entry._2.toSeq)), ) case (minute: MinutePage, _) => Future.successful(common.renderJson(views.html.fragments.minuteBody(minute), minute)) case _ => Future { Cached(600)(WithoutRevalidationResult(NotFound)) } } } } private[this] def renderWithRange( path: String, range: BlockRange, filterKeyEvents: Boolean, )(implicit request: RequestHeader, ): Future[Result] = { mapModel(path, range, filterKeyEvents) { (page, blocks) => { val isAmpSupported = page.article.content.shouldAmplify val pageType: PageType = PageType(page, request, context) (page, request.getRequestFormat) match { case (minute: MinutePage, HtmlFormat) => Future.successful(common.renderHtml(MinuteHtmlPage.html(minute), minute)) case (blog: LiveBlogPage, HtmlFormat) => val dcrCouldRender = true val theme = blog.article.content.metadata.format.getOrElse(ContentFormat.defaultContentFormat).theme val design = blog.article.content.metadata.format.getOrElse(ContentFormat.defaultContentFormat).design val display = blog.article.content.metadata.format.getOrElse(ContentFormat.defaultContentFormat).display val isDeadBlog = !blog.article.fields.isLive val properties = Map( "participatingInTest" -> "false", "dcrCouldRender" -> dcrCouldRender.toString, "theme" -> theme.toString, "design" -> design.toString, "display" -> display.toString, "isDead" -> isDeadBlog.toString, "isLiveBlog" -> "true", ) val remoteRendering = !request.forceDCROff if (remoteRendering) { DotcomponentsLogger.logger .logRequest(s"liveblog executing in dotcomponents", properties, page.article) val pageType: PageType = PageType(blog, request, context) remoteRenderer.getArticle( ws, blog, blocks, pageType, newsletter = None, filterKeyEvents, request.forceLive, ) } else { DotcomponentsLogger.logger.logRequest(s"liveblog executing in web", properties, page.article) Future.successful(common.renderHtml(LiveBlogHtmlPage.html(blog), blog)) } case (blog: LiveBlogPage, AmpFormat) if isAmpSupported => remoteRenderer.getAMPArticle(ws, blog, blocks, pageType, newsletter = None, filterKeyEvents) case (blog: LiveBlogPage, AmpFormat) => Future.successful(common.renderHtml(LiveBlogHtmlPage.html(blog), blog)) case (blog: LiveBlogPage, AppsFormat) => remoteRenderer.getAppsArticle( ws, blog, blocks, pageType, newsletter = None, filterKeyEvents, request.forceLive, ) case _ => Future.successful(NotFound) } } } } private[this] def getRange( lastUpdate: Option[String], page: Option[String], ): BlockRange = { (lastUpdate.map(ParseBlockId.fromBlockId), page.map(ParseBlockId.fromPageParam)) match { case (Some(ParsedBlockId(id)), _) => SinceBlockId(id) case (_, Some(ParsedBlockId(id))) => PageWithBlock(id) case _ => CanonicalLiveBlog } } private[this] def getJsonForFronts(liveblog: LiveBlogPage)(implicit request: RequestHeader): Future[Result] = { Future { Cached(liveblog)(JsonComponent("blocks" -> model.LiveBlogHelpers.blockTextJson(liveblog, 6))) } } private[this] def getJson( liveblog: LiveBlogPage, range: BlockRange, isLivePage: Option[Boolean], filterKeyEvents: Boolean, requestedBodyBlocks: scala.collection.Map[String, Seq[Block]] = Map.empty, )(implicit request: RequestHeader): Future[Result] = { val remoteRender = !request.forceDCROff range match { case SinceBlockId(lastBlockId) => renderNewerUpdatesJson( liveblog, SinceBlockId(lastBlockId), isLivePage, filterKeyEvents, remoteRender, requestedBodyBlocks, ) case _ => Future.successful(common.renderJson(views.html.liveblog.liveBlogBody(liveblog), liveblog)) } } private[this] def getNewBlocks( page: PageWithStoryPackage, lastUpdateBlockId: SinceBlockId, filterKeyEvents: Boolean, ): (Option[BodyBlock], Seq[BodyBlock]) = { val requestedBlocks = page.article.fields.blocks.toSeq.flatMap { _.requestedBodyBlocks.getOrElse(lastUpdateBlockId.around, Seq()) } val latestBlocks = requestedBlocks.takeWhile { block => block.id != lastUpdateBlockId.lastUpdate } val filteredBlocks = if (filterKeyEvents) { latestBlocks.filter(block => block.eventType == KeyEvent || block.eventType == SummaryEvent) } else latestBlocks // the last block is picked from the unfiltered list (latestBlocks.headOption, filteredBlocks) } private[this] def getNewBlocks( requestedBodyBlocks: scala.collection.Map[String, Seq[Block]], lastUpdateBlockId: SinceBlockId, filterKeyEvents: Boolean, ): Seq[Block] = { val blocksAround = requestedBodyBlocks.getOrElse(lastUpdateBlockId.around, Seq.empty).takeWhile { block => block.id != lastUpdateBlockId.lastUpdate } if (filterKeyEvents) { blocksAround.filter(block => block.attributes.keyEvent.getOrElse(false) || block.attributes.summary.getOrElse(false), ) } else blocksAround } private def getDCRBlocksHTML(page: LiveBlogPage, blocks: Seq[Block])(implicit request: RequestHeader, ): Future[Html] = { remoteRenderer.getBlocks(ws, page, blocks) map { result => new Html(result) } } private def getAppsBlocksHTML(page: LiveBlogPage, blocks: Seq[Block])(implicit request: RequestHeader, ): Future[Html] = { remoteRenderer.getAppsBlocks(ws, page, blocks) map { result => new Html(result) } } private[this] def renderNewerUpdatesJson( page: LiveBlogPage, lastUpdateBlockId: SinceBlockId, isLivePage: Option[Boolean], filterKeyEvents: Boolean, remoteRender: Boolean, requestedBodyBlocks: scala.collection.Map[String, Seq[Block]], )(implicit request: RequestHeader): Future[Result] = { val (newestBlock, newBlocks) = getNewBlocks(page, lastUpdateBlockId, filterKeyEvents) val newCapiBlocks = getNewBlocks(requestedBodyBlocks, lastUpdateBlockId, filterKeyEvents) val timelineHtml = views.html.liveblog.keyEvents( "", model.KeyEventData(newBlocks, Edition(request).timezone), filterKeyEvents, ) for { blocksHtml <- if (remoteRender && request.getRequestFormat == AppsFormat) { getAppsBlocksHTML(page, newCapiBlocks) } else if (remoteRender) { getDCRBlocksHTML(page, newCapiBlocks) } else { Future.successful(views.html.liveblog.liveBlogBlocks(newBlocks, page.article, Edition(request).timezone)) } } yield { val allPagesJson = Seq( "timeline" -> timelineHtml, "numNewBlocks" -> newBlocks.size, ) val livePageJson = isLivePage.filter(_ == true).map { _ => "html" -> blocksHtml } val mostRecent = newestBlock.map { block => "mostRecentBlockId" -> s"block-${block.id}" } Cached(page)(JsonComponent(allPagesJson ++ livePageJson ++ mostRecent: _*)) } } /** Returns a JSON representation of the payload that's sent to DCR when rendering the whole LiveBlog page. */ private[this] def renderDCRJson( blog: LiveBlogPage, blocks: Blocks, filterKeyEvents: Boolean, )(implicit request: RequestHeader): Result = { val pageType: PageType = PageType(blog, request, context) val newsletter = newsletterService.getNewsletterForLiveBlog(blog) val model = DotcomRenderingDataModel.forLiveblog( blog, blocks, request, pageType, filterKeyEvents, request.forceLive, newsletter, ) val json = DotcomRenderingDataModel.toJson(model) common.renderJson(json, blog).as("application/json") } private[this] def mapModel( path: String, range: BlockRange, filterKeyEvents: Boolean = false, )( render: (PageWithStoryPackage, Blocks) => Future[Result], )(implicit request: RequestHeader): Future[Result] = { capiLookup .lookup(path, Some(range)) .map(responseToModelOrResult(range, filterKeyEvents)) .recover(convertApiExceptions) .flatMap { case Right((model, blocks)) => render(model, blocks) case Left(other) => Future.successful(RenderOtherStatus(other)) } } private[this] def responseToModelOrResult( range: BlockRange, filterKeyEvents: Boolean, )(response: ItemResponse)(implicit request: RequestHeader): Either[Result, (PageWithStoryPackage, Blocks)] = { val supportedContent: Option[ContentType] = response.content.filter(isSupported).map(Content(_)) val supportedContentResult: Either[Result, ContentType] = ModelOrResult(supportedContent, response) val blocks = response.content.flatMap(_.blocks).getOrElse(Blocks()) val content = supportedContentResult.flatMap { case minute: Article if minute.isTheMinute => Right(MinutePage(minute, StoryPackages(minute.metadata.id, response)), blocks) case liveBlog: Article if liveBlog.isLiveBlog && request.isEmail => Right(MinutePage(liveBlog, StoryPackages(liveBlog.metadata.id, response)), blocks) case liveBlog: Article if liveBlog.isLiveBlog => createLiveBlogModel( liveBlog, response, range, filterKeyEvents, ).map(_ -> blocks) case nonLiveBlogArticle: Article => /** If `isLiveBlog` is false, it must be because the article has no blocks, or lacks the `tone/minutebyminute` * tag, or both. Logging these values will help us to identify which is causing the issue. */ val hasBlocks = nonLiveBlogArticle.fields.blocks.nonEmpty; val hasMinuteByMinuteTag = nonLiveBlogArticle.tags.isLiveBlog; logErrorWithRequestId( s"Requested non-liveblog article as liveblog: ${nonLiveBlogArticle.metadata.id}: { hasBlocks: ${hasBlocks}, hasMinuteByMinuteTag: ${hasMinuteByMinuteTag} }", ) Left(InternalServerError) case unknown => logErrorWithRequestId(s"Requested non-liveblog: ${unknown.metadata.id}") Left(InternalServerError) } content } def shouldFilter(filterKeyEvents: Option[Boolean]): Boolean = { filterKeyEvents.getOrElse(false) } }