sport/app/football/controllers/MoreOnMatchController.scala (418 lines of code) (raw):
package football.controllers
import com.github.nscala_time.time.Imports._
import common._
import conf.Configuration
import contentapi.ContentApiClient
import feed.CompetitionsService
import football.datetime.DateHelpers
import football.model.{FootballMatchTrail, GuTeamCodes}
import implicits.{Football, Requests}
import model.Cached.{RevalidatableResult, WithoutRevalidationResult}
import model.{Cached, Competition, Content, ContentType, TeamColours}
import pa.{FootballMatch, LineUp, LineUpTeam, MatchDayTeam}
import play.api.libs.json._
import play.api.mvc._
import play.twirl.api.Html
import model.CompetitionDisplayHelpers.cleanTeamNameNextGenApi
import java.time.format.DateTimeFormatter
import java.time.ZonedDateTime
import scala.concurrent.Future
// TODO import java.time.LocalDate and do not import DateHelpers.
case class Report(trail: FootballMatchTrail, name: String)
case class MatchNav(
theMatch: FootballMatch,
matchReport: Option[FootballMatchTrail],
minByMin: Option[FootballMatchTrail],
preview: Option[FootballMatchTrail],
stats: FootballMatchTrail,
currentPage: Option[FootballMatchTrail],
) {
// do not count stats as a report (stats will always be there)
lazy val hasReports = hasReport || hasMinByMin || hasPreview
lazy val hasMinByMin = minByMin.isDefined
lazy val hasReport = matchReport.isDefined
lazy val hasPreview = preview.isDefined
}
/*
Date: 12th June 2020
Author: Pascal
For the moment I am essentially reproducing here what I did for MatchController.scala
If the two sets of classes turn out to be the same (or one an extension of the other) then I will move
them to a single model file.
*/
sealed trait NxAnswer
case class NxEvent(eventTime: String, eventType: String) extends NxAnswer
case class NxPlayer(
id: String,
name: String,
position: String,
lastName: String,
substitute: Boolean,
timeOnPitch: String,
shirtNumber: String,
events: Seq[EventAnswer],
) extends NxAnswer
case class NxTeam(
id: String,
name: String,
codename: String,
players: Seq[NxPlayer],
score: Int,
scorers: List[String],
possession: Int,
shotsOn: Int,
shotsOff: Int,
corners: Int,
fouls: Int,
colours: String,
crest: String,
) extends NxAnswer
case class NxCompetition(fullName: Option[String]) extends NxAnswer
case class NxMatchData(
id: String,
isResult: Boolean,
homeTeam: NxTeam,
awayTeam: NxTeam,
competition: NxCompetition,
isLive: Boolean,
venue: String,
comments: String,
minByMinUrl: Option[String],
reportUrl: Option[String],
) extends NxAnswer
object NxAnswer {
val reportedEventTypes = List("booking", "dismissal", "substitution")
def makePlayers(team: LineUpTeam): Seq[NxPlayer] = {
team.players.map { player =>
val events = player.events.filter(event => NsAnswer.reportedEventTypes.contains(event.eventType)).map { event =>
EventAnswer(event.eventTime, event.eventType)
}
NxPlayer(
player.id,
player.name,
player.position,
player.lastName,
player.substitute,
player.timeOnPitch,
player.shirtNumber,
events,
)
}
}
def makeTeamAnswer(teamV1: MatchDayTeam, teamV2: LineUpTeam, teamPossession: Int, teamColour: String): NxTeam = {
val players = makePlayers(teamV2)
NxTeam(
teamV1.id,
cleanTeamNameNextGenApi(teamV1.name),
codename = GuTeamCodes.codeFor(teamV1),
players = players,
score = teamV1.score.getOrElse(0),
scorers = teamV1.scorers.fold(Nil: List[String])(
_.split(",")
.map(scorer => {
scorer.replace("(", "").replace(")", "")
})
.toList,
),
possession = teamPossession,
shotsOn = teamV2.shotsOn,
shotsOff = teamV2.shotsOff,
corners = teamV2.corners,
fouls = teamV2.fouls,
colours = teamColour,
crest = s"${Configuration.staticSport.path}/football/crests/120/${teamV1.id}.png",
)
}
def makeMinByMinUrl(implicit
request: RequestHeader,
theMatch: FootballMatch,
related: Seq[ContentType],
): Option[String] = {
val (_, minByMin, _, _) = MatchMetadata.fetchRelatedMatchContent(theMatch, related)
minByMin.map(x => LinkTo(x.url))
}
def makeMatchReportUrl(implicit
request: RequestHeader,
theMatch: FootballMatch,
related: Seq[ContentType],
): Option[String] = {
val (matchReport, _, _, _) = MatchMetadata.fetchRelatedMatchContent(theMatch, related)
matchReport.map(x => LinkTo(x.url))
}
def makeFromFootballMatch(
request: RequestHeader,
theMatch: FootballMatch,
related: Seq[ContentType],
lineUp: LineUp,
competition: Option[Competition],
isResult: Boolean,
isLive: Boolean,
): NxMatchData = {
val teamColours = TeamColours(lineUp.homeTeam, lineUp.awayTeam)
NxMatchData(
id = theMatch.id,
isResult = isResult,
homeTeam = makeTeamAnswer(theMatch.homeTeam, lineUp.homeTeam, lineUp.homeTeamPossession, teamColours.home),
awayTeam = makeTeamAnswer(theMatch.awayTeam, lineUp.awayTeam, lineUp.awayTeamPossession, teamColours.away),
competition = NxCompetition(competition.map(_.fullName)),
isLive = isLive,
venue = theMatch.venue.map(_.name).getOrElse(""),
comments = theMatch.comments.getOrElse(""),
minByMinUrl = makeMinByMinUrl(request, theMatch, related),
reportUrl = makeMatchReportUrl(request, theMatch, related),
)
}
implicit val EventAnswerWrites: Writes[NxEvent] = Json.writes[NxEvent]
implicit val PlayerAnswerWrites: Writes[NxPlayer] = Json.writes[NxPlayer]
implicit val TeamAnswerWrites: Writes[NxTeam] = Json.writes[NxTeam]
implicit val CompetitionAnswerWrites: Writes[NxCompetition] = Json.writes[NxCompetition]
implicit val MatchDataAnswerWrites: Writes[NxMatchData] = Json.writes[NxMatchData]
}
case class Interval(start: ZonedDateTime, end: ZonedDateTime) {
def contains(dt: ZonedDateTime): Boolean = {
(dt.isAfter(start) && dt.isBefore(end)) || dt.isEqual(
start,
) // nb. don't check for equals end as Interval.contains which this replaces is not end-inclusive.
}
}
object MatchMetadata extends Football {
def fetchRelatedMatchContent(theMatch: FootballMatch, related: Seq[ContentType])(implicit
request: RequestHeader,
): (Option[FootballMatchTrail], Option[FootballMatchTrail], Option[FootballMatchTrail], FootballMatchTrail) = {
val matchDate = theMatch.date
val matchReport = related.find { c =>
val webPublicationDate =
DateHelpers.asZonedDateTime(c.trail.webPublicationDate.withZone(DateTimeZone.forID("Europe/London")))
webPublicationDate.isAfter(DateHelpers.startOfDay(matchDate)) && c.matchReport && !c.minByMin && !c.preview
}
val minByMin = related.find { c =>
c.minByMin && !c.preview
}
val preview = related.find { c =>
val webPublicationDate =
DateHelpers.asZonedDateTime(c.trail.webPublicationDate.withZone(DateTimeZone.forID("Europe/London")))
webPublicationDate.isBefore(
DateHelpers.startOfDay(matchDate),
) && (c.preview || c.squadSheet) && !c.matchReport && !c.minByMin
}
val stats: FootballMatchTrail = FootballMatchTrail.toTrail(theMatch)
(
matchReport.map(FootballMatchTrail.toTrail),
minByMin.map(FootballMatchTrail.toTrail),
preview.map(FootballMatchTrail.toTrail),
stats,
)
}
}
class MoreOnMatchController(
val competitionsService: CompetitionsService,
contentApiClient: ContentApiClient,
val controllerComponents: ControllerComponents,
) extends BaseController
with Football
with Requests
with GuLogging
with ImplicitControllerExecutionContext {
def interval(contentDate: java.time.LocalDate): Interval = {
val twoDaysAgo = DateHelpers.asZonedDateTime(contentDate).minusDays(2)
val threeDaysAhead = DateHelpers.asZonedDateTime(contentDate).plusDays(3)
Interval(twoDaysAgo, threeDaysAhead)
}
// note team1 & team2 are the home and away team, but we do NOT know their order
def matchNavJson(year: String, month: String, day: String, team1: String, team2: String): Action[AnyContent] =
matchNav(year, month, day, team1, team2)
def matchNav(year: String, month: String, day: String, team1: String, team2: String): Action[AnyContent] =
Action.async { implicit request =>
val contentDate = DateHelpers.parseLocalDate(year, month, day)
val maybeResponse: Option[Future[Result]] =
competitionsService.matchFor(interval(contentDate), team1, team2) map { theMatch =>
val related: Future[Seq[ContentType]] = loadMoreOn(request, theMatch)
// We are only interested in content with exactly 2 team tags
val group = theMatch.round.name
.flatMap {
case roundName if roundName.toLowerCase.startsWith("group") =>
Some(roundName.toLowerCase.replace(' ', '-'))
case _ => None
}
.getOrElse("")
lazy val competition = competitionsService.competitionForMatch(theMatch.id)
if (request.forceDCR) {
for {
lineup <- competitionsService.getLineup(theMatch)
filtered <- related map { _ filter hasExactlyTwoTeams }
} yield {
Cached(if (theMatch.isLive) 10 else 300) {
JsonComponent.fromWritable(
NxAnswer.makeFromFootballMatch(
request,
theMatch,
filtered,
lineup,
competition,
theMatch.isResult,
theMatch.isLive,
),
)
}
}
} else {
for {
filtered <- related map { _ filter hasExactlyTwoTeams }
} yield {
Cached(if (theMatch.isLive) 10 else 300) {
JsonComponent(
"nav" -> football.views.html.fragments.matchNav(populateNavModel(theMatch, filtered)),
"matchSummary" -> football.views.html.fragments
.matchSummary(theMatch, competitionsService.competitionForMatch(theMatch.id), responsive = true),
"hasStarted" -> theMatch.hasStarted,
"group" -> group,
"matchDate" -> theMatch.date.format(DateTimeFormatter.ofPattern("yyyy/MMM/dd")).toLowerCase(),
"dropdown" -> views.html.fragments.dropdown("")(Html("")),
)
}
}
}
}
maybeResponse.getOrElse(Future.successful(Cached(30) { JsonNotFound() }))
}
def moreOnJson(matchId: String): Action[AnyContent] = moreOn(matchId)
def moreOn(matchId: String): Action[AnyContent] =
Action.async { implicit request =>
val maybeMatch: Option[FootballMatch] = competitionsService.findMatch(matchId)
val maybeResponse: Option[Future[RevalidatableResult]] = maybeMatch map { theMatch =>
loadMoreOn(request, theMatch) map {
case Nil =>
logInfoWithRequestId(s"Cannot load more for match id: ${theMatch.id}")
JsonNotFound()
case related =>
JsonComponent(
"nav" -> football.views.html.fragments.matchNav(
populateNavModel(
theMatch,
related filter {
hasExactlyTwoTeams
},
),
),
)
}
}
val response: Future[RevalidatableResult] = maybeResponse.getOrElse(Future { JsonNotFound() })
response map { Cached(60) }
}
def loadMoreOn(request: RequestHeader, theMatch: FootballMatch): Future[List[ContentType]] = {
val matchDate = theMatch.date
val startOfDateRange = DateHelpers.startOfDay(matchDate.minusDays(2))
val endOfDateRange = DateHelpers.startOfDay(matchDate.plusDays(2))
contentApiClient
.getResponse(
contentApiClient
.search()
.section("football")
.tag(
"tone/minutebyminute|tone/matchreports|football/series/squad-sheets|football/series/match-previews|football/series/saturday-clockwatch",
)
.fromDate(startOfDateRange.toInstant)
.toDate(endOfDateRange.toInstant)
.reference(s"pa-football-team/${theMatch.homeTeam.id},pa-football-team/${theMatch.awayTeam.id}"),
)
.map { response =>
response.results.map(Content(_)).toList
}
}
def redirectToMatchId(matchId: String): Action[AnyContent] =
Action.async { implicit request =>
val maybeMatch: Option[FootballMatch] = competitionsService.findMatch(matchId)
canonicalRedirectForMatch(maybeMatch, request)
}
def redirectToMatch(year: String, month: String, day: String, home: String, away: String): Action[AnyContent] =
Action.async { implicit request =>
val contentDate = DateHelpers.parseLocalDate(year, month, day)
val maybeMatch = competitionsService.matchFor(interval(contentDate), home, away)
canonicalRedirectForMatch(maybeMatch, request)
}
def bigMatchSpecial(matchId: String): Action[AnyContent] =
Action { implicit request =>
val response = competitionsService.competitions
.find { _.matches.exists(_.id == matchId) }
.fold(JsonNotFound()) { competition =>
val fMatch = competition.matches.find(_.id == matchId).head
JsonComponent(football.views.html.fragments.matchSummary(fMatch, Some(competition), link = true))
}
Cached(30)(response)
}
def matchSummaryMf2(year: String, month: String, day: String, team1: String, team2: String): Action[AnyContent] =
Action.async { implicit request =>
val contentDate = DateHelpers.parseLocalDate(year, month, day)
val maybeResponse: Option[Future[Result]] =
competitionsService.matchFor(interval(contentDate), team1, team2) map { theMatch =>
val related: Future[Seq[ContentType]] = loadMoreOn(request, theMatch)
// We are only interested in content with exactly 2 team tags
related map { _ filter hasExactlyTwoTeams } map { filtered =>
Cached(if (theMatch.isLive) 10 else 300) {
lazy val competition = competitionsService.competitionForMatch(theMatch.id)
lazy val homeTeamResults = competition.map(_.teamResults(theMatch.homeTeam.id).take(5))
implicit val dateToTimestampWrites = play.api.libs.json.JodaWrites.JodaDateTimeNumberWrites
JsonComponent(
"items" -> Json.arr(
Json.obj(
"id" -> theMatch.id,
"date" -> theMatch.date,
"venue" -> theMatch.venue.map(_.name),
"isLive" -> theMatch.isLive,
"isResult" -> theMatch.isResult,
"isLiveOrIsResult" -> (theMatch.isResult || theMatch.isLive),
"homeTeam" -> Json.obj(
"name" -> theMatch.homeTeam.name,
"id" -> theMatch.homeTeam.id,
"score" -> theMatch.homeTeam.score,
"crest" -> s"${Configuration.staticSport.path}/football/crests/120/${theMatch.homeTeam.id}.png",
"scorers" -> theMatch.homeTeam.scorers
.getOrElse("")
.split(",")
.map(scorer => {
Json.obj(
"scorer" -> scorer.replace("(", "").replace(")", ""),
)
}),
),
"awayTeam" -> Json.obj(
"name" -> theMatch.awayTeam.name,
"id" -> theMatch.awayTeam.id,
"score" -> theMatch.awayTeam.score,
"crest" -> s"${Configuration.staticSport.path}/football/crests/120/${theMatch.awayTeam.id}.png",
"scorers" -> theMatch.awayTeam.scorers
.getOrElse("")
.split(",")
.map(scorer => {
Json.obj(
"scorer" -> scorer.replace("(", "").replace(")", ""),
)
}),
),
"competition" -> Json.obj(
"fullName" -> competition.map(_.fullName),
),
),
),
)
}
}
}
maybeResponse.getOrElse(Future.successful(Cached(30) { JsonNotFound() }))
}
private def canonicalRedirectForMatch(maybeMatch: Option[FootballMatch], request: RequestHeader)(implicit
requestHeader: RequestHeader,
): Future[Result] = {
maybeMatch
.map { theMatch =>
loadMoreOn(request, theMatch).map { related =>
val (matchReport, minByMin, preview, stats) = MatchMetadata.fetchRelatedMatchContent(theMatch, related)
val canonicalPage =
matchReport.orElse(minByMin).orElse { if (theMatch.isFixture) preview else None }.getOrElse(stats)
Cached(60)(WithoutRevalidationResult(Found(canonicalPage.url)))
}
}
.getOrElse {
// we do not keep historical data, so just redirect old stuff to the results page (see also MatchController)
Future.successful(Cached(60)(WithoutRevalidationResult(Found("/football/results"))))
}
}
// for our purposes we expect exactly 2 football teams
private def hasExactlyTwoTeams(content: ContentType): Boolean = content.tags.tags.count(_.isFootballTeam) == 2
private def populateNavModel(theMatch: FootballMatch, related: Seq[ContentType])(implicit
request: RequestHeader,
): MatchNav = {
val (matchReport, minByMin, preview, stats) = MatchMetadata.fetchRelatedMatchContent(theMatch, related)
val currentPage = request.getParameter("page").flatMap { pageId =>
(stats :: List(matchReport, minByMin, preview).flatten).find(_.url.endsWith(pageId))
}
MatchNav(theMatch, matchReport, minByMin, preview, stats, currentPage)
}
}