diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
index 943e93dff946..d723824d2be3 100644
--- a/.git-blame-ignore-revs
+++ b/.git-blame-ignore-revs
@@ -3,3 +3,9 @@
# Scala Steward: Reformat with scalafmt 3.8.3
b3af67b602e9862062ca4dd9fb5038b80aaad6e2
+
+# Scala Steward: Reformat with scalafmt 3.8.6
+744c3b90418a47f0fadff5138c7c3923bf7d6fec
+
+# Scala Steward: Reformat with scalafmt 3.10.2
+6c0512446a4c6b1ebfbe779aad810c617e0bf08f
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 0d63db2d3f1e..59fd50c7dd8c 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -7,5 +7,5 @@
/static/src/javascripts/projects/commercial/ @guardian/commercial-dev
/commercial/ @guardian/commercial-dev
-# Targeted Experiences (TX)
-/static/src/javascripts/projects/common/modules/commercial/braze @guardian/tx-engineers
+# Value (Supporter Revenue)
+/static/src/javascripts/projects/common/modules/commercial/braze @guardian/value
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 21635f59ec18..f635feb8948f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -1,10 +1,11 @@
-name: "Build"
+name: 'Build'
on:
pull_request:
push: # Do not rely on `push` for PR CI - see https://github.com/guardian/mobile-apps-api/pull/2760
branches:
- main # Optimal for GHA workflow caching - see https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#restrictions-for-accessing-a-cache
+ workflow_dispatch:
permissions:
id-token: write
@@ -13,33 +14,78 @@ permissions:
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
- group: "${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
+ group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}'
cancel-in-progress: true
jobs:
- build:
+ client-validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - run: corepack enable
+
+ - uses: actions/setup-node@v6
+ with:
+ cache: yarn
+ node-version-file: .nvmrc
+
+ - run: make install
+ - run: make validate
+ - run: make test
+
+ client-build:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- run: corepack enable
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
cache: yarn
node-version-file: .nvmrc
- - uses: actions/setup-java@v4
+
+ - run: make install
+ - run: make compile
+
+ - name: upload frontend-client
+ uses: actions/upload-artifact@v5
+ with:
+ name: frontend-client
+ path: |
+ static/hash
+ static/target
+ common/conf/assets
+ if-no-files-found: error
+
+ build:
+ needs: [client-validate, client-build]
+ runs-on: 8core-ubuntu-latest-frontend
+ steps:
+ - uses: actions/checkout@v6
+
+ - run: corepack enable
+
+ - uses: actions/setup-node@v6
+ with:
+ cache: yarn
+ node-version-file: .nvmrc
+
+ - uses: actions/setup-java@v5.1.0
with:
distribution: corretto
cache: sbt
java-version: 11
- - run: make reinstall
- - run: make validate
- - run: make test
- - run: make compile
+ # Scala tests rely on client build assets
+ - name: Download frontend-client
+ uses: actions/download-artifact@v6
+ with:
+ name: frontend-client
+ path: .
- - name: Test, compile
+ - name: Test, Compile, Package
# Australia/Sydney -because it is too easy for devs to forget about timezones
run: |
java \
@@ -49,12 +95,12 @@ jobs:
-XX:+UseParallelGC \
-DAPP_SECRET="fake_secret" \
-Duser.timezone=Australia/Sydney \
- -jar ./bin/sbt-launch.jar clean compile assets scalafmtCheckAll test Universal/packageBin
+ -jar ./bin/sbt-launch.jar compile assets scalafmtCheckAll test Universal/packageBin
- name: Test Summary
uses: test-summary/action@v2
with:
- paths: "test-results/**/TEST-*.xml"
+ paths: 'test-results/**/TEST-*.xml'
if: always()
- uses: guardian/actions-riff-raff@v4.1.2
diff --git a/.github/workflows/sbt-dependency-graph.yaml b/.github/workflows/sbt-dependency-graph.yaml
index 809be3caffb9..3f50f9b129bd 100644
--- a/.github/workflows/sbt-dependency-graph.yaml
+++ b/.github/workflows/sbt-dependency-graph.yaml
@@ -10,16 +10,16 @@ jobs:
steps:
- name: Checkout branch
id: checkout
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
+ uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Install Java
id: java
- uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.2.0
+ uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # v5.1.0
with:
distribution: corretto
java-version: 17
- name: Install sbt
id: sbt
- uses: sbt/setup-sbt@8a071aa780c993c7a204c785d04d3e8eb64ef272 # v1.1.0
+ uses: sbt/setup-sbt@3e125ece5c3e5248e18da9ed8d2cce3d335ec8dd # v1.1.14
- name: Submit dependencies
id: submit
uses: scalacenter/sbt-dependency-submission@64084844d2b0a9b6c3765f33acde2fbe3f5ae7d3 # v3.1.0
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index d1dd4ca980b6..a4288318abdd 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -11,7 +11,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v9
+ - uses: actions/stale@v10
id: stale
# Read about options here: https://github.com/actions/stale#all-options
with:
diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml
index 46456c3a860d..fd0bea1c47e9 100644
--- a/.github/workflows/typescript.yml
+++ b/.github/workflows/typescript.yml
@@ -13,10 +13,10 @@ jobs:
name: Typescript
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- run: corepack enable
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: yarn
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index ba17ef518b7b..fd203c3097d0 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -12,10 +12,10 @@ jobs:
validate:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- run: corepack enable
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'
cache: yarn
diff --git a/.nvmrc b/.nvmrc
index b8e593f5210c..7d41c735d712 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-20.15.1
+22.14.0
diff --git a/.scalafmt.conf b/.scalafmt.conf
index c6e9c58f8166..f0da1152955e 100644
--- a/.scalafmt.conf
+++ b/.scalafmt.conf
@@ -1,4 +1,4 @@
-version=3.8.3
+version=3.10.2
runner.dialect = scala213
maxColumn = 120
diff --git a/README.md b/README.md
index 94d6f0f8e5a9..baa69d69aa69 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,3 @@
-## We're hiring!
-Ever thought about joining us?
-[https://workforus.theguardian.com/careers/product-engineering/](https://workforus.theguardian.com/careers/product-engineering/)
-
# Frontend
The Guardian website frontend.
diff --git a/admin/app/AppLoader.scala b/admin/app/AppLoader.scala
index 4b1c80964341..4aa5663b3c8e 100644
--- a/admin/app/AppLoader.scala
+++ b/admin/app/AppLoader.scala
@@ -1,11 +1,8 @@
import app.{FrontendApplicationLoader, FrontendComponents, LifecycleComponent}
import com.softwaremill.macwire._
-import dfp._
-import common.dfp._
import common._
import conf.switches.SwitchboardLifecycle
import controllers.{AdminControllers, HealthCheck}
-import _root_.dfp.DfpDataCacheLifecycle
import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem}
import concurrent.BlockingOperations
import contentapi.{CapiHttpClient, ContentApiClient, HttpClient}
@@ -40,34 +37,11 @@ trait AdminServices extends I18nComponents {
lazy val contentApiClient = wire[ContentApiClient]
lazy val ophanApi = wire[OphanApi]
lazy val emailService = wire[EmailService]
- lazy val fastlyStatisticService = wire[FastlyStatisticService]
- lazy val fastlyCloudwatchLoadJob = wire[FastlyCloudwatchLoadJob]
lazy val redirects = wire[RedirectService]
lazy val r2PagePressJob = wire[R2PagePressJob]
- lazy val analyticsSanityCheckJob = wire[AnalyticsSanityCheckJob]
lazy val rebuildIndexJob = wire[RebuildIndexJob]
- lazy val dfpApi: DfpApi = wire[DfpApi]
lazy val blockingOperations: BlockingOperations = wire[BlockingOperations]
- lazy val adUnitAgent: AdUnitAgent = wire[AdUnitAgent]
- lazy val adUnitService: AdUnitService = wire[AdUnitService]
- lazy val advertiserAgent: AdvertiserAgent = wire[AdvertiserAgent]
- lazy val creativeTemplateAgent: CreativeTemplateAgent = wire[CreativeTemplateAgent]
- lazy val customFieldAgent: CustomFieldAgent = wire[CustomFieldAgent]
- lazy val customFieldService: CustomFieldService = wire[CustomFieldService]
- lazy val customTargetingAgent: CustomTargetingAgent = wire[CustomTargetingAgent]
- lazy val customTargetingService: CustomTargetingService = wire[CustomTargetingService]
- lazy val customTargetingKeyValueJob: CustomTargetingKeyValueJob = wire[CustomTargetingKeyValueJob]
- lazy val dataMapper: DataMapper = wire[DataMapper]
- lazy val dataValidation: DataValidation = wire[DataValidation]
- lazy val dfpDataCacheJob: DfpDataCacheJob = wire[DfpDataCacheJob]
- lazy val orderAgent: OrderAgent = wire[OrderAgent]
- lazy val placementAgent: PlacementAgent = wire[PlacementAgent]
- lazy val placementService: PlacementService = wire[PlacementService]
- lazy val dfpFacebookIaAdUnitCacheJob: DfpFacebookIaAdUnitCacheJob = wire[DfpFacebookIaAdUnitCacheJob]
- lazy val dfpAdUnitCacheJob: DfpAdUnitCacheJob = wire[DfpAdUnitCacheJob]
- lazy val dfpMobileAppUnitCacheJob: DfpMobileAppAdUnitCacheJob = wire[DfpMobileAppAdUnitCacheJob]
- lazy val dfpTemplateCreativeCacheJob: DfpTemplateCreativeCacheJob = wire[DfpTemplateCreativeCacheJob]
lazy val parameterStoreService: ParameterStoreService = wire[ParameterStoreService]
lazy val parameterStoreProvider: ParameterStoreProvider = wire[ParameterStoreProvider]
}
@@ -82,20 +56,12 @@ trait AppComponents extends FrontendComponents with AdminControllers with AdminS
wire[SwitchboardLifecycle],
wire[CloudWatchMetricsLifecycle],
wire[SurgingContentAgentLifecycle],
- wire[DfpAgentLifecycle],
- wire[DfpDataCacheLifecycle],
- wire[CommercialDfpReportingLifecycle],
)
lazy val router: Router = wire[Routes]
lazy val appIdentity = ApplicationIdentity("admin")
- override lazy val appMetrics = ApplicationMetrics(
- DfpApiMetrics.DfpSessionErrors,
- DfpApiMetrics.DfpApiErrors,
- )
-
def pekkoActorSystem: PekkoActorSystem
override lazy val httpFilters: Seq[EssentialFilter] =
diff --git a/admin/app/controllers/AdminControllers.scala b/admin/app/controllers/AdminControllers.scala
index 5064cab9154f..09d4495174eb 100644
--- a/admin/app/controllers/AdminControllers.scala
+++ b/admin/app/controllers/AdminControllers.scala
@@ -1,20 +1,17 @@
package controllers
-import com.amazonaws.regions.Regions
-import com.amazonaws.services.s3.AmazonS3ClientBuilder
import com.softwaremill.macwire._
import common.PekkoAsync
import controllers.admin._
import controllers.admin.commercial._
import controllers.cache.{ImageDecacheController, PageDecacheController}
-import dfp._
import http.{GuardianAuthWithExemptions, routes}
import model.ApplicationContext
import play.api.http.HttpConfiguration
import play.api.libs.ws.WSClient
import play.api.mvc.ControllerComponents
import services.{OphanApi, ParameterStoreService, RedirectService}
-import conf.Configuration.aws.mandatoryCredentials
import org.apache.pekko.stream.Materializer
+import utils.AWSv2
trait AdminControllers {
def pekkoAsync: PekkoAsync
@@ -26,38 +23,14 @@ trait AdminControllers {
def httpConfiguration: HttpConfiguration
def controllerComponents: ControllerComponents
def assets: Assets
- def adUnitAgent: AdUnitAgent
- def adUnitService: AdUnitService
- def advertiserAgent: AdvertiserAgent
- def creativeTemplateAgent: CreativeTemplateAgent
- def customFieldAgent: CustomFieldAgent
- def customFieldService: CustomFieldService
- def customTargetingAgent: CustomTargetingAgent
- def customTargetingService: CustomTargetingService
- def customTargetingKeyValueJob: CustomTargetingKeyValueJob
- def dataMapper: DataMapper
- def dataValidation: DataValidation
- def dfpDataCacheJob: DfpDataCacheJob
- def orderAgent: OrderAgent
- def placementAgent: PlacementAgent
- def placementService: PlacementService
- def dfpApi: DfpApi
def parameterStoreService: ParameterStoreService
- private lazy val s3Client = AmazonS3ClientBuilder
- .standard()
- .withRegion(Regions.EU_WEST_1)
- .withCredentials(
- mandatoryCredentials,
- )
- .build()
-
lazy val auth = new GuardianAuthWithExemptions(
controllerComponents,
wsClient,
toolsDomainPrefix = "frontend",
oauthCallbackPath = routes.GuardianAuthWithExemptions.oauthCallback.path,
- s3Client,
+ AWSv2.S3Sync,
system = "frontend-admin",
extraDoNotAuthenticatePathPrefixes = Seq(
// Date: 06 July 2021
@@ -83,12 +56,8 @@ trait AdminControllers {
lazy val appConfigController = wire[AppConfigController]
lazy val switchboardController = wire[SwitchboardController]
lazy val analyticsController = wire[AnalyticsController]
- lazy val analyticsConfidenceController = wire[AnalyticsConfidenceController]
lazy val metricsController = wire[MetricsController]
lazy val commercialController = wire[CommercialController]
- lazy val dfpDataController = wire[DfpDataController]
- lazy val takeoverWithEmptyMPUsController = wire[TakeoverWithEmptyMPUsController]
- lazy val fastlyController = wire[FastlyController]
lazy val redirectController = wire[RedirectController]
lazy val sportTroubleShooterController = wire[SportTroubleshooterController]
lazy val troubleshooterController = wire[TroubleshooterController]
diff --git a/admin/app/controllers/admin/AnalyticsConfidenceController.scala b/admin/app/controllers/admin/AnalyticsConfidenceController.scala
deleted file mode 100644
index f3f8208fdc15..000000000000
--- a/admin/app/controllers/admin/AnalyticsConfidenceController.scala
+++ /dev/null
@@ -1,45 +0,0 @@
-package controllers.admin
-
-import common.{ImplicitControllerExecutionContext, GuLogging}
-import model.ApplicationContext
-import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
-import tools._
-
-class AnalyticsConfidenceController(val controllerComponents: ControllerComponents)(implicit
- context: ApplicationContext,
-) extends BaseController
- with GuLogging
- with ImplicitControllerExecutionContext {
- def renderConfidence(): Action[AnyContent] =
- Action.async { implicit request =>
- for {
- ophan <- CloudWatch.ophanConfidence()
- google <- CloudWatch.googleConfidence()
- } yield {
- val ophanAverage = ophan.dataset.flatMap(_.values.headOption).sum / ophan.dataset.length
- val googleAverage = google.dataset.flatMap(_.values.headOption).sum / google.dataset.length
-
- val ophanGraph = new AwsLineChart(
- "Ophan confidence",
- Seq("Time", "%", "avg."),
- ChartFormat(Colour.`tone-comment-1`, Colour.success),
- ) {
- override lazy val dataset = ophan.dataset.map { point =>
- point.copy(values = point.values :+ ophanAverage)
- }
- }
-
- val googleGraph = new AwsLineChart(
- "Google confidence",
- Seq("Time", "%", "avg."),
- ChartFormat(Colour.`tone-comment-1`, Colour.success),
- ) {
- override lazy val dataset = google.dataset.map { point =>
- point.copy(values = point.values :+ googleAverage)
- }
- }
-
- Ok(views.html.lineCharts(Seq(ophanGraph, googleGraph)))
- }
- }
-}
diff --git a/admin/app/controllers/admin/AnalyticsController.scala b/admin/app/controllers/admin/AnalyticsController.scala
index 4225a22f1b8c..1562960e4ba4 100644
--- a/admin/app/controllers/admin/AnalyticsController.scala
+++ b/admin/app/controllers/admin/AnalyticsController.scala
@@ -3,6 +3,7 @@ package controllers.admin
import common.{GuLogging, ImplicitControllerExecutionContext}
import model.{ApplicationContext, NoCache}
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
+import tools._
import scala.concurrent.Future
@@ -10,8 +11,15 @@ class AnalyticsController(val controllerComponents: ControllerComponents)(implic
extends BaseController
with GuLogging
with ImplicitControllerExecutionContext {
- def abtests(): Action[AnyContent] =
+
+ def abTests(): Action[AnyContent] =
+ Action.async { implicit request =>
+ val frameUrl = Store.getAbTestFrameUrl
+ Future(NoCache(Ok(views.html.abTests(frameUrl))))
+ }
+
+ def legacyAbTests(): Action[AnyContent] =
Action.async { implicit request =>
- Future(NoCache(Ok(views.html.abtests())))
+ Future(NoCache(Ok(views.html.legacyAbTests())))
}
}
diff --git a/admin/app/controllers/admin/CommercialController.scala b/admin/app/controllers/admin/CommercialController.scala
index 1aa9f724a73b..09748097e661 100644
--- a/admin/app/controllers/admin/CommercialController.scala
+++ b/admin/app/controllers/admin/CommercialController.scala
@@ -1,37 +1,21 @@
package controllers.admin
-import common.dfp.{GuCreativeTemplate, GuCustomField, GuLineItem}
+import common.dfp.{GuCustomField, GuLineItem}
import common.{ImplicitControllerExecutionContext, JsonComponent, GuLogging}
import conf.Configuration
-import dfp.{AdvertiserAgent, CreativeTemplateAgent, CustomFieldAgent, DfpApi, DfpDataExtractor, OrderAgent}
+import dfp.{DfpDataExtractor}
import model._
import services.ophan.SurgingContentAgent
import play.api.libs.json.{JsString, Json}
import play.api.mvc._
import tools._
+import conf.switches.Switches.{LineItemJobs}
import scala.concurrent.duration._
import scala.util.Try
-case class CommercialPage() extends StandalonePage {
- override val metadata = MetaData.make(
- id = "commercial-templates",
- section = Some(SectionId.fromId("admin")),
- webTitle = "Commercial Templates",
- javascriptConfigOverrides = Map(
- "keywordIds" -> JsString("live-better"),
- "adUnit" -> JsString("/59666047/theguardian.com/global-development/ng"),
- ),
- )
-}
-
class CommercialController(
val controllerComponents: ControllerComponents,
- createTemplateAgent: CreativeTemplateAgent,
- advertiserAgent: AdvertiserAgent,
- orderAgent: OrderAgent,
- customFieldAgent: CustomFieldAgent,
- dfpApi: DfpApi,
)(implicit context: ApplicationContext)
extends BaseController
with GuLogging
@@ -42,15 +26,10 @@ class CommercialController(
NoCache(Ok(views.html.commercial.commercialMenu()))
}
- def renderFluidAds: Action[AnyContent] =
- Action { implicit request =>
- NoCache(Ok(views.html.commercial.fluidAds()))
- }
-
def renderSpecialAdUnits: Action[AnyContent] =
Action { implicit request =>
- val specialAdUnits = dfpApi.readSpecialAdUnits(Configuration.commercial.dfpAdUnitGuRoot)
- Ok(views.html.commercial.specialAdUnits(specialAdUnits))
+ val specialAdUnits = Store.getDfpSpecialAdUnits
+ NoCache(Ok(views.html.commercial.specialAdUnits(specialAdUnits)))
}
def renderPageskins: Action[AnyContent] =
@@ -78,32 +57,28 @@ class CommercialController(
NoCache(Ok(views.html.commercial.surveySponsorships(surveyAdUnits)))
}
- def renderCreativeTemplates: Action[AnyContent] =
- Action { implicit request =>
- val emptyTemplates = createTemplateAgent.get
- val creatives = Store.getDfpTemplateCreatives
- val templates = emptyTemplates
- .foldLeft(Seq.empty[GuCreativeTemplate]) { (soFar, template) =>
- soFar :+ template.copy(creatives = creatives.filter(_.templateId.get == template.id).sortBy(_.name))
- }
- .sortBy(_.name)
- NoCache(Ok(views.html.commercial.templates(templates)))
- }
-
def renderCustomFields: Action[AnyContent] =
Action { implicit request =>
- val fields: Seq[GuCustomField] = customFieldAgent.get.data.values.toSeq
+ val fields: Seq[GuCustomField] = Store.getDfpCustomFields
NoCache(Ok(views.html.commercial.customFields(fields)))
-
}
def renderAdTests: Action[AnyContent] =
Action { implicit request =>
val report = Store.getDfpLineItemsReport()
+ val commDevTestOrderId = 3093057300L
+ val (commDevLineItems, lineItems) = report.lineItems partition (_.orderId == commDevTestOrderId)
+
+ def groupByAdTest(lineItems: Seq[GuLineItem]) = {
+ lineItems
+ .filter(_.targeting.hasAdTestTargetting)
+ .groupBy(_.targeting.adTestValue.get)
+ }
- val lineItemsByAdTest = report.lineItems
- .filter(_.targeting.hasAdTestTargetting)
- .groupBy(_.targeting.adTestValue.get)
+ val lineItemsByAdTest = groupByAdTest(lineItems)
+ val commDevLineItemsByAdTest = groupByAdTest(commDevLineItems).toSeq.sortBy { case (testValue, _) =>
+ testValue
+ }
val (hasNumericTestValue, hasStringTestValue) =
lineItemsByAdTest partition { case (testValue, _) =>
@@ -113,11 +88,11 @@ class CommercialController(
}
val sortedGroups = {
- hasNumericTestValue.toSeq.sortBy { case (testValue, _) => testValue.toInt } ++
+ hasNumericTestValue.toSeq.sortBy { case (testValue, _) => testValue.toLong } ++
hasStringTestValue.toSeq.sortBy { case (testValue, _) => testValue }
}
- NoCache(Ok(views.html.commercial.adTests(report.timestamp, sortedGroups)))
+ NoCache(Ok(views.html.commercial.adTests(report.timestamp, commDevLineItemsByAdTest, sortedGroups)))
}
def getLineItemsForOrder(orderId: String): Action[AnyContent] =
@@ -129,24 +104,6 @@ class CommercialController(
}
}
- def getCreativesListing(lineitemId: String, section: String): Action[AnyContent] =
- Action { implicit request: RequestHeader =>
- val validSections: List[String] = List("uk", "lifeandstyle", "sport", "science")
-
- val previewUrls: Seq[String] =
- (for {
- lineItemId <- Try(lineitemId.toLong).toOption
- validSection <- validSections.find(_ == section)
- } yield {
- dfpApi.getCreativeIds(lineItemId) flatMap (dfpApi
- .getPreviewUrl(lineItemId, _, s"https://theguardian.com/$validSection"))
- }) getOrElse Nil
-
- Cached(5.minutes) {
- JsonComponent.fromWritable(previewUrls)
- }
- }
-
def renderKeyValues(): Action[AnyContent] =
Action { implicit request =>
Ok(views.html.commercial.customTargetingKeyValues(Store.getDfpCustomTargetingKeyValues))
@@ -172,29 +129,17 @@ class CommercialController(
val invalidLineItems: Seq[GuLineItem] = Store.getDfpLineItemsReport().invalidLineItems
val invalidItemsExtractor = DfpDataExtractor(invalidLineItems, Nil)
- val advertisers = advertiserAgent.get
- val orders = orderAgent.get
- val sonobiAdvertiserId = advertisers.find(_.name.toLowerCase == "sonobi").map(_.id).getOrElse(0L)
- val sonobiOrderIds = orders.filter(_.advertiserId == sonobiAdvertiserId).map(_.id)
-
// Sort line items into groups where possible, and bucket everything else.
val pageskins = invalidItemsExtractor.pageSkinSponsorships
- val groupedItems = invalidLineItems.groupBy {
- case item if sonobiOrderIds.contains(item.orderId) => "sonobi"
- case _ => "unknown"
- }
-
- val sonobiItems = groupedItems.getOrElse("sonobi", Seq.empty)
val invalidItemsMap = GuLineItem.asMap(invalidLineItems)
val unidentifiedLineItems =
- invalidItemsMap.keySet -- pageskins.map(_.lineItemId) -- sonobiItems.map(_.id)
+ invalidItemsMap.keySet -- pageskins.map(_.lineItemId)
Ok(
views.html.commercial.invalidLineItems(
pageskins,
- sonobiItems,
unidentifiedLineItems.toSeq.map(invalidItemsMap),
),
)
diff --git a/admin/app/controllers/admin/TroubleshooterController.scala b/admin/app/controllers/admin/TroubleshooterController.scala
index 8a8c6df365b2..232f1e7aeb2e 100644
--- a/admin/app/controllers/admin/TroubleshooterController.scala
+++ b/admin/app/controllers/admin/TroubleshooterController.scala
@@ -1,20 +1,14 @@
package controllers.admin
-import com.amazonaws.services.ec2.model.{DescribeInstancesRequest, Filter}
-import com.amazonaws.services.ec2.{AmazonEC2, AmazonEC2ClientBuilder}
import common.{GuLogging, ImplicitControllerExecutionContext}
-import conf.Configuration.aws.credentials
import contentapi.{CapiHttpClient, ContentApiClient, PreviewContentApi, PreviewSigner}
import model.{ApplicationContext, NoCache}
-import play.api.Mode
import play.api.libs.ws.WSClient
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
import tools.LoadBalancer
-import scala.jdk.CollectionConverters._
import scala.concurrent.Future
import scala.concurrent.duration._
-import scala.util.Random
case class EndpointStatus(name: String, isOk: Boolean, messages: String*)
@@ -36,14 +30,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con
val contentApi = new ContentApiClient(capiLiveHttpClient)
val previewContentApi = new PreviewContentApi(capiPreviewHttpClient)
- private lazy val awsEc2Client: Option[AmazonEC2] = credentials.map { credentials =>
- AmazonEC2ClientBuilder
- .standard()
- .withCredentials(credentials)
- .withRegion(conf.Configuration.aws.region)
- .build()
- }
-
def index(): Action[AnyContent] =
Action { implicit request =>
NoCache(Ok(views.html.troubleshooter(LoadBalancer.all.filter(_.testPath.isDefined))))
@@ -63,7 +49,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con
.getOrElse(Future.successful(TestFailed("Can find the appropriate loadbalancer")))
val viaWebsite = testOnGuardianSite(pathToTest, id)
val directToContentApi = testOnContentApi(pathToTest, id)
- val directToRouter = testOnRouter(pathToTest, id)
val directToPreviewContentApi = testOnPreviewContentApi(pathToTest, id)
val viaPreviewWebsite = testOnPreviewSite(pathToTest, id)
@@ -74,7 +59,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con
Seq(
directToContentApi,
directToLoadBalancer,
- directToRouter,
viaWebsite,
directToPreviewContentApi,
viaPreviewWebsite,
@@ -85,55 +69,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con
}
}
- private def testOnRouter(testPath: String, id: String): Future[EndpointStatus] = {
-
- def fetchWithRouterUrl(url: String) = {
- val result = httpGet("Can fetch directly from Router load balancer", s"http://$url$testPath")
- result.map { result =>
- if (result.isOk)
- result
- else
- TestFailed(
- result.name,
- result.messages :+
- "NOTE: if hitting the Router you MUST set Host header to 'www.theguardian.com' or else you will get '403 Forbidden'": _*,
- )
- }
- }
-
- val routerUrl = if (appContext.environment.mode == Mode.Prod) {
- // Workaround in PROD:
- // Getting the private dns of one of the router instances because
- // the Router ELB can only be accessed via its public IP/DNS from Fastly or Guardian VPN/office, not from an Admin instance
- // However Admin instances can access router instances via private IPs
- // This is of course not very fast since it has to make a call to AWS API before to fetch the url
- // but the troubleshooter is an admin only tool
- val tagsAsFilters = Map(
- "Stack" -> "frontend",
- "App" -> "router",
- "Stage" -> "PROD",
- ).map { case (name, value) =>
- new Filter("tag:" + name).withValues(value)
- }.asJavaCollection
- val instancesDnsName: Seq[String] = awsEc2Client
- .map(
- _.describeInstances(new DescribeInstancesRequest().withFilters(tagsAsFilters)).getReservations.asScala
- .flatMap(_.getInstances.asScala)
- .map(_.getPrivateDnsName),
- )
- .toSeq
- .flatten
- Random.shuffle(instancesDnsName).headOption
- } else {
- LoadBalancer("frontend-router").flatMap(_.url)
- }
-
- routerUrl
- .map(fetchWithRouterUrl)
- .getOrElse(Future.successful(TestFailed("Can get Frontend router url")))
-
- }
-
private def testOnLoadBalancer(
thisLoadBalancer: LoadBalancer,
testPath: String,
diff --git a/admin/app/controllers/admin/commercial/DashboardRenderer.scala b/admin/app/controllers/admin/commercial/DashboardRenderer.scala
deleted file mode 100644
index 10cd82ae1546..000000000000
--- a/admin/app/controllers/admin/commercial/DashboardRenderer.scala
+++ /dev/null
@@ -1,55 +0,0 @@
-package controllers.admin.commercial
-
-import java.util.Locale
-
-import jobs.CommercialDfpReporting
-import jobs.CommercialDfpReporting.DfpReportRow
-import model.{ApplicationContext, NoCache}
-import play.api.mvc._
-
-object DashboardRenderer extends Results {
-
- def renderDashboard(testName: String, dashboardTitle: String, controlColour: String, variantColour: String)(implicit
- request: RequestHeader,
- context: ApplicationContext,
- ): Result = {
- val maybeData = for {
- reportId <- CommercialDfpReporting.reportMappings.get(CommercialDfpReporting.teamKPIReport)
- report: Seq[DfpReportRow] <- CommercialDfpReporting.getReport(reportId)
- } yield {
- val keyValueRows: Seq[KeyValueRevenueRow] = report.flatMap { row =>
- val fields = row.fields
- for {
- customCriteria: String <- fields.lift(0)
- customTargetingId: String <- fields.lift(1)
- totalImpressions: Int <- fields.lift(2).map(_.toInt)
- totalAverageECPM: Double <- fields.lift(3).map(_.toDouble / 1000000.0d) // convert DFP micropounds to pounds
- } yield KeyValueRevenueRow(customCriteria, customTargetingId, totalImpressions, totalAverageECPM)
- }
-
- keyValueRows
- }
-
- val abTestRows = maybeData.getOrElse(Seq.empty)
-
- val controlDataRow = abTestRows.find(_.customCriteria.startsWith(s"ab=${testName}Control"))
- val variantDataRow = abTestRows.find(_.customCriteria.startsWith(s"ab=${testName}Variant"))
-
- val integerFormatter = java.text.NumberFormat.getIntegerInstance
- val currencyFormatter = java.text.NumberFormat.getCurrencyInstance(Locale.UK)
-
- NoCache(
- Ok(
- views.html.commercial.revenueDashboard(
- controlDataRow,
- variantDataRow,
- integerFormatter,
- currencyFormatter,
- dashboardTitle,
- controlColour,
- variantColour,
- ),
- ),
- )
- }
-}
diff --git a/admin/app/controllers/admin/commercial/DfpDataController.scala b/admin/app/controllers/admin/commercial/DfpDataController.scala
deleted file mode 100644
index fd209d834046..000000000000
--- a/admin/app/controllers/admin/commercial/DfpDataController.scala
+++ /dev/null
@@ -1,25 +0,0 @@
-package controllers.admin.commercial
-
-import common.ImplicitControllerExecutionContext
-import dfp.DfpDataCacheJob
-import model.{ApplicationContext, NoCache}
-import play.api.mvc._
-
-class DfpDataController(val controllerComponents: ControllerComponents, dfpDataCacheJob: DfpDataCacheJob)(implicit
- context: ApplicationContext,
-) extends BaseController
- with ImplicitControllerExecutionContext {
-
- def renderCacheFlushPage(): Action[AnyContent] =
- Action { implicit request =>
- NoCache(Ok(views.html.commercial.dfpFlush()))
- }
-
- def flushCache(): Action[AnyContent] =
- Action { implicit request =>
- dfpDataCacheJob.refreshAllDfpData()
- NoCache(Redirect(routes.DfpDataController.renderCacheFlushPage()))
- .flashing("triggered" -> "true")
- }
-
-}
diff --git a/admin/app/controllers/admin/commercial/TakeoverWithEmptyMPUsController.scala b/admin/app/controllers/admin/commercial/TakeoverWithEmptyMPUsController.scala
deleted file mode 100644
index 9aeee38eb418..000000000000
--- a/admin/app/controllers/admin/commercial/TakeoverWithEmptyMPUsController.scala
+++ /dev/null
@@ -1,43 +0,0 @@
-package controllers.admin.commercial
-
-import common.dfp.TakeoverWithEmptyMPUs
-import model.{ApplicationContext, NoCache}
-import play.api.i18n.I18nSupport
-import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
-
-class TakeoverWithEmptyMPUsController(val controllerComponents: ControllerComponents)(implicit
- context: ApplicationContext,
-) extends BaseController
- with I18nSupport {
-
- def viewList(): Action[AnyContent] =
- Action { implicit request =>
- NoCache(Ok(views.html.commercial.takeoverWithEmptyMPUs(TakeoverWithEmptyMPUs.fetchSorted())))
- }
-
- def viewForm(): Action[AnyContent] =
- Action { implicit request =>
- NoCache(Ok(views.html.commercial.takeoverWithEmptyMPUsCreate(TakeoverWithEmptyMPUs.form)))
- }
-
- def create(): Action[AnyContent] =
- Action { implicit request =>
- TakeoverWithEmptyMPUs.form
- .bindFromRequest()
- .fold(
- formWithErrors => {
- NoCache(BadRequest(views.html.commercial.takeoverWithEmptyMPUsCreate(formWithErrors)))
- },
- takeover => {
- TakeoverWithEmptyMPUs.create(takeover)
- NoCache(Redirect(routes.TakeoverWithEmptyMPUsController.viewList()))
- },
- )
- }
-
- def remove(url: String): Action[AnyContent] =
- Action { implicit request =>
- TakeoverWithEmptyMPUs.remove(url)
- NoCache(Redirect(routes.TakeoverWithEmptyMPUsController.viewList()))
- }
-}
diff --git a/admin/app/controllers/metrics/FastlyController.scala b/admin/app/controllers/metrics/FastlyController.scala
deleted file mode 100644
index 7e61997f1511..000000000000
--- a/admin/app/controllers/metrics/FastlyController.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package controllers.admin
-
-import common.{ImplicitControllerExecutionContext, GuLogging}
-import model.ApplicationContext
-import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
-import tools.CloudWatch
-
-class FastlyController(val controllerComponents: ControllerComponents)(implicit context: ApplicationContext)
- extends BaseController
- with GuLogging
- with ImplicitControllerExecutionContext {
- def renderFastly(): Action[AnyContent] =
- Action.async { implicit request =>
- for {
- errors <- CloudWatch.fastlyErrors()
- statistics <- CloudWatch.fastlyHitMissStatistics()
- } yield Ok(views.html.lineCharts(errors ++ statistics))
- }
-}
diff --git a/admin/app/controllers/metrics/MetricsController.scala b/admin/app/controllers/metrics/MetricsController.scala
index ad21b7c69c0a..d856ab5fdbd4 100644
--- a/admin/app/controllers/metrics/MetricsController.scala
+++ b/admin/app/controllers/metrics/MetricsController.scala
@@ -18,18 +18,11 @@ class MetricsController(
lazy val stage = Configuration.environment.stage.toUpperCase
- def renderLoadBalancers(): Action[AnyContent] =
- Action.async { implicit request =>
- for {
- graphs <- CloudWatch.dualOkLatencyFullStack()
- } yield NoCache(Ok(views.html.lineCharts(graphs)))
- }
-
def renderErrors(): Action[AnyContent] =
Action.async { implicit request =>
for {
- errors4xx <- HttpErrors.global4XX()
- errors5xx <- HttpErrors.global5XX()
+ errors4xx <- HttpErrors.legacyElb4XXs()
+ errors5xx <- HttpErrors.legacyElb5XXs()
} yield NoCache(Ok(views.html.lineCharts(Seq(errors4xx, errors5xx))))
}
@@ -46,35 +39,4 @@ class MetricsController(
httpErrors <- HttpErrors.errors()
} yield NoCache(Ok(views.html.lineCharts(httpErrors)))
}
-
- def renderGooglebot404s(): Action[AnyContent] =
- Action.async { implicit request =>
- for {
- googleBot404s <- HttpErrors.googlebot404s()
- } yield NoCache(Ok(views.html.lineCharts(googleBot404s, Some("GoogleBot 404s"))))
- }
-
- def renderAfg(): Action[AnyContent] =
- Action.async { implicit request =>
- wsClient.url("https://s3-eu-west-1.amazonaws.com/aws-frontend-metrics/frequency/index.html").get() map {
- response =>
- NoCache(Ok(views.html.afg(response.body)))
- }
- }
-
- def renderBundleVisualization(): Action[AnyContent] =
- Action { implicit request =>
- NoCache(SeeOther(Static("javascripts/webpack-stats.html")))
- }
-
- def renderBundleAnalyzer(): Action[AnyContent] =
- Action { implicit request =>
- NoCache(SeeOther(Static("javascripts/bundle-analyzer-report.html")))
- }
-
- private def toPercentage(graph: AwsLineChart) =
- graph.dataset
- .map(_.values)
- .collect { case Seq(saw, clicked) => if (saw == 0) 0.0 else clicked / saw * 100 }
-
}
diff --git a/admin/app/dfp/AdUnitAgent.scala b/admin/app/dfp/AdUnitAgent.scala
deleted file mode 100644
index 956bd070bbfd..000000000000
--- a/admin/app/dfp/AdUnitAgent.scala
+++ /dev/null
@@ -1,79 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import common.dfp.GuAdUnit
-import conf.Configuration
-import ApiHelper.toSeq
-import concurrent.BlockingOperations
-
-import scala.util.Try
-
-class AdUnitAgent(val blockingOperations: BlockingOperations) extends DataAgent[String, GuAdUnit] {
-
- override def loadFreshData(): Try[Map[String, GuAdUnit]] =
- Try {
- val maybeData = for (session <- SessionWrapper()) yield {
-
- val statementBuilder = new StatementBuilder()
-
- val dfpAdUnits = session.adUnits(statementBuilder)
-
- val networkRootId = session.getRootAdUnitId
- lazy val guardianRootName = Configuration.commercial.dfpAdUnitGuRoot
-
- val runOfNetwork = dfpAdUnits
- .find(_.getId == networkRootId)
- .map(networkAdUnit => {
- val id = networkAdUnit.getId
- id -> GuAdUnit(id = id, path = Nil, status = networkAdUnit.getStatus.getValue)
- })
- .toSeq
-
- val rootAndDescendantAdUnits = dfpAdUnits filter { adUnit =>
- Option(adUnit.getParentPath) exists { path =>
- val isGuRoot = path.length == 1 && adUnit.getName == guardianRootName
- val isDescendantOfRoot = path.length > 1 && path(1).getName == guardianRootName
- isGuRoot || isDescendantOfRoot
- }
- }
-
- val adUnits = rootAndDescendantAdUnits.map { adUnit =>
- val id = adUnit.getId
- val path = toSeq(adUnit.getParentPath).tail.map(_.getName) :+ adUnit.getName
- id -> GuAdUnit(id, path, adUnit.getStatus.getValue)
- }
-
- (adUnits ++ runOfNetwork).toMap
- }
-
- maybeData getOrElse Map.empty
- }
-
-}
-
-class AdUnitService(adUnitAgent: AdUnitAgent) {
-
- // Retrieves the ad unit object if the id matches and the ad unit is active.
- def activeAdUnit(adUnitId: String): Option[GuAdUnit] = {
- adUnitAgent.get.data.get(adUnitId).collect {
- case adUnit if adUnit.isActive => adUnit
- }
- }
-
- def archivedAdUnit(adUnitId: String): Option[GuAdUnit] = {
- adUnitAgent.get.data.get(adUnitId).collect {
- case adUnit if adUnit.isArchived => adUnit
- }
- }
-
- def isArchivedAdUnit(adUnitId: String): Boolean = archivedAdUnit(adUnitId).isDefined
-
- def inactiveAdUnit(adUnitId: String): Option[GuAdUnit] = {
- adUnitAgent.get.data.get(adUnitId).collect {
- case adUnit if adUnit.isInactive => adUnit
- }
- }
-
- def isInactiveAdUnit(adUnitId: String): Boolean = inactiveAdUnit(adUnitId).isDefined
-
-}
diff --git a/admin/app/dfp/AdvertiserAgent.scala b/admin/app/dfp/AdvertiserAgent.scala
deleted file mode 100644
index cf170a11b9e4..000000000000
--- a/admin/app/dfp/AdvertiserAgent.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package dfp
-
-import common.Box
-import common.dfp.GuAdvertiser
-import concurrent.BlockingOperations
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class AdvertiserAgent(blockingOperations: BlockingOperations, dfpApi: DfpApi) {
-
- private lazy val cache = Box(Seq.empty[GuAdvertiser])
-
- def refresh()(implicit executionContext: ExecutionContext): Future[Seq[GuAdvertiser]] = {
- blockingOperations.executeBlocking(dfpApi.getAllAdvertisers).flatMap { freshData =>
- cache.alter(if (freshData.nonEmpty) freshData else _)
- }
- }
-
- def get: Seq[GuAdvertiser] = cache.get()
-}
diff --git a/admin/app/dfp/ApiHelper.scala b/admin/app/dfp/ApiHelper.scala
deleted file mode 100644
index 9458356e8211..000000000000
--- a/admin/app/dfp/ApiHelper.scala
+++ /dev/null
@@ -1,41 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.v202405._
-import common.GuLogging
-import org.joda.time.{DateTime => JodaDateTime, DateTimeZone}
-
-private[dfp] object ApiHelper extends GuLogging {
-
- def isPageSkin(dfpLineItem: LineItem): Boolean = {
-
- def hasA1x1Pixel(placeholders: Array[CreativePlaceholder]): Boolean = {
- placeholders.exists {
- _.getCompanions.exists { companion =>
- val size = companion.getSize
- size.getWidth == 1 && size.getHeight == 1
- }
- }
- }
-
- (dfpLineItem.getRoadblockingType == RoadblockingType.CREATIVE_SET) &&
- hasA1x1Pixel(dfpLineItem.getCreativePlaceholders)
- }
-
- def toJodaTime(time: DateTime): JodaDateTime = {
- val date = time.getDate
- new JodaDateTime(
- date.getYear,
- date.getMonth,
- date.getDay,
- time.getHour,
- time.getMinute,
- time.getSecond,
- DateTimeZone.forID(time.getTimeZoneId),
- )
- }
-
- def toSeq[A](as: Array[A]): Seq[A] = Option(as) map (_.toSeq) getOrElse Nil
-
- // noinspection IfElseToOption
- def optJavaInt(i: java.lang.Integer): Option[Int] = if (i == null) None else Some(i)
-}
diff --git a/admin/app/dfp/CreativeTemplateAgent.scala b/admin/app/dfp/CreativeTemplateAgent.scala
deleted file mode 100644
index 75ea488aaeea..000000000000
--- a/admin/app/dfp/CreativeTemplateAgent.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package dfp
-
-import common.Box
-import common.dfp.GuCreativeTemplate
-import concurrent.BlockingOperations
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class CreativeTemplateAgent(blockingOperations: BlockingOperations, dfpApi: DfpApi) {
-
- private lazy val cache = Box(Seq.empty[GuCreativeTemplate])
-
- def refresh()(implicit executionContext: ExecutionContext): Future[Seq[GuCreativeTemplate]] = {
- blockingOperations.executeBlocking(dfpApi.readActiveCreativeTemplates()).flatMap { freshData =>
- cache.alter(if (freshData.nonEmpty) freshData else _)
- }
- }
-
- def get: Seq[GuCreativeTemplate] = cache.get()
-}
diff --git a/admin/app/dfp/CustomFieldAgent.scala b/admin/app/dfp/CustomFieldAgent.scala
deleted file mode 100644
index de35cbe0e6c9..000000000000
--- a/admin/app/dfp/CustomFieldAgent.scala
+++ /dev/null
@@ -1,34 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import com.google.api.ads.admanager.axis.v202405.{CustomFieldValue, LineItem, TextValue}
-import common.dfp.GuCustomField
-import concurrent.BlockingOperations
-
-import scala.util.Try
-
-class CustomFieldAgent(val blockingOperations: BlockingOperations) extends DataAgent[String, GuCustomField] {
-
- override def loadFreshData(): Try[Map[String, GuCustomField]] =
- Try {
- getAllCustomFields.map(f => f.name -> f).toMap
- }
-
- private def getAllCustomFields: Seq[GuCustomField] = {
- val stmtBuilder = new StatementBuilder()
- DfpApi.withDfpSession(_.customFields(stmtBuilder).map(DataMapper.toGuCustomField))
- }
-}
-
-class CustomFieldService(customFieldAgent: CustomFieldAgent) {
-
- def sponsor(lineItem: LineItem): Option[String] =
- for {
- sponsorField <- customFieldAgent.get.data.get("Sponsor")
- customFieldValues <- Option(lineItem.getCustomFieldValues)
- sponsor <- customFieldValues.collect {
- case fieldValue: CustomFieldValue if fieldValue.getCustomFieldId == sponsorField.id =>
- fieldValue.getValue.asInstanceOf[TextValue].getValue
- }.headOption
- } yield sponsor
-}
diff --git a/admin/app/dfp/CustomTargetingAgent.scala b/admin/app/dfp/CustomTargetingAgent.scala
deleted file mode 100644
index 601077bb280e..000000000000
--- a/admin/app/dfp/CustomTargetingAgent.scala
+++ /dev/null
@@ -1,76 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import com.google.api.ads.admanager.axis.v202405.{CustomTargetingKey, CustomTargetingValue}
-import common.GuLogging
-import common.dfp.{GuCustomTargeting, GuCustomTargetingValue}
-import concurrent.BlockingOperations
-
-import scala.util.Try
-
-class CustomTargetingAgent(val blockingOperations: BlockingOperations)
- extends DataAgent[Long, GuCustomTargeting]
- with GuLogging {
-
- def loadFreshData(): Try[Map[Long, GuCustomTargeting]] =
- Try {
- val maybeData = for (session <- SessionWrapper()) yield {
-
- val keys: Map[Long, CustomTargetingKey] =
- session.customTargetingKeys(new StatementBuilder()).map { key => key.getId.longValue -> key }.toMap
-
- val statementWithIds = new StatementBuilder()
- .where(s"customTargetingKeyId IN (${keys.keys.mkString(",")})")
-
- val valuesByKey: Map[Long, Seq[CustomTargetingValue]] =
- session.customTargetingValues(statementWithIds).groupBy { _.getCustomTargetingKeyId.longValue }
-
- valuesByKey flatMap { case (keyId: Long, values: Seq[CustomTargetingValue]) =>
- keys.get(keyId) map { key =>
- val guValues: Seq[GuCustomTargetingValue] = values map { value =>
- GuCustomTargetingValue(
- id = value.getId.longValue,
- name = value.getName,
- displayName = value.getDisplayName,
- )
- }
-
- keyId -> GuCustomTargeting(
- keyId = keyId,
- name = key.getName,
- displayName = key.getDisplayName,
- values = guValues,
- )
- }
- }
- }
-
- maybeData getOrElse Map.empty
- }
-}
-
-class CustomTargetingService(customTargetingAgent: CustomTargetingAgent) {
-
- def targetingKey(session: SessionWrapper)(keyId: Long): String = {
- lazy val fallback = {
- val stmtBuilder = new StatementBuilder()
- .where("id = :keyId")
- .withBindVariableValue("keyId", keyId)
- session.customTargetingKeys(stmtBuilder).headOption.map(_.getName).getOrElse("unknown")
- }
-
- customTargetingAgent.get.data get keyId map (_.name) getOrElse fallback
- }
-
- def targetingValue(session: SessionWrapper)(keyId: Long, valueId: Long): String = {
- lazy val fallback = {
- val stmtBuilder = new StatementBuilder()
- .where("customTargetingKeyId = :keyId AND id = :valueId")
- .withBindVariableValue("keyId", keyId)
- .withBindVariableValue("valueId", valueId)
- session.customTargetingValues(stmtBuilder).headOption.map(_.getName).getOrElse("unknown")
- }
-
- customTargetingAgent.get.data get keyId flatMap { _.values.find(_.id == valueId) } map (_.name) getOrElse fallback
- }
-}
diff --git a/admin/app/dfp/CustomTargetingKeyValueJob.scala b/admin/app/dfp/CustomTargetingKeyValueJob.scala
deleted file mode 100644
index 3f524a0dcd35..000000000000
--- a/admin/app/dfp/CustomTargetingKeyValueJob.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package dfp
-
-import play.api.libs.json._
-import tools.Store
-
-import scala.concurrent.{ExecutionContext, Future}
-
-// This object is run by the commercial lifecycle and writes a json S3 file that stores
-// key value mappings. In contrast, the CustomTargetingAgent is used to resolve key/value ids to string names.
-class CustomTargetingKeyValueJob(customTargetingAgent: CustomTargetingAgent) {
-
- def run()(implicit executionContext: ExecutionContext): Future[Unit] =
- Future {
- val customTargeting = customTargetingAgent.get.data.values
-
- if (customTargeting.nonEmpty) {
- Store.putDfpCustomTargetingKeyValues(Json.stringify(Json.toJson(customTargeting)))
- }
- }
-}
diff --git a/admin/app/dfp/DataAgent.scala b/admin/app/dfp/DataAgent.scala
deleted file mode 100644
index 4627fbd83137..000000000000
--- a/admin/app/dfp/DataAgent.scala
+++ /dev/null
@@ -1,42 +0,0 @@
-package dfp
-
-import common.{Box, GuLogging}
-import concurrent.BlockingOperations
-
-import scala.concurrent.{ExecutionContext, Future}
-import scala.util.{Failure, Success, Try}
-
-trait DataAgent[K, V] extends GuLogging with implicits.Strings {
-
- private val initialCache: DataCache[K, V] = DataCache(Map.empty[K, V])
- private lazy val cache = Box(initialCache)
-
- def blockingOperations: BlockingOperations
-
- def loadFreshData(): Try[Map[K, V]]
-
- def refresh()(implicit executionContext: ExecutionContext): Future[DataCache[K, V]] = {
- log.info("Refreshing data cache")
- val start = System.currentTimeMillis
- blockingOperations.executeBlocking(loadFreshData()).map(freshIfExists(start))
- }
-
- private def freshIfExists(start: Long)(tryFreshData: Try[Map[K, V]]): DataCache[K, V] = {
- tryFreshData match {
- case Success(freshData) if freshData.nonEmpty =>
- val duration = System.currentTimeMillis - start
- log.info(s"Loading DFP data (${freshData.keys.size} items}) took $duration ms")
- val freshCache = DataCache(freshData)
- cache.send(freshCache)
- freshCache
- case Success(_) =>
- log.error("No fresh data loaded so keeping old data")
- cache.get()
- case Failure(e) =>
- log.error("Loading of fresh data has failed.", e)
- cache.get()
- }
- }
-
- def get: DataCache[K, V] = cache.get()
-}
diff --git a/admin/app/dfp/DataCache.scala b/admin/app/dfp/DataCache.scala
deleted file mode 100644
index dfce89a1f42d..000000000000
--- a/admin/app/dfp/DataCache.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-package dfp
-
-import java.time.LocalDateTime
-
-case class DataCache[K, V](timestamp: LocalDateTime, data: Map[K, V])
-
-object DataCache {
- def apply[K, V](data: Map[K, V]): DataCache[K, V] = DataCache(LocalDateTime.now(), data)
-}
diff --git a/admin/app/dfp/DataMapper.scala b/admin/app/dfp/DataMapper.scala
deleted file mode 100644
index 8fba37698855..000000000000
--- a/admin/app/dfp/DataMapper.scala
+++ /dev/null
@@ -1,228 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.v202405._
-import common.dfp._
-import dfp.ApiHelper.{isPageSkin, optJavaInt, toJodaTime, toSeq}
-
-// These mapping functions use libraries that are only available in admin to create common DFP data models.
-class DataMapper(
- adUnitService: AdUnitService,
- placementService: dfp.PlacementService,
- customTargetingService: dfp.CustomTargetingService,
- customFieldService: dfp.CustomFieldService,
-) {
-
- def toGuAdUnit(dfpAdUnit: AdUnit): GuAdUnit = {
- val ancestors = toSeq(dfpAdUnit.getParentPath)
- val ancestorNames = if (ancestors.isEmpty) Nil else ancestors.tail.map(_.getName)
- GuAdUnit(dfpAdUnit.getId, ancestorNames :+ dfpAdUnit.getName, dfpAdUnit.getStatus.getValue)
- }
-
- private def toGuTargeting(session: SessionWrapper)(dfpTargeting: Targeting): GuTargeting = {
-
- def toIncludedGuAdUnits(inventoryTargeting: InventoryTargeting): Seq[GuAdUnit] = {
-
- // noinspection MapFlatten
- val directAdUnits =
- toSeq(inventoryTargeting.getTargetedAdUnits).map(_.getAdUnitId).map(adUnitService.activeAdUnit).flatten
-
- // noinspection MapFlatten
- val adUnitsDerivedFromPlacements = {
- toSeq(inventoryTargeting.getTargetedPlacementIds).map(placementService.placementAdUnitIds(session)).flatten
- }
-
- (directAdUnits ++ adUnitsDerivedFromPlacements).sortBy(_.path.mkString).distinct
- }
-
- def toExcludedGuAdUnits(inventoryTargeting: InventoryTargeting): Seq[GuAdUnit] = {
- toSeq(inventoryTargeting.getExcludedAdUnits).map(_.getAdUnitId).flatMap(adUnitService.activeAdUnit)
- }
-
- def toCustomTargetSets(criteriaSets: CustomCriteriaSet): Seq[CustomTargetSet] = {
-
- def toCustomTargetSet(criteria: CustomCriteriaSet): CustomTargetSet = {
-
- def toCustomTarget(criterion: CustomCriteria) =
- CustomTarget(
- customTargetingService.targetingKey(session)(criterion.getKeyId),
- criterion.getOperator.getValue,
- criterion.getValueIds.toSeq map (valueId =>
- customTargetingService.targetingValue(session)(criterion.getKeyId, valueId)
- ),
- )
-
- val targets = criteria.getChildren collect { case criterion: CustomCriteria =>
- criterion
- } map toCustomTarget
- CustomTargetSet(criteria.getLogicalOperator.getValue, targets.toIndexedSeq)
- }
-
- criteriaSets.getChildren
- .collect { case criteria: CustomCriteriaSet =>
- criteria
- }
- .map(toCustomTargetSet)
- .toSeq
- }
-
- def geoTargets(locations: GeoTargeting => Array[Location]): Seq[GeoTarget] = {
-
- def toGeoTarget(dfpLocation: Location) =
- GeoTarget(
- dfpLocation.getId,
- optJavaInt(dfpLocation.getCanonicalParentId),
- dfpLocation.getType,
- dfpLocation.getDisplayName,
- )
-
- Option(dfpTargeting.getGeoTargeting) flatMap { geoTargeting =>
- Option(locations(geoTargeting)) map (_.map(toGeoTarget).toSeq)
- } getOrElse Nil
- }
- val geoTargetsIncluded = geoTargets(_.getTargetedLocations)
- val geoTargetsExcluded = geoTargets(_.getExcludedLocations)
-
- GuTargeting(
- adUnitsIncluded = Option(dfpTargeting.getInventoryTargeting) map toIncludedGuAdUnits getOrElse Nil,
- adUnitsExcluded = Option(dfpTargeting.getInventoryTargeting) map toExcludedGuAdUnits getOrElse Nil,
- geoTargetsIncluded,
- geoTargetsExcluded,
- customTargetSets = Option(dfpTargeting.getCustomTargeting) map toCustomTargetSets getOrElse Nil,
- )
- }
-
- private def toGuCreativePlaceholders(session: SessionWrapper)(dfpLineItem: LineItem): Seq[GuCreativePlaceholder] = {
-
- def creativeTargeting(name: String): Option[GuTargeting] = {
- for (targeting <- toSeq(dfpLineItem.getCreativeTargetings) find (_.getName == name)) yield {
- toGuTargeting(session)(targeting.getTargeting)
- }
- }
-
- val placeholders = for (placeholder <- dfpLineItem.getCreativePlaceholders) yield {
- val size = placeholder.getSize
- val targeting = Option(placeholder.getTargetingName).flatMap(creativeTargeting)
- GuCreativePlaceholder(AdSize(size.getWidth, size.getHeight), targeting)
- }
-
- placeholders.toIndexedSeq sortBy { placeholder =>
- val size = placeholder.size
- (size.width, size.height)
- }
- }
-
- def toGuLineItem(session: SessionWrapper)(dfpLineItem: LineItem): GuLineItem =
- GuLineItem(
- id = dfpLineItem.getId,
- orderId = dfpLineItem.getOrderId,
- name = dfpLineItem.getName,
- lineItemType = GuLineItemType.fromDFPLineItemType(dfpLineItem.getLineItemType.getValue),
- startTime = toJodaTime(dfpLineItem.getStartDateTime),
- endTime = {
- if (dfpLineItem.getUnlimitedEndDateTime) None
- else Some(toJodaTime(dfpLineItem.getEndDateTime))
- },
- isPageSkin = isPageSkin(dfpLineItem),
- sponsor = customFieldService.sponsor(dfpLineItem),
- creativePlaceholders = toGuCreativePlaceholders(session)(
- dfpLineItem,
- ),
- targeting = toGuTargeting(session)(dfpLineItem.getTargeting),
- status = dfpLineItem.getStatus.toString,
- costType = dfpLineItem.getCostType.toString,
- lastModified = toJodaTime(dfpLineItem.getLastModifiedDateTime),
- )
-
- def toGuCreativeTemplate(dfpCreativeTemplate: CreativeTemplate): GuCreativeTemplate = {
-
- def toParameter(param: CreativeTemplateVariable) =
- GuCreativeTemplateParameter(
- parameterType = param.getClass.getSimpleName.stripSuffix("CreativeTemplateVariable"),
- label = param.getLabel,
- isRequired = param.getIsRequired,
- description = Option(param.getDescription),
- )
-
- GuCreativeTemplate(
- id = dfpCreativeTemplate.getId,
- name = dfpCreativeTemplate.getName,
- description = dfpCreativeTemplate.getDescription,
- parameters = Option(dfpCreativeTemplate.getVariables)
- .map { params =>
- (params map toParameter).toSeq
- }
- .getOrElse(Nil),
- snippet = dfpCreativeTemplate.getSnippet,
- creatives = Nil,
- isNative = dfpCreativeTemplate.getIsNativeEligible,
- )
- }
-
- def toGuTemplateCreative(dfpCreative: TemplateCreative): GuCreative = {
-
- def arg(variableValue: BaseCreativeTemplateVariableValue): (String, String) = {
- val exampleAssetUrl =
- "https://tpc.googlesyndication.com/pagead/imgad?id=CICAgKCT8L-fJRABGAEyCCXl5VJTW9F8"
- val argValue = variableValue match {
- case s: StringCreativeTemplateVariableValue =>
- Option(s.getValue) getOrElse ""
- case u: UrlCreativeTemplateVariableValue =>
- Option(u.getValue) getOrElse ""
- case _: AssetCreativeTemplateVariableValue =>
- exampleAssetUrl
- case other => "???"
- }
- variableValue.getUniqueName -> argValue
- }
-
- GuCreative(
- id = dfpCreative.getId,
- name = dfpCreative.getName,
- lastModified = toJodaTime(dfpCreative.getLastModifiedDateTime),
- args = Option(dfpCreative.getCreativeTemplateVariableValues).map(_.map(arg)).map(_.toMap).getOrElse(Map.empty),
- templateId = Some(dfpCreative.getCreativeTemplateId),
- snippet = None,
- previewUrl = Some(dfpCreative.getPreviewUrl),
- )
- }
-
- def toGuOrder(dfpOrder: Order): GuOrder = {
- GuOrder(
- id = dfpOrder.getId,
- name = dfpOrder.getName,
- advertiserId = dfpOrder.getAdvertiserId,
- )
- }
- def toGuAdvertiser(dfpCompany: Company): GuAdvertiser = {
-
- GuAdvertiser(
- id = dfpCompany.getId,
- name = dfpCompany.getName,
- )
- }
-}
-
-object DataMapper {
- def toGuCustomFieldOption(option: CustomFieldOption): GuCustomFieldOption =
- GuCustomFieldOption(option.getId, option.getDisplayName)
-
- def toGuCustomField(dfpCustomField: CustomField): GuCustomField = {
- val options: List[GuCustomFieldOption] = {
- dfpCustomField match {
- case dropdown: DropDownCustomField => dropdown.getOptions.toList
- case _ => Nil
- }
- } map toGuCustomFieldOption
-
- GuCustomField(
- dfpCustomField.getId,
- dfpCustomField.getName,
- dfpCustomField.getDescription,
- dfpCustomField.getIsActive,
- dfpCustomField.getEntityType.getValue,
- dfpCustomField.getDataType.getValue,
- dfpCustomField.getVisibility.getValue,
- options,
- )
- }
-}
diff --git a/admin/app/dfp/DataValidation.scala b/admin/app/dfp/DataValidation.scala
deleted file mode 100644
index 31418bc2903e..000000000000
--- a/admin/app/dfp/DataValidation.scala
+++ /dev/null
@@ -1,27 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.v202405._
-import common.dfp._
-import dfp.ApiHelper.toSeq
-
-class DataValidation(adUnitService: AdUnitService) {
-
- def isGuLineItemValid(guLineItem: GuLineItem, dfpLineItem: LineItem): Boolean = {
-
- // Check that all the direct dfp ad units have been accounted for in the targeting.
- val guAdUnits = guLineItem.targeting.adUnitsIncluded
-
- val dfpAdUnitIds = Option(dfpLineItem.getTargeting.getInventoryTargeting)
- .map(inventoryTargeting => toSeq(inventoryTargeting.getTargetedAdUnits).map(_.getAdUnitId()))
- .getOrElse(Nil)
-
- // The validation should not account for inactive or archived ad units.
- val activeDfpAdUnitIds = dfpAdUnitIds.filterNot { adUnitId =>
- adUnitService.isArchivedAdUnit(adUnitId) || adUnitService.isInactiveAdUnit(adUnitId)
- }
-
- activeDfpAdUnitIds.forall(adUnitId => {
- guAdUnits.exists(_.id == adUnitId)
- })
- }
-}
diff --git a/admin/app/dfp/DfpAdUnitCacheJob.scala b/admin/app/dfp/DfpAdUnitCacheJob.scala
deleted file mode 100644
index 64567715abbf..000000000000
--- a/admin/app/dfp/DfpAdUnitCacheJob.scala
+++ /dev/null
@@ -1,43 +0,0 @@
-package dfp
-
-import common.{PekkoAsync, GuLogging}
-import conf.Configuration
-import tools.Store
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class DfpAdUnitCacher(val rootAdUnit: Any, val filename: String, dfpApi: DfpApi) extends GuLogging {
-
- def run(pekkoAsync: PekkoAsync)(implicit executionContext: ExecutionContext): Future[Unit] =
- Future {
- pekkoAsync {
- val adUnits = dfpApi.readActiveAdUnits(rootAdUnit.toString)
- if (adUnits.nonEmpty) {
- val rows = adUnits.map(adUnit => s"${adUnit.id},${adUnit.path.mkString(",")}")
- val list = rows.mkString("\n")
- Store.putDfpAdUnitList(filename, list)
- }
- }
- }
-}
-
-class DfpAdUnitCacheJob(dfpApi: DfpApi)
- extends DfpAdUnitCacher(
- Configuration.commercial.dfpAdUnitGuRoot,
- Configuration.commercial.dfpActiveAdUnitListKey,
- dfpApi,
- )
-
-class DfpFacebookIaAdUnitCacheJob(dfpApi: DfpApi)
- extends DfpAdUnitCacher(
- Configuration.commercial.dfpFacebookIaAdUnitRoot,
- Configuration.commercial.dfpFacebookIaAdUnitListKey,
- dfpApi,
- )
-
-class DfpMobileAppAdUnitCacheJob(dfpApi: DfpApi)
- extends DfpAdUnitCacher(
- Configuration.commercial.dfpMobileAppsAdUnitRoot,
- Configuration.commercial.dfpMobileAppsAdUnitListKey,
- dfpApi,
- )
diff --git a/admin/app/dfp/DfpApi.scala b/admin/app/dfp/DfpApi.scala
deleted file mode 100644
index 1d8aa714baee..000000000000
--- a/admin/app/dfp/DfpApi.scala
+++ /dev/null
@@ -1,200 +0,0 @@
-package dfp
-
-// StatementBuilder query language is PQL defined here:
-// https://developers.google.com/ad-manager/api/pqlreference
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import com.google.api.ads.admanager.axis.v202405._
-import com.madgag.scala.collection.decorators.MapDecorator
-import common.GuLogging
-import common.dfp._
-import org.joda.time.DateTime
-
-case class DfpLineItems(validItems: Seq[GuLineItem], invalidItems: Seq[GuLineItem])
-
-class DfpApi(dataMapper: DataMapper, dataValidation: DataValidation) extends GuLogging {
- import dfp.DfpApi._
-
- private def readLineItems(
- stmtBuilder: StatementBuilder,
- postFilter: LineItem => Boolean = _ => true,
- ): DfpLineItems = {
-
- val lineItems = withDfpSession(session => {
- session
- .lineItems(stmtBuilder)
- .filter(postFilter)
- .map(dfpLineItem => {
- (dataMapper.toGuLineItem(session)(dfpLineItem), dfpLineItem)
- })
- })
-
- // Note that this will call getTargeting on each
- // item, potentially making one API call per lineitem.
- val validatedLineItems = lineItems
- .groupBy(Function.tupled(dataValidation.isGuLineItemValid))
- .mapV(_.map(_._1))
-
- DfpLineItems(
- validItems = validatedLineItems.getOrElse(true, Nil),
- invalidItems = validatedLineItems.getOrElse(false, Nil),
- )
- }
-
- def getAllOrders: Seq[GuOrder] = {
- val stmtBuilder = new StatementBuilder()
- withDfpSession(_.orders(stmtBuilder).map(dataMapper.toGuOrder))
- }
-
- def getAllAdvertisers: Seq[GuAdvertiser] = {
- val stmtBuilder = new StatementBuilder()
- .where("type = :type")
- .withBindVariableValue("type", CompanyType.ADVERTISER.toString)
- .orderBy("id ASC")
-
- withDfpSession(_.companies(stmtBuilder).map(dataMapper.toGuAdvertiser))
- }
-
- def readCurrentLineItems: DfpLineItems = {
-
- val stmtBuilder = new StatementBuilder()
- .where("status = :readyStatus OR status = :deliveringStatus")
- .withBindVariableValue("readyStatus", ComputedStatus.READY.toString)
- .withBindVariableValue("deliveringStatus", ComputedStatus.DELIVERING.toString)
- .orderBy("id ASC")
-
- readLineItems(stmtBuilder)
- }
-
- def readLineItemsModifiedSince(threshold: DateTime): DfpLineItems = {
-
- val stmtBuilder = new StatementBuilder()
- .where("lastModifiedDateTime > :threshold")
- .withBindVariableValue("threshold", threshold.getMillis)
-
- readLineItems(stmtBuilder)
- }
-
- def readSponsorshipLineItemIds(): Seq[Long] = {
-
- // The advertiser ID for "Amazon Transparent Ad Marketplace"
- val amazonAdvertiserId = 4751525411L
-
- val stmtBuilder = new StatementBuilder()
- .where(
- "(status = :readyStatus OR status = :deliveringStatus) AND lineItemType = :sponsorshipType AND advertiserId != :amazonAdvertiserId",
- )
- .withBindVariableValue("readyStatus", ComputedStatus.READY.toString)
- .withBindVariableValue("deliveringStatus", ComputedStatus.DELIVERING.toString)
- .withBindVariableValue("sponsorshipType", LineItemType.SPONSORSHIP.toString)
- .withBindVariableValue("amazonAdvertiserId", amazonAdvertiserId.toString)
- .orderBy("id ASC")
-
- // Lets avoid Prebid lineitems
- val IsPrebid = "(?i).*?prebid.*".r
-
- val lineItems = readLineItems(
- stmtBuilder,
- lineItem => {
- lineItem.getName match {
- case IsPrebid() => false
- case _ => true
- }
- },
- )
- (lineItems.validItems.map(_.id) ++ lineItems.invalidItems.map(_.id)).sorted
- }
-
- def readActiveCreativeTemplates(): Seq[GuCreativeTemplate] = {
-
- val stmtBuilder = new StatementBuilder()
- .where("status = :active and type = :userDefined")
- .withBindVariableValue("active", CreativeTemplateStatus._ACTIVE)
- .withBindVariableValue("userDefined", CreativeTemplateType._USER_DEFINED)
-
- withDfpSession {
- _.creativeTemplates(stmtBuilder) map dataMapper.toGuCreativeTemplate filterNot (_.isForApps)
- }
- }
-
- def readTemplateCreativesModifiedSince(threshold: DateTime): Seq[GuCreative] = {
-
- val stmtBuilder = new StatementBuilder()
- .where("lastModifiedDateTime > :threshold")
- .withBindVariableValue("threshold", threshold.getMillis)
-
- withDfpSession {
- _.creatives.get(stmtBuilder) collect { case creative: TemplateCreative =>
- creative
- } map dataMapper.toGuTemplateCreative
- }
- }
-
- private def readDescendantAdUnits(rootName: String, stmtBuilder: StatementBuilder): Seq[GuAdUnit] = {
- withDfpSession { session =>
- session.adUnits(stmtBuilder) filter { adUnit =>
- def isRoot(path: Array[AdUnitParent]) = path.length == 1 && adUnit.getName == rootName
- def isDescendant(path: Array[AdUnitParent]) = path.length > 1 && path(1).getName == rootName
-
- Option(adUnit.getParentPath) exists { path => isRoot(path) || isDescendant(path) }
- } map dataMapper.toGuAdUnit sortBy (_.id)
- }
- }
-
- def readActiveAdUnits(rootName: String): Seq[GuAdUnit] = {
-
- val stmtBuilder = new StatementBuilder()
- .where("status = :status")
- .withBindVariableValue("status", InventoryStatus._ACTIVE)
-
- readDescendantAdUnits(rootName, stmtBuilder)
- }
-
- def readSpecialAdUnits(rootName: String): Seq[(String, String)] = {
-
- val statementBuilder = new StatementBuilder()
- .where("status = :status")
- .where("explicitlyTargeted = :targeting")
- .withBindVariableValue("status", InventoryStatus._ACTIVE)
- .withBindVariableValue("targeting", true)
-
- readDescendantAdUnits(rootName, statementBuilder) map { adUnit =>
- (adUnit.id, adUnit.path.mkString("/"))
- } sortBy (_._2)
- }
-
- def getCreativeIds(lineItemId: Long): Seq[Long] = {
- val stmtBuilder = new StatementBuilder()
- .where("status = :status AND lineItemId = :lineItemId")
- .withBindVariableValue("status", LineItemCreativeAssociationStatus._ACTIVE)
- .withBindVariableValue("lineItemId", lineItemId)
-
- withDfpSession { session =>
- session.lineItemCreativeAssociations.get(stmtBuilder) map (id => Long2long(id.getCreativeId))
- }
- }
-
- def getPreviewUrl(lineItemId: Long, creativeId: Long, url: String): Option[String] =
- for {
- session <- SessionWrapper()
- previewUrl <- session.lineItemCreativeAssociations.getPreviewUrl(lineItemId, creativeId, url)
- } yield previewUrl
-
- def getReportQuery(reportId: Long): Option[ReportQuery] =
- for {
- session <- SessionWrapper()
- query <- session.getReportQuery(reportId)
- } yield query
-
- def runReportJob(report: ReportQuery): Seq[String] = {
- withDfpSession { session =>
- session.runReportJob(report)
- }
- }
-}
-
-object DfpApi {
- def withDfpSession[T](block: SessionWrapper => Seq[T]): Seq[T] = {
- val results = for (session <- SessionWrapper()) yield block(session)
- results getOrElse Nil
- }
-}
diff --git a/admin/app/dfp/DfpDataCacheJob.scala b/admin/app/dfp/DfpDataCacheJob.scala
deleted file mode 100644
index 4747744c9b04..000000000000
--- a/admin/app/dfp/DfpDataCacheJob.scala
+++ /dev/null
@@ -1,179 +0,0 @@
-package dfp
-
-import common.dfp._
-import common.GuLogging
-import org.joda.time.DateTime
-import play.api.libs.json.Json.{toJson, _}
-import tools.Store
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class DfpDataCacheJob(
- adUnitAgent: AdUnitAgent,
- customFieldAgent: CustomFieldAgent,
- customTargetingAgent: CustomTargetingAgent,
- placementAgent: PlacementAgent,
- dfpApi: DfpApi,
-) extends GuLogging {
-
- case class LineItemLoadSummary(validLineItems: Seq[GuLineItem], invalidLineItems: Seq[GuLineItem])
-
- def run()(implicit executionContext: ExecutionContext): Future[Unit] =
- Future {
- log.info("Refreshing data cache")
- val start = System.currentTimeMillis
- val data = loadLineItems()
- val sponsorshipLineItemIds = dfpApi.readSponsorshipLineItemIds()
- val currentLineItems = loadCurrentLineItems()
- val duration = System.currentTimeMillis - start
- log.info(s"Loading DFP data took $duration ms")
- write(data)
- Store.putNonRefreshableLineItemIds(sponsorshipLineItemIds)
- writeLiveBlogTopSponsorships(currentLineItems)
- writeSurveySponsorships(currentLineItems)
- }
-
- /*
- for initialization and total refresh of data,
- so would be used for first read and for emergency data update.
- */
- def refreshAllDfpData()(implicit executionContext: ExecutionContext): Unit = {
-
- for {
- _ <- adUnitAgent.refresh()
- _ <- customFieldAgent.refresh()
- _ <- customTargetingAgent.refresh()
- _ <- placementAgent.refresh()
- } {
- loadLineItems()
- }
- }
-
- private def loadCurrentLineItems(): DfpDataExtractor = {
- val currentLineItems = dfpApi.readCurrentLineItems
-
- val loadSummary = LineItemLoadSummary(
- validLineItems = currentLineItems.validItems,
- invalidLineItems = currentLineItems.invalidItems,
- )
-
- DfpDataExtractor(loadSummary.validLineItems, loadSummary.invalidLineItems)
- }
-
- private def loadLineItems(): DfpDataExtractor = {
-
- def fetchCachedLineItems(): DfpLineItems = {
- val lineItemReport = Store.getDfpLineItemsReport()
-
- DfpLineItems(validItems = lineItemReport.lineItems, invalidItems = lineItemReport.invalidLineItems)
- }
-
- val start = System.currentTimeMillis
-
- val loadSummary = loadLineItems(
- fetchCachedLineItems(),
- dfpApi.readLineItemsModifiedSince,
- dfpApi.readCurrentLineItems,
- )
-
- val loadDuration = System.currentTimeMillis - start
- log.info(s"Loading line items took $loadDuration ms")
-
- DfpDataExtractor(loadSummary.validLineItems, loadSummary.invalidLineItems)
- }
-
- def report(ids: Iterable[Long]): String = if (ids.isEmpty) "None" else ids.mkString(", ")
-
- def loadLineItems(
- cachedLineItems: => DfpLineItems,
- lineItemsModifiedSince: DateTime => DfpLineItems,
- allActiveLineItems: => DfpLineItems,
- ): LineItemLoadSummary = {
-
- // If the cache is empty, run a full query to generate a complete LineItemLoadSummary, using allActiveLineItems.
- if (cachedLineItems.validItems.isEmpty) {
- // Create a full summary object from scratch, using a query that collects all line items from dfp.
- LineItemLoadSummary(
- validLineItems = allActiveLineItems.validItems,
- invalidLineItems = allActiveLineItems.invalidItems,
- )
- } else {
-
- // Calculate the most recent modified timestamp of the existing cache items,
- // and find line items modified since that timestamp.
- val threshold = cachedLineItems.validItems.map(_.lastModified).maxBy(_.getMillis)
- val recentlyModified = lineItemsModifiedSince(threshold)
-
- // Update existing items with a patch of new items.
- def updateCachedContent(existingItems: Seq[GuLineItem], newItems: Seq[GuLineItem]): Seq[GuLineItem] = {
-
- // Create a combined map of all the line items, preferring newer items over old ones (equality is based on id).
- val updatedLineItemMap = GuLineItem.asMap(existingItems) ++ GuLineItem.asMap(newItems)
-
- // These are the existing, cached keys.
- val existingKeys = existingItems.map(_.id).toSet
-
- val (active, inactive) = newItems partition (Seq("READY", "DELIVERING", "DELIVERY_EXTENDED") contains _.status)
- val activeKeys = active.map(_.id).toSet
- val inactiveKeys = inactive.map(_.id).toSet
-
- val added = activeKeys -- existingKeys
- val modified = activeKeys intersect existingKeys
- val removed = inactiveKeys intersect existingKeys
-
- // New cache contents.
- val updatedKeys = existingKeys ++ added -- removed
-
- log.info(s"Cached line item count was ${cachedLineItems.validItems.size}")
- log.info(s"Last modified time of cached line items: $threshold")
-
- log.info(s"Added: ${report(added)}")
- log.info(s"Modified: ${report(modified)}")
- log.info(s"Removed: ${report(inactiveKeys)}")
- log.info(s"Cached line item count now ${updatedKeys.size}")
-
- updatedKeys.toSeq.sorted.map(updatedLineItemMap)
- }
-
- LineItemLoadSummary(
- validLineItems = updateCachedContent(cachedLineItems.validItems, recentlyModified.validItems),
- invalidLineItems = updateCachedContent(cachedLineItems.invalidItems, recentlyModified.invalidItems),
- )
- }
- }
-
- private def write(data: DfpDataExtractor): Unit = {
-
- if (data.hasValidLineItems) {
- val now = printLondonTime(DateTime.now())
-
- val pageSkinSponsorships = data.pageSkinSponsorships
- Store.putDfpPageSkinAdUnits(stringify(toJson(PageSkinSponsorshipReport(now, pageSkinSponsorships))))
-
- Store.putDfpLineItemsReport(stringify(toJson(LineItemReport(now, data.lineItems, data.invalidLineItems))))
- }
- }
-
- private def writeLiveBlogTopSponsorships(data: DfpDataExtractor): Unit = {
- if (data.hasValidLineItems) {
- val now = printLondonTime(DateTime.now())
-
- val sponsorships = data.liveBlogTopSponsorships
- Store.putLiveBlogTopSponsorships(
- stringify(toJson(LiveBlogTopSponsorshipReport(Some(now), sponsorships))),
- )
- }
- }
-
- private def writeSurveySponsorships(data: DfpDataExtractor): Unit = {
- if (data.hasValidLineItems) {
- val now = printLondonTime(DateTime.now())
-
- val sponsorships = data.surveySponsorships
- Store.putSurveySponsorships(
- stringify(toJson(SurveySponsorshipReport(Some(now), sponsorships))),
- )
- }
- }
-
-}
diff --git a/admin/app/dfp/DfpDataCacheLifecycle.scala b/admin/app/dfp/DfpDataCacheLifecycle.scala
deleted file mode 100644
index 22a5e4f70664..000000000000
--- a/admin/app/dfp/DfpDataCacheLifecycle.scala
+++ /dev/null
@@ -1,127 +0,0 @@
-package dfp
-
-import app.LifecycleComponent
-import common.dfp.{GuAdUnit, GuCreativeTemplate, GuCustomField, GuCustomTargeting}
-import common._
-import play.api.inject.ApplicationLifecycle
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class DfpDataCacheLifecycle(
- appLifecycle: ApplicationLifecycle,
- jobScheduler: JobScheduler,
- creativeTemplateAgent: CreativeTemplateAgent,
- adUnitAgent: AdUnitAgent,
- advertiserAgent: AdvertiserAgent,
- customFieldAgent: CustomFieldAgent,
- orderAgent: OrderAgent,
- placementAgent: PlacementAgent,
- customTargetingAgent: CustomTargetingAgent,
- dfpDataCacheJob: DfpDataCacheJob,
- customTargetingKeyValueJob: CustomTargetingKeyValueJob,
- dfpAdUnitCacheJob: DfpAdUnitCacheJob,
- dfpMobileAppAdUnitCacheJob: DfpMobileAppAdUnitCacheJob,
- dfpFacebookIaAdUnitCacheJob: DfpFacebookIaAdUnitCacheJob,
- dfpTemplateCreativeCacheJob: DfpTemplateCreativeCacheJob,
- pekkoAsync: PekkoAsync,
-)(implicit ec: ExecutionContext)
- extends LifecycleComponent {
-
- appLifecycle.addStopHook { () =>
- Future {
- jobs foreach { job =>
- jobScheduler.deschedule(job.name)
- }
- }
- }
-
- trait Job[T] {
- val name: String
- val interval: Int
- def run(): Future[T]
- }
-
- val jobs = Set(
- new Job[DataCache[String, GuAdUnit]] {
- val name = "DFP-AdUnits-Update"
- val interval = 30
- def run() = adUnitAgent.refresh()
- },
- new Job[DataCache[String, GuCustomField]] {
- val name = "DFP-CustomFields-Update"
- val interval = 30
- def run() = customFieldAgent.refresh()
- },
- new Job[DataCache[Long, GuCustomTargeting]] {
- val name = "DFP-CustomTargeting-Update"
- val interval = 30
- def run() = customTargetingAgent.refresh()
- },
- new Job[Unit] {
- val name: String = "DFP-CustomTargeting-Store"
- val interval: Int = 15
- def run() = customTargetingKeyValueJob.run()
- },
- new Job[DataCache[Long, Seq[String]]] {
- val name = "DFP-Placements-Update"
- val interval = 30
- def run() = placementAgent.refresh()
- },
- new Job[Unit] {
- val name: String = "DFP-Cache"
- val interval: Int = 2
- def run(): Future[Unit] = dfpDataCacheJob.run()
- },
- new Job[Unit] {
- val name: String = "DFP-Ad-Units-Update"
- val interval: Int = 60
- def run(): Future[Unit] = dfpAdUnitCacheJob.run(pekkoAsync)
- },
- new Job[Unit] {
- val name: String = "DFP-Mobile-Apps-Ad-Units-Update"
- val interval: Int = 60
- def run(): Future[Unit] = dfpMobileAppAdUnitCacheJob.run(pekkoAsync)
- },
- new Job[Unit] {
- val name: String = "DFP-Facebook-IA-Ad-Units-Update"
- val interval: Int = 60
- def run(): Future[Unit] = dfpFacebookIaAdUnitCacheJob.run(pekkoAsync)
- },
- new Job[Seq[GuCreativeTemplate]] {
- val name: String = "DFP-Creative-Templates-Update"
- val interval: Int = 15
- def run() = creativeTemplateAgent.refresh()
- },
- new Job[Unit] {
- val name: String = "DFP-Template-Creatives-Cache"
- val interval: Int = 2
- def run() = dfpTemplateCreativeCacheJob.run()
- },
- new Job[Unit] {
- val name = "DFP-Order-Advertiser-Update"
- val interval: Int = 300
- def run() = {
- Future.sequence(Seq(advertiserAgent.refresh(), orderAgent.refresh())).map(_ => ())
- }
- },
- )
-
- override def start(): Unit = {
- jobs foreach { job =>
- jobScheduler.deschedule(job.name)
- jobScheduler.scheduleEveryNMinutes(job.name, job.interval) {
- job.run().map(_ => ())
- }
- }
-
- pekkoAsync.after1s {
- dfpDataCacheJob.refreshAllDfpData()
- creativeTemplateAgent.refresh()
- dfpTemplateCreativeCacheJob.run()
- customTargetingKeyValueJob.run()
- advertiserAgent.refresh()
- orderAgent.refresh()
- customFieldAgent.refresh()
- }
- }
-}
diff --git a/admin/app/dfp/DfpDataExtractor.scala b/admin/app/dfp/DfpDataExtractor.scala
index 4c6a7a74e118..a89f077dc25a 100644
--- a/admin/app/dfp/DfpDataExtractor.scala
+++ b/admin/app/dfp/DfpDataExtractor.scala
@@ -7,37 +7,6 @@ case class DfpDataExtractor(lineItems: Seq[GuLineItem], invalidLineItems: Seq[Gu
val hasValidLineItems: Boolean = lineItems.nonEmpty
- val liveBlogTopSponsorships: Seq[LiveBlogTopSponsorship] = {
- lineItems
- .filter(lineItem => lineItem.targetsLiveBlogTop && lineItem.isCurrent)
- .foldLeft(Seq.empty[LiveBlogTopSponsorship]) { (soFar, lineItem) =>
- soFar :+ LiveBlogTopSponsorship(
- lineItemName = lineItem.name,
- lineItemId = lineItem.id,
- adTest = lineItem.targeting.adTestValue,
- editions = editionsTargeted(lineItem),
- sections = lineItem.liveBlogTopTargetedSections,
- keywords = lineItem.targeting.keywordValues,
- targetsAdTest = lineItem.targeting.hasAdTestTargetting,
- )
- }
- }
-
- val surveySponsorships: Seq[SurveySponsorship] = {
- lineItems
- .filter(lineItem => lineItem.targetsSurvey && lineItem.isCurrent)
- .foldLeft(Seq.empty[SurveySponsorship]) { (soFar, lineItem) =>
- soFar :+ SurveySponsorship(
- lineItemName = lineItem.name,
- lineItemId = lineItem.id,
- adUnits = lineItem.targeting.adUnitsIncluded map (_.path mkString "/"),
- countries = countriesTargeted(lineItem),
- adTest = lineItem.targeting.adTestValue,
- targetsAdTest = lineItem.targeting.hasAdTestTargetting,
- )
- }
- }
-
val pageSkinSponsorships: Seq[PageSkinSponsorship] = {
lineItems withFilter { lineItem =>
lineItem.isPageSkin && lineItem.isCurrent
@@ -56,11 +25,6 @@ case class DfpDataExtractor(lineItems: Seq[GuLineItem], invalidLineItems: Seq[Gu
}
}
- def dateSort(lineItems: => Seq[GuLineItem]): Seq[GuLineItem] =
- lineItems sortBy { lineItem =>
- (lineItem.startTime.getMillis, lineItem.endTime.map(_.getMillis).getOrElse(0L))
- }
-
def editionsTargeted(lineItem: GuLineItem): Seq[Edition] = {
for {
targetSet <- lineItem.targeting.customTargetSets
diff --git a/admin/app/dfp/DfpTemplateCreativeCacheJob.scala b/admin/app/dfp/DfpTemplateCreativeCacheJob.scala
deleted file mode 100644
index 4709df018546..000000000000
--- a/admin/app/dfp/DfpTemplateCreativeCacheJob.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package dfp
-
-import common.dfp.GuCreative
-import org.joda.time.DateTime.now
-import play.api.libs.json.Json
-import tools.Store
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class DfpTemplateCreativeCacheJob(dfpApi: DfpApi) {
-
- def run()(implicit executionContext: ExecutionContext): Future[Unit] =
- Future {
- val cached = Store.getDfpTemplateCreatives
- val threshold = GuCreative.lastModified(cached) getOrElse now.minusMonths(1)
- val recentlyModified = dfpApi.readTemplateCreativesModifiedSince(threshold)
- val merged = GuCreative.merge(cached, recentlyModified)
- Store.putDfpTemplateCreatives(Json.stringify(Json.toJson(merged)))
- }
-}
diff --git a/admin/app/dfp/OrderAgent.scala b/admin/app/dfp/OrderAgent.scala
deleted file mode 100644
index c31537e6093a..000000000000
--- a/admin/app/dfp/OrderAgent.scala
+++ /dev/null
@@ -1,20 +0,0 @@
-package dfp
-
-import common.Box
-import common.dfp.GuOrder
-import concurrent.BlockingOperations
-
-import scala.concurrent.{ExecutionContext, Future}
-
-class OrderAgent(blockingOperations: BlockingOperations, dfpApi: DfpApi) {
-
- private lazy val cache = Box(Seq.empty[GuOrder])
-
- def refresh()(implicit executionContext: ExecutionContext): Future[Seq[GuOrder]] = {
- blockingOperations.executeBlocking(dfpApi.getAllOrders).flatMap { freshData =>
- cache.alter(if (freshData.nonEmpty) freshData else _)
- }
- }
-
- def get: Seq[GuOrder] = cache.get()
-}
diff --git a/admin/app/dfp/PlacementAgent.scala b/admin/app/dfp/PlacementAgent.scala
deleted file mode 100644
index 7764346b3390..000000000000
--- a/admin/app/dfp/PlacementAgent.scala
+++ /dev/null
@@ -1,34 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import common.dfp.GuAdUnit
-import concurrent.BlockingOperations
-
-import scala.util.Try
-
-class PlacementAgent(val blockingOperations: BlockingOperations) extends DataAgent[Long, Seq[String]] {
-
- override def loadFreshData(): Try[Map[Long, Seq[String]]] =
- Try {
- val maybeData = for (session <- SessionWrapper()) yield {
- val placements = session.placements(new StatementBuilder())
- placements.map { placement =>
- placement.getId.toLong -> placement.getTargetedAdUnitIds.toSeq
- }.toMap
- }
- maybeData getOrElse Map.empty
- }
-}
-
-class PlacementService(placementAgent: PlacementAgent, adUnitService: AdUnitService) {
-
- def placementAdUnitIds(session: SessionWrapper)(placementId: Long): Seq[GuAdUnit] = {
- lazy val fallback = {
- val stmtBuilder = new StatementBuilder().where("id = :id").withBindVariableValue("id", placementId)
- session.placements(stmtBuilder) flatMap (_.getTargetedAdUnitIds.toSeq)
- }
- val adUnitIds = placementAgent.get.data getOrElse (placementId, fallback)
- adUnitIds.flatMap(adUnitService.activeAdUnit)
- }
-
-}
diff --git a/admin/app/dfp/Reader.scala b/admin/app/dfp/Reader.scala
deleted file mode 100644
index 522b03a67cea..000000000000
--- a/admin/app/dfp/Reader.scala
+++ /dev/null
@@ -1,28 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder.SUGGESTED_PAGE_LIMIT
-import com.google.api.ads.admanager.axis.v202405._
-
-import scala.annotation.tailrec
-
-object Reader {
-
- def read[T](statementBuilder: StatementBuilder)(readPage: Statement => (Array[T], Int)): Seq[T] = {
-
- @tailrec
- def read(soFar: Seq[T]): Seq[T] = {
- val (pageOfResults, totalResultSetSize) = readPage(statementBuilder.toStatement)
- val resultsSoFar = Option(pageOfResults).map(soFar ++ _).getOrElse(soFar)
- if (resultsSoFar.size >= totalResultSetSize) {
- resultsSoFar
- } else {
- statementBuilder.increaseOffsetBy(SUGGESTED_PAGE_LIMIT)
- read(resultsSoFar)
- }
- }
-
- statementBuilder.limit(SUGGESTED_PAGE_LIMIT)
- read(Nil)
- }
-}
diff --git a/admin/app/dfp/ServicesWrapper.scala b/admin/app/dfp/ServicesWrapper.scala
deleted file mode 100644
index 371a3812fd9f..000000000000
--- a/admin/app/dfp/ServicesWrapper.scala
+++ /dev/null
@@ -1,36 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.factory.AdManagerServices
-import com.google.api.ads.admanager.axis.v202405._
-import com.google.api.ads.admanager.lib.client.AdManagerSession
-
-private[dfp] class ServicesWrapper(session: AdManagerSession) {
-
- private val dfpServices = new AdManagerServices
-
- lazy val lineItemService = dfpServices.get(session, classOf[LineItemServiceInterface])
-
- lazy val licaService = dfpServices.get(session, classOf[LineItemCreativeAssociationServiceInterface])
-
- lazy val customFieldsService = dfpServices.get(session, classOf[CustomFieldServiceInterface])
-
- lazy val customTargetingService = dfpServices.get(session, classOf[CustomTargetingServiceInterface])
-
- lazy val inventoryService = dfpServices.get(session, classOf[InventoryServiceInterface])
-
- lazy val suggestedAdUnitService = dfpServices.get(session, classOf[SuggestedAdUnitServiceInterface])
-
- lazy val placementService = dfpServices.get(session, classOf[PlacementServiceInterface])
-
- lazy val creativeTemplateService = dfpServices.get(session, classOf[CreativeTemplateServiceInterface])
-
- lazy val creativeService = dfpServices.get(session, classOf[CreativeServiceInterface])
-
- lazy val networkService = dfpServices.get(session, classOf[NetworkServiceInterface])
-
- lazy val orderService = dfpServices.get(session, classOf[OrderServiceInterface])
-
- lazy val companyService = dfpServices.get(session, classOf[CompanyServiceInterface])
-
- lazy val reportService = dfpServices.get(session, classOf[ReportServiceInterface])
-}
diff --git a/admin/app/dfp/SessionLogger.scala b/admin/app/dfp/SessionLogger.scala
deleted file mode 100644
index 05e869582048..000000000000
--- a/admin/app/dfp/SessionLogger.scala
+++ /dev/null
@@ -1,95 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import com.google.api.ads.admanager.axis.v202405._
-import common.GuLogging
-
-import scala.util.control.NonFatal
-import common.DfpApiMetrics.DfpApiErrors
-
-private[dfp] object SessionLogger extends GuLogging {
-
- def logAroundRead[T](typesToRead: String, stmtBuilder: StatementBuilder)(read: => Seq[T]): Seq[T] = {
- logAroundSeq(typesToRead, opName = "reading", Some(stmtBuilder.toStatement))(read)
- }
-
- def logAroundReadSingle[T](typesToRead: String)(read: => T): Option[T] = {
- logAround(typesToRead, "reading")(read)((_: T) => 1)
- }
-
- def logAroundCreate[T](typesToCreate: String)(create: => Seq[T]): Seq[T] = {
- logAroundSeq(typesToCreate, opName = "creating")(create)
- }
-
- def logAroundPerform(typesName: String, opName: String, statement: Statement)(op: => Int): Int = {
- logAround(typesName, opName, Some(statement))(op)(identity) getOrElse 0
- }
-
- private def logAroundSeq[T](typesName: String, opName: String, statement: Option[Statement] = None)(
- op: => Seq[T],
- ): Seq[T] = {
- logAround(typesName, opName, statement)(op)(_.size) getOrElse Nil
- }
-
- private def logAround[T](typesName: String, opName: String, statement: Option[Statement] = None)(
- op: => T,
- )(numAffected: T => Int): Option[T] = {
-
- def logApiException(e: ApiException, baseMessage: String): Unit = {
- e.getErrors foreach { err =>
- val reasonMsg = err match {
- case freqCapErr: FrequencyCapError => s", with the reason '${freqCapErr.getReason}'"
- case notNullErr: NotNullError => s", with the reason '${notNullErr.getReason}'"
- case _ => ""
- }
- val path = err.getFieldPath
- val trigger = err.getTrigger
- val msg = s"'${err.getErrorString}'$reasonMsg"
- log.error(
- s"$baseMessage failed: API exception in field '$path', " +
- s"caused by an invalid value '$trigger', " +
- s"with the error message $msg",
- e,
- )
- }
- }
-
- val maybeQryLogMessage = statement map { stmt =>
- val qry = stmt.getQuery
- val params = stmt.getValues.map { param =>
- val k = param.getKey
- val rawValue = param.getValue
- k -> (
- rawValue match {
- case t: TextValue => s""""${t.getValue}""""
- case n: NumberValue => n.getValue
- case b: BooleanValue => b.getValue
- case other => other.toString
- }
- )
- }.toMap
- val paramStr = if (params.isEmpty) "" else s"and params ${params.toString}"
- s"""with statement "$qry" $paramStr"""
- }
- val baseMessage = s"$opName $typesName"
- val msgPrefix = maybeQryLogMessage map (qryLogMsg => s"$baseMessage $qryLogMsg") getOrElse baseMessage
-
- try {
- log.info(s"$msgPrefix ...")
- val start = System.currentTimeMillis()
- val result = op
- val duration = System.currentTimeMillis() - start
- log.info(s"Successful $opName of ${numAffected(result)} $typesName in $duration ms")
- Some(result)
- } catch {
- case e: ApiException =>
- logApiException(e, msgPrefix);
- DfpApiErrors.increment();
- None
- case NonFatal(e) =>
- log.error(s"$msgPrefix failed", e);
- DfpApiErrors.increment();
- None
- }
- }
-}
diff --git a/admin/app/dfp/SessionWrapper.scala b/admin/app/dfp/SessionWrapper.scala
deleted file mode 100644
index 42896ede872d..000000000000
--- a/admin/app/dfp/SessionWrapper.scala
+++ /dev/null
@@ -1,232 +0,0 @@
-package dfp
-
-import com.google.api.ads.common.lib.auth.OfflineCredentials
-import com.google.api.ads.common.lib.auth.OfflineCredentials.Api
-import com.google.api.ads.admanager.axis.utils.v202405.{ReportDownloader, StatementBuilder}
-import com.google.api.ads.admanager.axis.v202405._
-import com.google.api.ads.admanager.lib.client.AdManagerSession
-import com.google.common.io.CharSource
-import common.GuLogging
-import conf.{AdminConfiguration, Configuration}
-import dfp.Reader.read
-import dfp.SessionLogger.{logAroundCreate, logAroundPerform, logAroundRead, logAroundReadSingle}
-import scala.jdk.CollectionConverters._
-
-import scala.util.control.NonFatal
-import common.DfpApiMetrics.DfpSessionErrors
-
-private[dfp] class SessionWrapper(dfpSession: AdManagerSession) {
-
- private val services = new ServicesWrapper(dfpSession)
-
- def lineItems(stmtBuilder: StatementBuilder): Seq[LineItem] = {
- logAroundRead("line items", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.lineItemService.getLineItemsByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def orders(stmtBuilder: StatementBuilder): Seq[Order] = {
- logAroundRead("orders", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.orderService.getOrdersByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def companies(stmtBuilder: StatementBuilder): Seq[Company] = {
- logAroundRead("companies", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.companyService.getCompaniesByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def customFields(stmtBuilder: StatementBuilder): Seq[CustomField] = {
- logAroundRead("custom fields", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.customFieldsService.getCustomFieldsByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def customTargetingKeys(stmtBuilder: StatementBuilder): Seq[CustomTargetingKey] = {
- logAroundRead("custom targeting keys", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.customTargetingService.getCustomTargetingKeysByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def customTargetingValues(stmtBuilder: StatementBuilder): Seq[CustomTargetingValue] = {
- logAroundRead("custom targeting values", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.customTargetingService.getCustomTargetingValuesByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def adUnits(stmtBuilder: StatementBuilder): Seq[AdUnit] = {
- logAroundRead("ad units", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.inventoryService.getAdUnitsByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def placements(stmtBuilder: StatementBuilder): Seq[Placement] = {
- logAroundRead("placements", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.placementService.getPlacementsByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def creativeTemplates(stmtBuilder: StatementBuilder): Seq[CreativeTemplate] = {
- logAroundRead("creative templates", stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = services.creativeTemplateService.getCreativeTemplatesByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def getRootAdUnitId: String = {
- services.networkService.getCurrentNetwork.getEffectiveRootAdUnitId
- }
-
- def getReportQuery(reportId: Long): Option[ReportQuery] = {
- // Retrieve the saved query.
- val stmtBuilder = new StatementBuilder()
- .where("id = :id")
- .limit(1)
- .withBindVariableValue("id", reportId)
-
- val page: SavedQueryPage = services.reportService.getSavedQueriesByStatement(stmtBuilder.toStatement)
- // page.getResults() may return null.
- val savedQuery: Option[SavedQuery] = Option(page.getResults()).flatMap(_.toList.headOption)
-
- /*
- * if this is null it means that the report is incompatible with the API version we're using.
- * Eg. check this for supported date-range types:
- * https://developers.google.com/doubleclick-publishers/docs/reference/v201711/ReportService.ReportQuery#daterangetype
- * And supported filter types:
- * https://developers.google.com/doubleclick-publishers/docs/reference/v201711/ReportService.ReportQuery#statement`
- * Also see https://developers.google.com/doubleclick-publishers/docs/reporting
- */
- savedQuery.flatMap(qry => Option(qry.getReportQuery))
- }
-
- def runReportJob(report: ReportQuery): Seq[String] = {
-
- val reportJob = new ReportJob()
- reportJob.setReportQuery(report)
-
- val runningJob = services.reportService.runReportJob(reportJob)
-
- val reportDownloader = new ReportDownloader(services.reportService, runningJob.getId)
- reportDownloader.waitForReportReady()
-
- // Download the report.
- val options: ReportDownloadOptions = new ReportDownloadOptions()
- options.setExportFormat(ExportFormat.CSV_DUMP)
- options.setUseGzipCompression(true)
- val charSource: CharSource = reportDownloader.getReportAsCharSource(options)
- charSource.readLines().asScala.toSeq
- }
-
- object lineItemCreativeAssociations {
-
- private val licaService = services.licaService
- private val typeName = "licas"
-
- def get(stmtBuilder: StatementBuilder): Seq[LineItemCreativeAssociation] = {
- logAroundRead(typeName, stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = licaService.getLineItemCreativeAssociationsByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def getPreviewUrl(lineItemId: Long, creativeId: Long, url: String): Option[String] =
- logAroundReadSingle(typeName) {
- licaService.getPreviewUrl(lineItemId, creativeId, url)
- }
-
- def create(licas: Seq[LineItemCreativeAssociation]): Seq[LineItemCreativeAssociation] = {
- logAroundCreate(typeName) {
- licaService.createLineItemCreativeAssociations(licas.toArray).toIndexedSeq
- }
- }
-
- def deactivate(filterStatement: Statement): Int = {
- logAroundPerform(typeName, "deactivating", filterStatement) {
- val action = new DeactivateLineItemCreativeAssociations()
- val result = licaService.performLineItemCreativeAssociationAction(action, filterStatement)
- result.getNumChanges
- }
- }
- }
-
- object creatives {
-
- private val creativeService = services.creativeService
- private val typeName = "creatives"
-
- def get(stmtBuilder: StatementBuilder): Seq[Creative] = {
- logAroundRead(typeName, stmtBuilder) {
- read(stmtBuilder) { statement =>
- val page = creativeService.getCreativesByStatement(statement)
- (page.getResults, page.getTotalResultSetSize)
- }
- }
- }
-
- def create(creatives: Seq[Creative]): Seq[Creative] = {
- logAroundCreate(typeName) {
- creativeService.createCreatives(creatives.toArray).toIndexedSeq
- }
- }
- }
-}
-
-object SessionWrapper extends GuLogging {
-
- def apply(networkId: Option[String] = None): Option[SessionWrapper] = {
- val dfpSession =
- try {
- for {
- serviceAccountKeyFile <- AdminConfiguration.dfpApi.serviceAccountKeyFile
- appName <- AdminConfiguration.dfpApi.appName
- } yield {
- val credential = new OfflineCredentials.Builder()
- .forApi(Api.AD_MANAGER)
- .withJsonKeyFilePath(serviceAccountKeyFile.toString())
- .build()
- .generateCredential()
- new AdManagerSession.Builder()
- .withOAuth2Credential(credential)
- .withApplicationName(appName)
- .withNetworkCode(networkId.getOrElse(Configuration.commercial.dfpAccountId))
- .build()
- }
- } catch {
- case NonFatal(e) =>
- log.error(s"Building DFP session failed.", e)
- DfpSessionErrors.increment();
- None
- }
-
- dfpSession map (new SessionWrapper(_))
- }
-}
diff --git a/admin/app/dfp/package.scala b/admin/app/dfp/package.scala
index 5538e5742e1b..4a48993eacda 100644
--- a/admin/app/dfp/package.scala
+++ b/admin/app/dfp/package.scala
@@ -1,7 +1,7 @@
import org.joda.time.format.{DateTimeFormat, DateTimeFormatter}
import org.joda.time.{DateTime, DateTimeZone}
-package object dfp {
+package object gam {
private def timeFormatter: DateTimeFormatter = {
DateTimeFormat.forPattern("d MMM YYYY HH:mm:ss z")
diff --git a/admin/app/football/controllers/TablesController.scala b/admin/app/football/controllers/TablesController.scala
index ed21a19c3763..3f518e2d74dd 100644
--- a/admin/app/football/controllers/TablesController.scala
+++ b/admin/app/football/controllers/TablesController.scala
@@ -38,7 +38,7 @@ class TablesController(val wsClient: WSClient, val controllerComponents: Control
val url = submission.get("focus").get.head match {
case "top" => s"/admin/football/tables/league/$competitionId/top"
case "bottom" => s"/admin/football/tables/league/$competitionId/bottom"
- case "team" =>
+ case "team" =>
val teamId = submission.get("teamId").get.head
submission.get("team2Id") match {
case Some(Seq(team2Id)) if !team2Id.startsWith("Choose") =>
@@ -66,9 +66,9 @@ class TablesController(val wsClient: WSClient, val controllerComponents: Control
) { season =>
client.leagueTable(season.competitionId, LocalDate.now()).map { tableEntries =>
val entries = focus match {
- case "top" => tableEntries.take(5)
- case "bottom" => tableEntries.takeRight(5)
- case "none" => tableEntries
+ case "top" => tableEntries.take(5)
+ case "bottom" => tableEntries.takeRight(5)
+ case "none" => tableEntries
case group if group.startsWith("group-") =>
tableEntries.filter(_.round.name.fold(false)(_.toLowerCase.replace(' ', '-') == group))
case teamId => surroundingItems[LeagueTableEntry](2, tableEntries, _.team.id == teamId)
diff --git a/admin/app/football/model/PrevResult.scala b/admin/app/football/model/PrevResult.scala
index 44c6fed3e719..ddd7fb976010 100644
--- a/admin/app/football/model/PrevResult.scala
+++ b/admin/app/football/model/PrevResult.scala
@@ -18,6 +18,7 @@ case class PrevResult(date: ZonedDateTime, self: MatchDayTeam, foe: MatchDayTeam
val lost = scores.exists { case (selfScore, foeScore) => selfScore < foeScore }
}
+// TODO: check if this class is used anywhere
object PrevResult {
def apply(result: Result, thisTeamId: String): PrevResult = {
if (thisTeamId == result.homeTeam.id) PrevResult(result.date, result.homeTeam, result.awayTeam, wasHome = true)
diff --git a/admin/app/jobs/AnalyticsSanityCheckJob.scala b/admin/app/jobs/AnalyticsSanityCheckJob.scala
deleted file mode 100644
index 0e793fc4036a..000000000000
--- a/admin/app/jobs/AnalyticsSanityCheckJob.scala
+++ /dev/null
@@ -1,61 +0,0 @@
-package jobs
-
-import java.util.concurrent.atomic.AtomicLong
-import com.amazonaws.services.cloudwatch.model.{GetMetricStatisticsResult, StandardUnit}
-import common.GuLogging
-import metrics.GaugeMetric
-import model.diagnostics.CloudWatch
-
-import java.time.{LocalDateTime, ZoneId}
-import services.{CloudWatchStats, OphanApi}
-
-import scala.jdk.CollectionConverters._
-import scala.concurrent.{ExecutionContext, Future}
-
-class AnalyticsSanityCheckJob(ophanApi: OphanApi) extends GuLogging {
-
- private val rawPageViews = new AtomicLong(0L)
- private val ophanPageViews = new AtomicLong(0L)
- private val googlePageViews = new AtomicLong(0L)
-
- val ophanConversionRate = GaugeMetric(
- name = "ophan-percent-conversion",
- description = "The percentage of raw page views that contain a recorded Ophan page view",
- metricUnit = StandardUnit.Percent,
- get = () => {
- ophanPageViews.get.toDouble / rawPageViews.get.toDouble * 100.0d
- },
- )
-
- def run()(implicit executionContext: ExecutionContext): Unit = {
-
- val fRawPageViews: Future[GetMetricStatisticsResult] = CloudWatchStats.rawPageViews()
- val fOphanViews = ophanViews()
- for {
- rawPageViewsStats <- fRawPageViews
- ophanViewsCount <- fOphanViews
- } yield {
-
- def metricLastSum(stats: GetMetricStatisticsResult): Long =
- stats.getDatapoints.asScala.headOption.map(_.getSum.longValue).getOrElse(0L)
-
- rawPageViews.set(metricLastSum(rawPageViewsStats))
- ophanPageViews.set(ophanViewsCount)
-
- CloudWatch.putMetrics("Analytics", List(ophanConversionRate), List.empty)
- }
-
- }
-
- private def ophanViews()(implicit executionContext: ExecutionContext): Future[Long] = {
- val instant: Long = LocalDateTime.now().minusMinutes(15).atZone(ZoneId.of("UTC")).toInstant().toEpochMilli()
- ophanApi.getBreakdown("next-gen", hours = 1).map { json =>
- (json \\ "data").flatMap { line =>
- val recent = line.asInstanceOf[play.api.libs.json.JsArray].value.filter { entry =>
- (entry \ "dateTime").as[Long] > instant
- }
- recent.map(r => (r \ "count").as[Long])
- }.sum
- }
- }
-}
diff --git a/admin/app/jobs/CommercialDfpReporting.scala b/admin/app/jobs/CommercialDfpReporting.scala
deleted file mode 100644
index 8d54e533077e..000000000000
--- a/admin/app/jobs/CommercialDfpReporting.scala
+++ /dev/null
@@ -1,112 +0,0 @@
-package jobs
-
-import java.time.{LocalDate, LocalDateTime}
-
-import app.LifecycleComponent
-import com.google.api.ads.admanager.axis.v202405.Column.{AD_SERVER_IMPRESSIONS, AD_SERVER_WITHOUT_CPD_AVERAGE_ECPM}
-import com.google.api.ads.admanager.axis.v202405.DateRangeType.CUSTOM_DATE
-import com.google.api.ads.admanager.axis.v202405.Dimension.{CUSTOM_CRITERIA, DATE}
-import com.google.api.ads.admanager.axis.v202405._
-import common.{PekkoAsync, Box, JobScheduler, GuLogging}
-import dfp.DfpApi
-import play.api.inject.ApplicationLifecycle
-
-import scala.concurrent.{ExecutionContext, Future}
-
-object CommercialDfpReporting extends GuLogging {
-
- case class DfpReportRow(value: String) {
- val fields = value.split(",").toSeq
- }
-
- case class DfpReport(rows: Seq[DfpReportRow], lastUpdated: LocalDateTime)
-
- private val dfpReports = Box[Map[Long, Seq[DfpReportRow]]](Map.empty)
- private val dfpCustomReports = Box[Map[String, DfpReport]](Map.empty)
-
- val teamKPIReport = "All ab-test impressions and CPM"
- val prebidBidderPerformance = "Prebid Bidder Performance"
-
- // These IDs correspond to queries saved in DFP's web console.
- val reportMappings = Map(
- teamKPIReport -> 10060521970L, // This report is accessible by the DFP user: "NGW DFP Production"
- )
-
- private def prebidBidderPerformanceQry = {
- def toGoogleDate(date: LocalDate) = new Date(date.getYear, date.getMonthValue, date.getDayOfMonth)
- val weekAgo = LocalDate.now.minusWeeks(1)
- val qry = new ReportQuery()
- qry.setDateRangeType(CUSTOM_DATE)
- qry.setStartDate(toGoogleDate(weekAgo.minusDays(1)))
- qry.setEndDate(toGoogleDate(LocalDate.now))
- qry.setDimensions(Array(DATE, CUSTOM_CRITERIA))
- qry.setColumns(Array(AD_SERVER_IMPRESSIONS, AD_SERVER_WITHOUT_CPD_AVERAGE_ECPM))
- qry
- }
-
- def update(dfpApi: DfpApi)(implicit executionContext: ExecutionContext): Future[Unit] =
- Future {
- for {
- (_, reportId) <- reportMappings.toSeq
- } {
- val maybeReport: Option[Seq[DfpReportRow]] = dfpApi
- .getReportQuery(reportId)
- .map(reportId => {
- // exclude the CSV header
- dfpApi.runReportJob(reportId).tail.map(DfpReportRow)
- })
-
- maybeReport.foreach { report: Seq[DfpReportRow] =>
- dfpReports.send(currentMap => {
- currentMap + (reportId -> report)
- })
- }
- }
-
- dfpCustomReports.send { prev =>
- val curr = prev + {
- prebidBidderPerformance ->
- DfpReport(
- rows = dfpApi.runReportJob(prebidBidderPerformanceQry).filter(_.contains("hb_bidder=")).map(DfpReportRow),
- lastUpdated = LocalDateTime.now,
- )
- }
- curr foreach { case (key, report) =>
- log.info(s"Updated report '$key' with ${report.rows.size} rows")
- }
- curr
- }
- }
-
- def getReport(reportId: Long): Option[Seq[DfpReportRow]] = dfpReports.get().get(reportId)
- def getCustomReport(reportName: String): Option[DfpReport] = dfpCustomReports.get().get(reportName)
-}
-
-class CommercialDfpReportingLifecycle(
- appLifecycle: ApplicationLifecycle,
- jobs: JobScheduler,
- pekkoAsync: PekkoAsync,
- dfpApi: DfpApi,
-)(implicit ec: ExecutionContext)
- extends LifecycleComponent
- with GuLogging {
-
- appLifecycle.addStopHook { () =>
- Future {
- jobs.deschedule("CommercialDfpReportingJob")
- }
- }
-
- override def start(): Unit = {
- jobs.deschedule("CommercialDfpReportingJob")
-
- CommercialDfpReporting.update(dfpApi)(ec)
-
- // 30 minutes between each log write.
- jobs.scheduleEveryNMinutes("CommercialDfpReportingJob", 30) {
- log.logger.info(s"Fetching commercial dfp report from dfp api")
- CommercialDfpReporting.update(dfpApi)(ec)
- }
- }
-
-}
diff --git a/admin/app/jobs/ExpiringSwitchesEmailJob.scala b/admin/app/jobs/ExpiringSwitchesEmailJob.scala
index 3fae895522b5..f88126fead8e 100644
--- a/admin/app/jobs/ExpiringSwitchesEmailJob.scala
+++ b/admin/app/jobs/ExpiringSwitchesEmailJob.scala
@@ -34,7 +34,7 @@ case class ExpiringSwitchesEmailJob(emailService: EmailService) extends GuLoggin
)
eventualResult.foreach { result =>
- log.info(s"Message sent successfully with ID: ${result.getMessageId}")
+ log.info(s"Message sent successfully with ID: ${result.messageId()}")
}
eventualResult.failed.foreach { case NonFatal(e) =>
diff --git a/admin/app/jobs/FastlyCloudwatchLoadJob.scala b/admin/app/jobs/FastlyCloudwatchLoadJob.scala
deleted file mode 100644
index 29e1d751038e..000000000000
--- a/admin/app/jobs/FastlyCloudwatchLoadJob.scala
+++ /dev/null
@@ -1,71 +0,0 @@
-package jobs
-
-import com.amazonaws.services.cloudwatch.model.StandardUnit
-import com.madgag.scala.collection.decorators.MapDecorator
-import common.GuLogging
-import metrics.SamplerMetric
-import model.diagnostics.CloudWatch
-import services.{FastlyStatistic, FastlyStatisticService}
-
-import scala.collection.mutable
-import conf.Configuration
-import org.joda.time.DateTime
-
-import scala.concurrent.ExecutionContext
-
-class FastlyCloudwatchLoadJob(fastlyStatisticService: FastlyStatisticService) extends GuLogging {
- // Samples in CloudWatch are additive so we want to limit duplicate reporting.
- // We do not want to corrupt the past either, so set a default value (the most
- // recent 15 minutes of results are unstable).
-
- // This key is (service, name, region)
- val latestTimestampsSent =
- mutable.Map[(String, String, String), Long]().withDefaultValue(DateTime.now().minusMinutes(15).getMillis())
-
- // Be very explicit about which metrics we want. It is not necessary to cloudwatch everything.
- val allFastlyMetrics: List[SamplerMetric] = List(
- SamplerMetric("usa-hits", StandardUnit.Count),
- SamplerMetric("usa-miss", StandardUnit.Count),
- SamplerMetric("usa-errors", StandardUnit.Count),
- SamplerMetric("europe-hits", StandardUnit.Count),
- SamplerMetric("europe-miss", StandardUnit.Count),
- SamplerMetric("europe-errors", StandardUnit.Count),
- SamplerMetric("ausnz-hits", StandardUnit.Count),
- SamplerMetric("ausnz-miss", StandardUnit.Count),
- SamplerMetric("ausnz-errors", StandardUnit.Count),
- )
-
- private def updateMetricFromStatistic(stat: FastlyStatistic): Unit = {
- val maybeMetric: Option[SamplerMetric] = allFastlyMetrics.find { metric =>
- metric.name == s"${stat.region}-${stat.name}"
- }
-
- maybeMetric.foreach { metric =>
- metric.recordSample(stat.value.toDouble, new DateTime(stat.timestamp))
- }
- }
-
- def run()(implicit executionContext: ExecutionContext): Unit = {
- log.info("Loading statistics from Fastly to CloudWatch.")
- fastlyStatisticService.fetch().map { statistics =>
- val fresh: List[FastlyStatistic] = statistics filter { statistic =>
- latestTimestampsSent(statistic.key) < statistic.timestamp
- }
-
- log.info("Uploading %d new metric data points" format fresh.size)
-
- if (Configuration.environment.isProd) {
- fresh.foreach { updateMetricFromStatistic }
- CloudWatch.putMetrics("Fastly", allFastlyMetrics, List.empty)
- } else {
- log.info("DISABLED: Metrics uploaded in PROD only to limit duplication.")
- }
-
- val groups = fresh groupBy { _.key }
- val timestampsSent = groups mapV { _ map { _.timestamp } }
- timestampsSent mapV { _.max } foreach { case (key, value) =>
- latestTimestampsSent.update(key, value)
- }
- }
- }
-}
diff --git a/admin/app/jobs/R2PagePressJob.scala b/admin/app/jobs/R2PagePressJob.scala
index 0fd804c1a3f1..bfcb594ff09c 100644
--- a/admin/app/jobs/R2PagePressJob.scala
+++ b/admin/app/jobs/R2PagePressJob.scala
@@ -1,7 +1,5 @@
package jobs
-import com.amazonaws.services.sqs.AmazonSQSAsyncClient
-import com.amazonaws.services.sqs.model.ReceiveMessageRequest
import common._
import conf.Configuration
import conf.switches.Switches.R2PagePressServiceSwitch
@@ -14,6 +12,9 @@ import model.R2PressMessage
import implicits.R2PressNotification.pressMessageFormatter
import org.jsoup.nodes.Document
import services.RedirectService.ArchiveRedirect
+import software.amazon.awssdk.services.sqs.SqsAsyncClient
+import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest
+import utils.AWSv2
import scala.concurrent.{ExecutionContext, Future}
@@ -21,17 +22,22 @@ class R2PagePressJob(wsClient: WSClient, redirects: RedirectService)(implicit ex
extends GuLogging {
private lazy val waitTimeSeconds = Configuration.r2Press.pressQueueWaitTimeInSeconds
private lazy val maxMessages = Configuration.r2Press.pressQueueMaxMessages
- private lazy val credentials = Configuration.aws.mandatoryCredentials
private lazy val sqsClient =
- AmazonSQSAsyncClient.asyncBuilder.withCredentials(credentials).withRegion(conf.Configuration.aws.region).build()
+ SqsAsyncClient
+ .builder()
+ .credentialsProvider(AWSv2.credentials)
+ .region(AWSv2.region)
+ .build()
def run(): Future[Unit] = {
if (R2PagePressServiceSwitch.isSwitchedOn) {
log.info("R2PagePressJob starting")
- val receiveRequest = new ReceiveMessageRequest()
- .withWaitTimeSeconds(waitTimeSeconds)
- .withMaxNumberOfMessages(maxMessages)
+ val receiveRequest = ReceiveMessageRequest
+ .builder()
+ .waitTimeSeconds(waitTimeSeconds)
+ .maxNumberOfMessages(maxMessages)
+ .build()
try {
val pressing = queue
diff --git a/admin/app/model/AdminLifecycle.scala b/admin/app/model/AdminLifecycle.scala
index 73c204d11e24..851c236f0b35 100644
--- a/admin/app/model/AdminLifecycle.scala
+++ b/admin/app/model/AdminLifecycle.scala
@@ -1,8 +1,6 @@
package model
import java.util.TimeZone
-import java.nio.file.Files.deleteIfExists
-
import app.LifecycleComponent
import common._
import conf.Configuration
@@ -10,20 +8,17 @@ import conf.switches.Switches._
import _root_.jobs._
import play.api.inject.ApplicationLifecycle
import services.EmailService
-import tools.{AssetMetricsCache, CloudWatch, LoadBalancer}
+import tools.{CloudWatch, LoadBalancer}
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
-import conf.AdminConfiguration
class AdminLifecycle(
appLifecycle: ApplicationLifecycle,
jobs: JobScheduler,
pekkoAsync: PekkoAsync,
emailService: EmailService,
- fastlyCloudwatchLoadJob: FastlyCloudwatchLoadJob,
r2PagePressJob: R2PagePressJob,
- analyticsSanityCheckJob: AnalyticsSanityCheckJob,
rebuildIndexJob: RebuildIndexJob,
)(implicit ec: ExecutionContext)
extends LifecycleComponent
@@ -32,9 +27,8 @@ class AdminLifecycle(
appLifecycle.addStopHook { () =>
Future {
descheduleJobs()
- CloudWatch.shutdown()
+ CloudWatch.close()
emailService.shutdown()
- deleteTmpFiles()
}
}
@@ -45,31 +39,15 @@ class AdminLifecycle(
lazy val r2PagePressRateInSeconds: Int = Configuration.r2Press.pressRateInSeconds
private def scheduleJobs(): Unit = {
-
- // every 0, 30 seconds past the minute
- jobs.schedule("AdminLoadJob", "0/30 * * * * ?") {
- model.abtests.AbTestJob.run()
- }
-
// every 4, 19, 34, 49 minutes past the hour, on the 2nd second past the minute (e.g 13:04:02, 13:19:02)
jobs.schedule("LoadBalancerLoadJob", "2 4/15 * * * ?") {
LoadBalancer.refresh()
}
- // every 2 minutes starting 5 seconds past the minute (e.g 13:02:05, 13:04:05)
- jobs.schedule("FastlyCloudwatchLoadJob", "5 0/2 * * * ?") {
- fastlyCloudwatchLoadJob.run()
- }
-
jobs.scheduleEvery("R2PagePressJob", r2PagePressRateInSeconds.seconds) {
r2PagePressJob.run()
}
- // every 2, 17, 32, 47 minutes past the hour, on the 12th second past the minute (e.g 13:02:12, 13:17:12)
- jobs.schedule("AnalyticsSanityCheckJob", "12 2/15 * * * ?") {
- analyticsSanityCheckJob.run()
- }
-
jobs.scheduleEveryNMinutes("FrontPressJobHighFrequency", adminPressJobHighPushRateInMinutes) {
if (FrontPressJobSwitch.isSwitchedOn) RefreshFrontsJob.runFrequency(pekkoAsync)(HighFrequency)
Future.successful(())
@@ -100,17 +78,11 @@ class AdminLifecycle(
log.info("Starting ExpiringSwitchesAfternoonEmailJob")
ExpiringSwitchesEmailJob(emailService).runReminder()
}
-
- jobs.scheduleEveryNMinutes("AssetMetricsCache", 60 * 6) {
- AssetMetricsCache.run()
- }
-
}
private def descheduleJobs(): Unit = {
jobs.deschedule("AdminLoadJob")
jobs.deschedule("LoadBalancerLoadJob")
- jobs.deschedule("FastlyCloudwatchLoadJob")
jobs.deschedule("R2PagePressJob")
jobs.deschedule("AnalyticsSanityCheckJob")
jobs.deschedule("RebuildIndexJob")
@@ -122,15 +94,12 @@ class AdminLifecycle(
jobs.deschedule("AssetMetricsCache")
}
- private def deleteTmpFiles(): Unit = AdminConfiguration.dfpApi.serviceAccountKeyFile.map(deleteIfExists)
-
override def start(): Unit = {
descheduleJobs()
scheduleJobs()
pekkoAsync.after1s {
rebuildIndexJob.run()
- AssetMetricsCache.run()
LoadBalancer.refresh()
}
}
diff --git a/admin/app/model/abtests/AbTestJob.scala b/admin/app/model/abtests/AbTestJob.scala
deleted file mode 100644
index 3153e24fed18..000000000000
--- a/admin/app/model/abtests/AbTestJob.scala
+++ /dev/null
@@ -1,39 +0,0 @@
-package model.abtests
-
-import common.GuLogging
-import tools.CloudWatch
-import views.support.CamelCase
-
-import scala.jdk.CollectionConverters._
-import scala.concurrent.ExecutionContext
-
-object AbTestJob extends GuLogging {
- def run()(implicit executionContext: ExecutionContext): Unit = {
-
- log.info("Downloading abtests info from CloudWatch")
-
- CloudWatch.AbMetricNames() map { result =>
- // Group variant names by test name
- val tests = result.getMetrics.asScala
- .map(_.getMetricName.split("-").toList)
- .collect { case test :: variant =>
- (test, variant.mkString("-"))
- }
- .groupBy(_._1)
-
- val switches =
- conf.switches.Switches.all.filter(_.name.startsWith("ab-")).map(switch => CamelCase.fromHyphenated(switch.name))
-
- val testVariants = switches.foldLeft(Map.empty[String, Seq[String]])((acc, switch) => {
- if (tests.isDefinedAt(switch)) {
- // Update map with a list of variants for the ab-test switch.
- acc.updated(switch, tests(switch).map(_._2).toSeq)
- } else {
- acc
- }
- })
-
- AbTests.update(testVariants)
- }
- }
-}
diff --git a/admin/app/model/abtests/AbTests.scala b/admin/app/model/abtests/AbTests.scala
deleted file mode 100644
index 14531c4e8deb..000000000000
--- a/admin/app/model/abtests/AbTests.scala
+++ /dev/null
@@ -1,44 +0,0 @@
-package model.abtests
-
-import tools.{ABDataChart, ChartFormat, CloudWatch}
-import com.amazonaws.services.cloudwatch.model.GetMetricStatisticsRequest
-import org.joda.time.DateTime
-
-import scala.concurrent.{ExecutionContext, Future}
-import awswrappers.cloudwatch._
-import common.Box
-
-object AbTests {
- private val abTests = Box[Map[String, Seq[String]]](Map.empty)
-
- def getTests(): Map[String, Seq[String]] = {
- abTests.get()
- }
-
- def update(testVariants: Map[String, Seq[String]]): Unit = {
- abTests.send(testVariants)
- }
-
- case class AbChart(testId: String, variants: Seq[String])
-
- def getAbCharts()(implicit executionContext: ExecutionContext): Future[Seq[ABDataChart]] = {
- Future.traverse(getTests().keys.toSeq) { abTest =>
- val variants: Seq[String] = getTests()(abTest)
-
- for {
- cloudWatchResults <- Future.traverse(variants) { variant =>
- CloudWatch.euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(6).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(360)
- .withStatistics("Average")
- .withNamespace("AbTests")
- .withMetricName(s"$abTest-$variant")
- .withDimensions(CloudWatch.stage),
- )
- }
- } yield new ABDataChart(abTest, Seq("Time") ++ variants, ChartFormat.MultiLine, cloudWatchResults: _*)
- }
- }
-}
diff --git a/admin/app/services/CloudWatchStats.scala b/admin/app/services/CloudWatchStats.scala
deleted file mode 100644
index 6f4d0bfc36f0..000000000000
--- a/admin/app/services/CloudWatchStats.scala
+++ /dev/null
@@ -1,47 +0,0 @@
-package services
-
-import com.amazonaws.services.cloudwatch.{AmazonCloudWatchAsync, AmazonCloudWatchAsyncClient}
-import com.amazonaws.services.cloudwatch.model._
-import common.GuLogging
-import conf.Configuration
-import conf.Configuration.environment
-import org.joda.time.DateTime
-import awswrappers.cloudwatch._
-
-import scala.concurrent.{ExecutionContext, Future}
-
-object CloudWatchStats extends GuLogging {
- val stage = new Dimension().withName("Stage").withValue(environment.stage)
-
- lazy val cloudwatch: AmazonCloudWatchAsync = {
- AmazonCloudWatchAsyncClient
- .asyncBuilder()
- .withCredentials(Configuration.aws.mandatoryCredentials)
- .withRegion(conf.Configuration.aws.region)
- .build()
- }
-
- private def sanityData(
- metric: String,
- )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] = {
- val ftr = cloudwatch.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusMinutes(15).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(900)
- .withStatistics("Sum")
- .withNamespace("Diagnostics")
- .withMetricName(metric)
- .withDimensions(stage),
- )
-
- ftr.failed.foreach { exception: Throwable =>
- log.error(s"CloudWatch GetMetricStatisticsRequest error: ${exception.getMessage}", exception)
- }
-
- ftr
- }
-
- def rawPageViews()(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] =
- sanityData("kpis-page-views")
-}
diff --git a/admin/app/services/EmailService.scala b/admin/app/services/EmailService.scala
index ff3c2faa002f..345542545d45 100644
--- a/admin/app/services/EmailService.scala
+++ b/admin/app/services/EmailService.scala
@@ -1,30 +1,24 @@
package services
-import java.util.concurrent.TimeoutException
-
-import com.amazonaws.handlers.AsyncHandler
-import com.amazonaws.services.simpleemail._
-import com.amazonaws.services.simpleemail.model.{Destination => EmailDestination, _}
-import common.{PekkoAsync, GuLogging}
-import conf.Configuration.aws.mandatoryCredentials
+import common.{GuLogging, PekkoAsync}
+import conf.Configuration
+import software.amazon.awssdk.services.ses.SesAsyncClient
+import software.amazon.awssdk.services.ses.model.{Destination => EmailDestination, _}
+import utils.AWSv2
-import scala.jdk.CollectionConverters._
+import java.util.concurrent.TimeoutException
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future, Promise}
import scala.util.control.NonFatal
-import scala.util.{Failure, Success}
class EmailService(pekkoAsync: PekkoAsync) extends GuLogging {
-
- private lazy val client: AmazonSimpleEmailServiceAsync = AmazonSimpleEmailServiceAsyncClient
- .asyncBuilder()
- .withCredentials(mandatoryCredentials)
- .withRegion(conf.Configuration.aws.region)
+ private lazy val client: SesAsyncClient = SesAsyncClient
+ .builder()
+ .credentialsProvider(AWSv2.credentials)
+ .region(AWSv2.region)
.build()
- val sendAsync = client.sendAsyncEmail(pekkoAsync) _
-
- def shutdown(): Unit = client.shutdown()
+ def shutdown(): Unit = client.close()
def send(
from: String,
@@ -33,69 +27,61 @@ class EmailService(pekkoAsync: PekkoAsync) extends GuLogging {
subject: String,
textBody: Option[String] = None,
htmlBody: Option[String] = None,
- )(implicit executionContext: ExecutionContext): Future[SendEmailResult] = {
+ )(implicit executionContext: ExecutionContext): Future[SendEmailResponse] = {
+
+ // Don't send emails in non-prod environments
+ if (Configuration.environment.isNonProd) {
+ log.info(s"Skipping email send in non-prod: from=$from to=$to subject=$subject")
+ return Future.successful(SendEmailResponse.builder().messageId("non-prod-mock").build())
+ }
log.info(s"Sending email from $from to $to about $subject")
- def withText(body: Body): Body = {
- textBody map { text =>
- body.withText(new Content().withData(text))
- } getOrElse body
- }
+ val textPart: Option[Content] = textBody.map(tb => Content.builder().data(tb).build())
+ val htmlPart: Option[Content] = htmlBody.map(hb => Content.builder().data(hb).build())
- def withHtml(body: Body): Body = {
- htmlBody map { html =>
- body.withHtml(new Content().withData(html))
- } getOrElse body
- }
+ val bodyBuilder = Body.builder()
+ textPart.foreach(bodyBuilder.text)
+ htmlPart.foreach(bodyBuilder.html)
+ val body = bodyBuilder.build()
- val body = withHtml(withText(new Body()))
+ val message = Message
+ .builder()
+ .subject(Content.builder().data(subject).build())
+ .body(body)
+ .build()
- val message = new Message()
- .withSubject(new Content().withData(subject))
- .withBody(body)
+ val destinationBuilder = EmailDestination.builder().toAddresses(to: _*)
+ if (cc.nonEmpty) destinationBuilder.ccAddresses(cc: _*)
+ val destination = destinationBuilder.build()
- val request = new SendEmailRequest()
- .withSource(from)
- .withDestination(new EmailDestination().withToAddresses(to.asJava).withCcAddresses(cc.asJava))
- .withMessage(message)
+ val request = SendEmailRequest
+ .builder()
+ .source(from)
+ .destination(destination)
+ .message(message)
+ .build()
- val futureResponse = sendAsync(request)
+ val promise = Promise[SendEmailResponse]()
- futureResponse.foreach { response =>
- log.info(s"Sent message ID ${response.getMessageId}")
+ pekkoAsync.after(1.minute) {
+ promise.tryFailure(new TimeoutException("Timed out"))
}
- futureResponse.failed.foreach { case NonFatal(e) =>
- log.error(s"Email send failed: ${e.getMessage}")
+ val cf = client.sendEmail(request)
+ cf.handle[Unit] { (result: SendEmailResponse, err: Throwable) =>
+ if (err != null) promise.tryFailure(err)
+ else promise.trySuccess(result)
+ ()
}
- futureResponse
- }
-
- private implicit class RichEmailClient(client: AmazonSimpleEmailServiceAsync) {
-
- def sendAsyncEmail(pekkoAsync: PekkoAsync)(request: SendEmailRequest): Future[SendEmailResult] = {
- val promise = Promise[SendEmailResult]()
-
- pekkoAsync.after(1.minute) {
- promise.tryFailure(new TimeoutException(s"Timed out"))
- }
-
- val handler = new AsyncHandler[SendEmailRequest, SendEmailResult] {
- override def onSuccess(request: SendEmailRequest, result: SendEmailResult): Unit =
- promise.complete(Success(result))
- override def onError(exception: Exception): Unit =
- promise.complete(Failure(exception))
- }
-
- try {
- client.sendEmailAsync(request, handler)
- promise.future
- } catch {
- case NonFatal(e) => Future.failed(e)
- }
+ promise.future.foreach { response =>
+ log.info(s"Sent message ID ${response.messageId()}")
+ }
+ promise.future.failed.foreach { case NonFatal(e) =>
+ log.error(s"Email send failed: ${e.getMessage}")
}
- }
+ promise.future
+ }
}
diff --git a/admin/app/services/Fastly.scala b/admin/app/services/Fastly.scala
deleted file mode 100644
index a9532cc8e5c4..000000000000
--- a/admin/app/services/Fastly.scala
+++ /dev/null
@@ -1,87 +0,0 @@
-package services
-
-import common.GuLogging
-import conf.AdminConfiguration.fastly
-import com.amazonaws.services.cloudwatch.model.{Dimension, MetricDatum}
-
-import java.util.Date
-import play.api.libs.ws.WSClient
-import play.api.libs.json.{JsValue, Json, OFormat}
-
-import scala.concurrent.{ExecutionContext, Future}
-import scala.concurrent.duration._
-
-case class FastlyStatistic(service: String, region: String, timestamp: Long, name: String, value: String) {
- lazy val key: (String, String, String) = (service, name, region)
-
- lazy val metric = new MetricDatum()
- .withMetricName(name)
- .withDimensions(
- new Dimension().withName("service").withValue(service),
- new Dimension().withName("region").withValue(region),
- )
- .withTimestamp(new Date(timestamp))
- .withValue(value.toDouble)
-}
-
-class FastlyStatisticService(wsClient: WSClient) extends GuLogging {
-
- private case class FastlyApiStat(
- hits: Int,
- miss: Int,
- errors: Int,
- service_id: String,
- start_time: Long,
- )
-
- private implicit val FastlyApiStatFormat: OFormat[FastlyApiStat] = Json.format[FastlyApiStat]
-
- private val regions = List("usa", "europe", "ausnz")
-
- def fetch()(implicit executionContext: ExecutionContext): Future[List[FastlyStatistic]] = {
-
- val futureResponses: Future[List[JsValue]] = Future
- .sequence {
- regions map { region =>
- val request = wsClient
- .url(
- s"https://api.fastly.com/stats/service/${fastly.serviceId}?by=minute&from=45+minutes+ago&to=15+minutes+ago®ion=$region",
- )
- .withHttpHeaders("Fastly-Key" -> fastly.key)
- .withRequestTimeout(20.seconds)
-
- val response: Future[Option[JsValue]] =
- request.get().map { resp => Some(resp.json) }.recover { case e: Throwable =>
- log.error(s"Error with request to api.fastly.com: ${e.getMessage}")
- None
- }
- response
- }
-
- }
- .map(_.flatten)
-
- futureResponses map { responses =>
- responses flatMap { json =>
- val samples: List[FastlyApiStat] = (json \ "data").validate[List[FastlyApiStat]].getOrElse(Nil)
- val region: String = (json \ "meta" \ "region").as[String]
-
- log.info(s"Loaded ${samples.size} Fastly statistics results for region: $region")
-
- samples flatMap { sample: FastlyApiStat =>
- val service: String = sample.service_id
- val timestamp: Long = sample.start_time * 1000
- val statistics: List[(String, String)] = List(
- ("hits", sample.hits.toString),
- ("miss", sample.miss.toString),
- ("errors", sample.errors.toString),
- )
-
- statistics map { case (name, stat) =>
- FastlyStatistic(service, region, timestamp, name, stat)
- }
- }
- }
- }
- }
-}
diff --git a/admin/app/tools/AssetMetrics.scala b/admin/app/tools/AssetMetrics.scala
deleted file mode 100644
index 8bed8295839e..000000000000
--- a/admin/app/tools/AssetMetrics.scala
+++ /dev/null
@@ -1,103 +0,0 @@
-package tools
-
-import awswrappers.cloudwatch._
-import com.amazonaws.services.cloudwatch.model._
-import common.{Box, GuLogging}
-import org.joda.time.DateTime
-import tools.CloudWatch._
-
-import scala.jdk.CollectionConverters._
-import scala.concurrent.{ExecutionContext, Future}
-import scala.math.BigDecimal
-import scala.util.control.NonFatal
-
-case class AssetMetric(name: String, metric: GetMetricStatisticsResult, yLabel: String) {
- lazy val chart = new AwsLineChart(name, Seq(yLabel, name), ChartFormat.SingleLineBlack, metric)
- lazy val change = BigDecimal(
- chart.dataset.last.values.headOption.getOrElse(0.0) - chart.dataset.head.values.headOption.getOrElse(0.0),
- ).setScale(2, BigDecimal.RoundingMode.HALF_UP).toFloat
-}
-
-object AssetMetrics {
-
- private val timePeriodInDays = 14 // Cloudwatch metric retention period is 14 days
-
- private val gzipped = new Dimension().withName("Compression").withValue("GZip")
- private val raw = new Dimension().withName("Compression").withValue("None")
- private val rules = new Dimension().withName("Metric").withValue("Rules")
- private val selectors = new Dimension().withName("Metric").withValue("Total Selectors")
-
- private def fetchMetric(metric: Metric, dimension: Dimension)(implicit
- executionContext: ExecutionContext,
- ): Future[GetMetricStatisticsResult] =
- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusDays(timePeriodInDays).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(86400) // One day
- .withStatistics("Average")
- .withNamespace("Assets")
- .withMetricName(metric.getMetricName)
- .withDimensions(dimension),
- ),
- )
-
- private def allMetrics()(implicit executionContext: ExecutionContext): Future[ListMetricsResult] =
- withErrorLogging(euWestClient.listMetricsFuture(new ListMetricsRequest().withNamespace("Assets")))
-
- private def metricResults(
- dimension: Dimension,
- )(implicit executionContext: ExecutionContext): Future[List[GetMetricStatisticsResult]] =
- allMetrics().flatMap { metricsList =>
- Future.sequence {
- metricsList.getMetrics.asScala
- .filter(_.getDimensions.contains(dimension))
- .toList
- .map { metric =>
- fetchMetric(metric, dimension)
- }
- }
- }
-
- private def metrics(dimension: Dimension, yLabel: String = "")(implicit
- executionContext: ExecutionContext,
- ): Future[List[AssetMetric]] =
- metricResults(dimension).map(
- _.map { result =>
- AssetMetric(result.getLabel, result, yLabel)
- },
- )
-
- // Public methods
-
- def sizeMetrics()(implicit executionContext: ExecutionContext): Future[List[AssetMetric]] =
- metrics(dimension = gzipped, yLabel = "Size").map(_.sortBy(m => (-m.change, m.name)))
-}
-
-object AssetMetricsCache extends GuLogging {
-
- sealed trait ReportType
- object ReportTypes {
- case object sizeOfFiles extends ReportType
- }
-
- private val cache = Box[Map[ReportType, List[AssetMetric]]](Map.empty)
-
- private def getReport(reportType: ReportType): Option[List[AssetMetric]] = cache().get(reportType)
-
- def run()(implicit executionContext: ExecutionContext): Future[Unit] = {
- AssetMetrics
- .sizeMetrics()
- .map { metrics =>
- log.info("Successfully refreshed Asset Metrics data")
- cache.send(cache.get() + (ReportTypes.sizeOfFiles -> metrics))
- }
- .recover { case NonFatal(e) =>
- log.error("Error refreshing Asset Metrics data", e)
- }
- }
-
- def sizes: List[AssetMetric] = getReport(ReportTypes.sizeOfFiles).getOrElse(List.empty[AssetMetric])
-
-}
diff --git a/admin/app/tools/CloudWatch.scala b/admin/app/tools/CloudWatch.scala
index 3a58f6946151..76ddb88cf815 100644
--- a/admin/app/tools/CloudWatch.scala
+++ b/admin/app/tools/CloudWatch.scala
@@ -1,43 +1,42 @@
package tools
-import awswrappers.cloudwatch._
-import com.amazonaws.services.cloudwatch.{
- AmazonCloudWatchAsync,
- AmazonCloudWatchAsyncClient,
- AmazonCloudWatchAsyncClientBuilder,
-}
-import com.amazonaws.services.cloudwatch.model._
import common.GuLogging
-import conf.Configuration
import conf.Configuration._
-import org.joda.time.DateTime
+import software.amazon.awssdk.regions.Region
+import software.amazon.awssdk.services.cloudwatch.{CloudWatchAsyncClient, CloudWatchAsyncClientBuilder}
+import software.amazon.awssdk.services.cloudwatch.model.{
+ Dimension,
+ DimensionFilter,
+ ListMetricsRequest,
+ ListMetricsResponse,
+}
+import utils.AWSv2
+import scala.jdk.FutureConverters._
-import scala.jdk.CollectionConverters._
import scala.concurrent.{ExecutionContext, Future}
-case class MaximumMetric(metric: GetMetricStatisticsResult) {
- lazy val max: Double = metric.getDatapoints.asScala.headOption.map(_.getMaximum.doubleValue()).getOrElse(0.0)
-}
-
object CloudWatch extends GuLogging {
- def shutdown(): Unit = {
- euWestClient.shutdown()
- defaultClient.shutdown()
+
+ def close(): Unit = {
+ euWestClient.close()
+ defaultClient.close()
}
- val stage = new Dimension().withName("Stage").withValue(environment.stage)
- val stageFilter = new DimensionFilter().withName("Stage").withValue(environment.stage)
+ val stage = Dimension.builder().name("Stage").value(environment.stage).build()
+ val stageFilter = DimensionFilter.builder().name("Stage").value(environment.stage).build()
- lazy val defaultClientBuilder: AmazonCloudWatchAsyncClientBuilder = AmazonCloudWatchAsyncClient
- .asyncBuilder()
- .withCredentials(Configuration.aws.mandatoryCredentials)
+ lazy val defaultClientBuilder: CloudWatchAsyncClientBuilder =
+ CloudWatchAsyncClient.builder().credentialsProvider(AWSv2.credentials)
- lazy val euWestClient: AmazonCloudWatchAsync = defaultClientBuilder
- .withRegion(conf.Configuration.aws.region)
+ lazy val euWestClient: CloudWatchAsyncClient = defaultClientBuilder
+ .region(Region.of(conf.Configuration.aws.region))
.build()
// some metrics are only available in the 'default' region
- lazy val defaultClient: AmazonCloudWatchAsync = defaultClientBuilder.build()
+ lazy val defaultClient: CloudWatchAsyncClient = defaultClientBuilder.build()
+
+ val v1LoadBalancerNamespace = "AWS/ELB"
+ val v2LoadBalancerNamespace = "AWS/ApplicationELB"
val primaryLoadBalancers: Seq[LoadBalancer] = Seq(
LoadBalancer("frontend-router"),
@@ -57,47 +56,8 @@ object CloudWatch extends GuLogging {
LoadBalancer("frontend-rss"),
).flatten
- private val chartColours = Map(
- ("frontend-router", ChartFormat(Colour.`tone-news-1`)),
- ("frontend-article", ChartFormat(Colour.`tone-news-1`)),
- ("frontend-facia", ChartFormat(Colour.`tone-news-1`)),
- ("frontend-applications", ChartFormat(Colour.`tone-news-1`)),
- ("frontend-discussion", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-identity", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-sport", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-commercial", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-onward", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-r2football", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-diagnostics", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-archive", ChartFormat(Colour.`tone-news-2`)),
- ("frontend-rss", ChartFormat(Colour.`tone-news-2`)),
- ).withDefaultValue(ChartFormat.SingleLineBlack)
-
val loadBalancers = primaryLoadBalancers ++ secondaryLoadBalancers
- private val fastlyMetrics = List(
- ("Fastly Errors (Europe) - errors per minute, average", "europe-errors"),
- ("Fastly Errors (USA) - errors per minute, average", "usa-errors"),
- )
-
- private val fastlyHitMissMetrics = List(
- ("Fastly Hits and Misses (Europe) - per minute, average", "europe"),
- ("Fastly Hits and Misses (USA) - per minute, average", "usa"),
- )
-
- val assetsFiles = Seq(
- "app.js",
- "commercial.js",
- "facia.js",
- "content.css",
- "head.commercial.css",
- "head.content.css",
- "head.facia.css",
- "head.football.css",
- "head.identity.css",
- "head.index.css",
- )
-
def withErrorLogging[A](future: Future[A])(implicit executionContext: ExecutionContext): Future[A] = {
future.failed.foreach { exception: Throwable =>
log.error(s"CloudWatch error: ${exception.getMessage}", exception)
@@ -105,310 +65,17 @@ object CloudWatch extends GuLogging {
future
}
- def fetchLatencyMetric(
- loadBalancer: LoadBalancer,
- )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] =
- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(2).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(60)
- .withUnit(StandardUnit.Seconds)
- .withStatistics("Average")
- .withNamespace("AWS/ELB")
- .withMetricName("Latency")
- .withDimensions(new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id)),
- ),
- )
-
- private def latency(
- loadBalancers: Seq[LoadBalancer],
- )(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] = {
- Future.traverse(loadBalancers) { loadBalancer =>
- fetchLatencyMetric(loadBalancer).map { metricsResult =>
- new AwsLineChart(
- loadBalancer.name,
- Seq("Time", "latency (ms)"),
- chartColours(loadBalancer.project),
- metricsResult,
- )
- }
- }
- }
-
- def fullStackLatency()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] =
- latency(primaryLoadBalancers ++ secondaryLoadBalancers)
-
- def fetchOkMetric(
- loadBalancer: LoadBalancer,
- )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] =
- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(2).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(60)
- .withStatistics("Sum")
- .withNamespace("AWS/ELB")
- .withMetricName("HTTPCode_Backend_2XX")
- .withDimensions(new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id)),
- ),
- )
-
- def dualOkLatency(
- loadBalancers: Seq[LoadBalancer],
- )(implicit executionContext: ExecutionContext): Future[Seq[AwsDualYLineChart]] = {
- Future.traverse(loadBalancers) { loadBalancer =>
- for {
- oks <- fetchOkMetric(loadBalancer)
- latency <- fetchLatencyMetric(loadBalancer)
- healthyHosts <- fetchHealthyHostMetric(loadBalancer)
- } yield {
- val chartTitle = s"${loadBalancer.name} - ${healthyHosts.getDatapoints.asScala.last.getMaximum.toInt} instances"
- new AwsDualYLineChart(
- chartTitle,
- ("Time", "2xx/minute", "latency (secs)"),
- ChartFormat(Colour.`tone-news-1`, Colour.`tone-comment-1`),
- oks,
- latency,
- )
- }
- }
- }
-
- def dualOkLatencyFullStack()(implicit executionContext: ExecutionContext): Future[Seq[AwsDualYLineChart]] =
- dualOkLatency(primaryLoadBalancers ++ secondaryLoadBalancers)
-
- def fetchHealthyHostMetric(
- loadBalancer: LoadBalancer,
- )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] =
+ def AbMetricNames()(implicit executionContext: ExecutionContext): Future[ListMetricsResponse] = {
withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(2).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(60)
- .withStatistics("Maximum")
- .withNamespace("AWS/ELB")
- .withMetricName("HealthyHostCount")
- .withDimensions(new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id)),
- ),
- )
-
- def fastlyErrors()(implicit executionContext: ExecutionContext): Future[List[AwsLineChart]] =
- Future.traverse(fastlyMetrics) { case (graphTitle, metric) =>
- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(6).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(120)
- .withStatistics("Average")
- .withNamespace("Fastly")
- .withDimensions(stage)
- .withMetricName(metric),
- ),
- ) map { metricsResult =>
- new AwsLineChart(graphTitle, Seq("Time", metric), ChartFormat(Colour.`tone-features-2`), metricsResult)
- }
- }
-
- def fastlyHitMissStatistics()(implicit executionContext: ExecutionContext): Future[List[AwsLineChart]] =
- Future.traverse(fastlyHitMissMetrics) { case (graphTitle, region) =>
- for {
- hits <- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(6).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(120)
- .withStatistics("Average")
- .withNamespace("Fastly")
- .withMetricName(s"$region-hits")
- .withDimensions(stage),
- ),
- )
-
- misses <- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(6).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(120)
- .withStatistics("Average")
- .withNamespace("Fastly")
- .withMetricName(s"$region-miss")
- .withDimensions(stage),
- ),
+ euWestClient
+ .listMetrics(
+ ListMetricsRequest
+ .builder()
+ .namespace("AbTests")
+ .dimensions(stageFilter)
+ .build(),
)
- } yield new AwsLineChart(
- graphTitle,
- Seq("Time", "Hits", "Misses"),
- ChartFormat(Colour.success, Colour.error),
- hits,
- misses,
- )
- }
-
- def confidenceGraph(metricName: String)(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
- for {
- percentConversion <- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusWeeks(2).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(900)
- .withStatistics("Average")
- .withNamespace("Analytics")
- .withMetricName(metricName)
- .withDimensions(stage),
- ),
- )
- } yield new AwsLineChart(metricName, Seq("Time", "%"), ChartFormat.SingleLineBlue, percentConversion)
-
- def ophanConfidence()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
- confidenceGraph("ophan-percent-conversion")
-
- def googleConfidence()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
- confidenceGraph("google-percent-conversion")
-
- def routerBackend50x()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = {
- val dimension = new Dimension()
- .withName("LoadBalancerName")
- .withValue(LoadBalancer("frontend-router").fold("unknown")(_.id))
- for {
- metric <- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(2).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(60)
- .withStatistics("Sum")
- .withNamespace("AWS/ELB")
- .withMetricName("HTTPCode_Backend_5XX")
- .withDimensions(dimension),
- ),
- )
- } yield new AwsLineChart("Router 50x", Seq("Time", "50x/min"), ChartFormat.SingleLineRed, metric)
- }
-
- object headlineTests {
-
- private def get(
- metricName: String,
- )(implicit executionContext: ExecutionContext): Future[GetMetricStatisticsResult] =
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(6).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(60)
- .withStatistics("Sum")
- .withNamespace("Diagnostics")
- .withMetricName(metricName)
- .withDimensions(stage),
- )
-
- def control()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
- withErrorLogging(
- for {
- viewed <- get("headlines-control-seen")
- clicked <- get("headlines-control-clicked")
- } yield new AwsLineChart(
- "Control Group",
- Seq("", "Saw the headline", "Clicked the headline"),
- ChartFormat.DoubleLineBlueRed,
- viewed,
- clicked,
- ),
- )
-
- def variant()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
- withErrorLogging(
- for {
- viewed <- get("headlines-variant-seen")
- clicked <- get("headlines-variant-clicked")
- } yield new AwsLineChart(
- "Test Group",
- Seq("cccc", "Saw the headline", "Clicked the headline"),
- ChartFormat.DoubleLineBlueRed,
- viewed,
- clicked,
- ),
- )
- }
-
- def AbMetricNames()(implicit executionContext: ExecutionContext): Future[ListMetricsResult] = {
- withErrorLogging(
- euWestClient.listMetricsFuture(
- new ListMetricsRequest()
- .withNamespace("AbTests")
- .withDimensions(stageFilter),
- ),
+ .asScala,
)
}
-
- def eventualAdResponseConfidenceGraph()(implicit executionContext: ExecutionContext): Future[AwsLineChart] = {
-
- def getMetric(metricName: String): Future[GetMetricStatisticsResult] = {
- val now = DateTime.now()
- withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- new GetMetricStatisticsRequest()
- .withNamespace("Diagnostics")
- .withMetricName(metricName)
- .withStartTime(now.minusWeeks(2).toDate)
- .withEndTime(now.toDate)
- .withPeriod(900)
- .withStatistics("Sum")
- .withDimensions(stage),
- ),
- )
- }
-
- def compare(
- pvCount: GetMetricStatisticsResult,
- pvWithAdCount: GetMetricStatisticsResult,
- ): GetMetricStatisticsResult = {
-
- val pvWithAdCountMap = pvWithAdCount.getDatapoints.asScala.map { point =>
- point.getTimestamp -> point.getSum.toDouble
- }.toMap
-
- val confidenceValues = pvCount.getDatapoints.asScala.foldLeft(List.empty[Datapoint]) {
- case (soFar, pvCountValue) =>
- val confidenceValue = pvWithAdCountMap
- .get(pvCountValue.getTimestamp)
- .map { pvWithAdCountValue =>
- pvWithAdCountValue * 100 / pvCountValue.getSum.toDouble
- }
- .getOrElse(0d)
- soFar :+ new Datapoint().withTimestamp(pvCountValue.getTimestamp).withSum(confidenceValue)
- }
-
- new GetMetricStatisticsResult().withDatapoints(confidenceValues.asJava)
- }
-
- for {
- pageViewCount <- getMetric("kpis-page-views")
- pageViewWithAdCount <- getMetric("first-ad-rendered")
- } yield {
- val confidenceMetric = compare(pageViewCount, pageViewWithAdCount)
- val averageMetric = {
- val dataPoints = confidenceMetric.getDatapoints
- val average = dataPoints.asScala.map(_.getSum.toDouble).sum / dataPoints.asScala.length
- val averageDataPoints = dataPoints.asScala map { point =>
- new Datapoint().withTimestamp(point.getTimestamp).withSum(average)
- }
- new GetMetricStatisticsResult().withDatapoints(averageDataPoints.asJava)
- }
- new AwsLineChart(
- name = "Ad Response Confidence",
- labels = Seq("Time", "%", "avg."),
- ChartFormat(Colour.`tone-comment-2`, Colour.success),
- charts = confidenceMetric,
- averageMetric,
- )
- }
- }
}
diff --git a/admin/app/tools/DfpLink.scala b/admin/app/tools/DfpLink.scala
index 419c386f347f..2d5964c95884 100644
--- a/admin/app/tools/DfpLink.scala
+++ b/admin/app/tools/DfpLink.scala
@@ -9,14 +9,6 @@ object DfpLink {
s"https://www.google.com/dfp/$dfpAccountId#delivery/LineItemDetail/lineItemId=$lineItemId"
}
- def creativeTemplate(templateId: Long): String = {
- s"https://www.google.com/dfp/$dfpAccountId#delivery/CreateCreativeTemplate/creativeTemplateId=$templateId"
- }
-
- def creative(creativeId: Long): String = {
- s"https://www.google.com/dfp/$dfpAccountId#delivery/CreativeDetail/creativeId=$creativeId"
- }
-
def adUnit(adUnitId: String): String = {
s"https://www.google.com/dfp/59666047?#inventory/inventory/adSlotId=$adUnitId"
}
diff --git a/admin/app/tools/LoadBalancer.scala b/admin/app/tools/LoadBalancer.scala
index fe380fec634d..7ec3584060d1 100644
--- a/admin/app/tools/LoadBalancer.scala
+++ b/admin/app/tools/LoadBalancer.scala
@@ -1,7 +1,10 @@
package tools
import common.{Box, GuLogging}
-import com.amazonaws.services.elasticloadbalancing.AmazonElasticLoadBalancingClient
+import software.amazon.awssdk.regions.Region
+import software.amazon.awssdk.services.elasticloadbalancing.ElasticLoadBalancingClient
+import software.amazon.awssdk.services.elasticloadbalancing.model.DescribeLoadBalancersRequest
+import utils.AWSv2
import scala.jdk.CollectionConverters._
@@ -11,48 +14,92 @@ case class LoadBalancer(
project: String,
url: Option[String] = None,
testPath: Option[String] = None,
+ // Application load balancers (v2) have target groups, classic load balancers (v1) do not
+ targetGroup: Option[String] = None,
)
object LoadBalancer extends GuLogging {
- import conf.Configuration.aws.credentials
-
private val loadBalancers = Seq(
LoadBalancer("frontend-PROD-router-ELB", "Router", "frontend-router"),
LoadBalancer(
- "frontend-PROD-article-ELB",
+ "app/fronte-LoadB-xXnA5yhOxB7G/cf797b52302fc833",
"Article",
"frontend-article",
testPath = Some("/uk-news/2014/jan/21/drax-protesters-convictions-quashed-police-spy-mark-kennedy"),
+ targetGroup = Some("targetgroup/fronte-Targe-YYBO08CFBLH9/19192d72461d4042"),
+ ),
+ LoadBalancer(
+ "app/fronte-LoadB-qTAetyigfHhb/f4371301ea282f8a",
+ "Front",
+ "frontend-facia",
+ testPath = Some("/uk"),
+ targetGroup = Some("targetgroup/fronte-Targe-RG4PYAXIXEAH/c4104696046f2543"),
+ ),
+ LoadBalancer(
+ "app/fronte-LoadB-jjbgLSz4Ttk7/0e30c8ef528bd918",
+ "Applications",
+ "frontend-applications",
+ testPath = Some("/books"),
+ targetGroup = Some("targetgroup/fronte-Targe-J5GTY7IUW6U4/5fa6083486dbd785"),
+ ),
+ LoadBalancer(
+ "app/fronte-LoadB-xmdWiUUHyRUS/122c59735e8374bb",
+ "Discussion",
+ "frontend-discussion",
+ targetGroup = Some("targetgroup/fronte-Targe-JGOOGIGPNWQJ/187642c8eda54a4a"),
),
- LoadBalancer("frontend-PROD-facia-ELB", "Front", "frontend-facia", testPath = Some("/uk")),
- LoadBalancer("frontend-PROD-applications-ELB", "Applications", "frontend-applications", testPath = Some("/books")),
- LoadBalancer("frontend-PROD-discussion-ELB", "Discussion", "frontend-discussion"),
LoadBalancer("frontend-PROD-identity-ELB", "Identity", "frontend-identity"),
- LoadBalancer("frontend-PROD-sport-ELB", "Sport", "frontend-sport"),
- LoadBalancer("frontend-PROD-commercial-ELB", "Commercial", "frontend-commercial"),
- LoadBalancer("frontend-PROD-onward-ELB", "Onward", "frontend-onward"),
- LoadBalancer("frontend-PROD-archive-ELB", "Archive", "frontend-archive"),
- LoadBalancer("frontend-PROD-rss-ELB", "Rss", "frontend-rss"),
+ LoadBalancer(
+ "app/fronte-LoadB-t2NTzJp2RZFf/4119950dc35e5cb4",
+ "Sport",
+ "frontend-sport",
+ targetGroup = Some("targetgroup/fronte-Targe-LJMDWMGH5FPD/e777dd4276b0bf29"),
+ ),
+ LoadBalancer(
+ "app/fronte-LoadB-4KxztKWTJxEu/faee39a2eecb4c1a",
+ "Commercial",
+ "frontend-commercial",
+ targetGroup = Some("targetgroup/fronte-Targe-C8VZWOPZ3TTS/271f997aea40fb19"),
+ ),
+ LoadBalancer(
+ "app/fronte-LoadB-NpLaks0rT7va/e5a6b5bea5119952",
+ "Onward",
+ "frontend-onward",
+ targetGroup = Some("targetgroup/fronte-Targe-N0YDVRHJB7IM/99164208e6758b4e"),
+ ),
+ LoadBalancer(
+ "app/fronte-LoadB-wSjta29AZxoG/32048dda4b467613",
+ "Archive",
+ "frontend-archive",
+ targetGroup = Some("targetgroup/fronte-Targe-CVM11DC1XUEX/5980205ce24de6bf"),
+ ),
+ LoadBalancer(
+ "app/fronte-LoadB-lVDfejahHxTX/5cfe31b29fa71749",
+ "Rss",
+ "frontend-rss",
+ targetGroup = Some("targetgroup/fronte-Targe-1UWQX08K530W/4da53e8e56d13ab8"),
+ ),
)
private val agent = Box(loadBalancers)
def refresh(): Unit = {
log.info("starting refresh LoadBalancer ELB DNS names")
- credentials.foreach { credentials =>
- val client = AmazonElasticLoadBalancingClient
- .builder()
- .withCredentials(credentials)
- .withRegion(conf.Configuration.aws.region)
- .build()
- val elbs = client.describeLoadBalancers().getLoadBalancerDescriptions
- client.shutdown()
- val newLoadBalancers = loadBalancers.map { lb =>
- lb.copy(url = elbs.asScala.find(_.getLoadBalancerName == lb.id).map(_.getDNSName))
- }
- agent.send(newLoadBalancers)
+
+ val client = ElasticLoadBalancingClient
+ .builder()
+ .credentialsProvider(AWSv2.credentials)
+ .region(Region.of(conf.Configuration.aws.region))
+ .build()
+ val response = client.describeLoadBalancers(DescribeLoadBalancersRequest.builder().build())
+ val elbs = response.loadBalancerDescriptions()
+ client.close()
+ val newLoadBalancers = loadBalancers.map { lb =>
+ lb.copy(url = elbs.asScala.find(_.loadBalancerName() == lb.id).map(_.dnsName()))
}
+ agent.send(newLoadBalancers)
+
log.info("finished refresh LoadBalancer ELB DNS names")
}
diff --git a/admin/app/tools/Store.scala b/admin/app/tools/Store.scala
index 9f4bac998a46..5c42faaf62e6 100644
--- a/admin/app/tools/Store.scala
+++ b/admin/app/tools/Store.scala
@@ -3,6 +3,7 @@ package tools
import common.GuLogging
import common.dfp._
import conf.Configuration.commercial._
+import conf.Configuration.abTesting._
import conf.{AdminConfiguration, Configuration}
import implicits.Dates
import org.joda.time.DateTime
@@ -19,34 +20,13 @@ trait Store extends GuLogging with Dates {
def getSwitches: Option[String] = S3.get(switchesKey)
def getSwitchesWithLastModified: Option[(String, DateTime)] = S3.getWithLastModified(switchesKey)
def getSwitchesLastModified: Option[DateTime] = S3.getLastModified(switchesKey)
- def putSwitches(config: String): Unit = { S3.putPublic(switchesKey, config, "text/plain") }
+ def putSwitches(config: String): Unit = { S3.putPrivate(switchesKey, config, "text/plain") }
def getTopStories: Option[String] = S3.get(topStoriesKey)
def putTopStories(config: String): Unit = { S3.putPublic(topStoriesKey, config, "application/json") }
- def putLiveBlogTopSponsorships(sponsorshipsJson: String): Unit = {
- S3.putPublic(dfpLiveBlogTopSponsorshipDataKey, sponsorshipsJson, defaultJsonEncoding)
- }
- def putSurveySponsorships(adUnitJson: String): Unit = {
- S3.putPublic(dfpSurveySponsorshipDataKey, adUnitJson, defaultJsonEncoding)
- }
- def putDfpPageSkinAdUnits(adUnitJson: String): Unit = {
- S3.putPublic(dfpPageSkinnedAdUnitsKey, adUnitJson, defaultJsonEncoding)
- }
def putDfpLineItemsReport(everything: String): Unit = {
- S3.putPublic(dfpLineItemsKey, everything, defaultJsonEncoding)
- }
- def putDfpAdUnitList(filename: String, adUnits: String): Unit = {
- S3.putPublic(filename, adUnits, "text/plain")
- }
- def putDfpTemplateCreatives(creatives: String): Unit = {
- S3.putPublic(dfpTemplateCreativesKey, creatives, defaultJsonEncoding)
- }
- def putDfpCustomTargetingKeyValues(keyValues: String): Unit = {
- S3.putPublic(dfpCustomTargetingKey, keyValues, defaultJsonEncoding)
- }
- def putNonRefreshableLineItemIds(lineItemIds: Seq[Long]): Unit = {
- S3.putPublic(dfpNonRefreshableLineItemIdsKey, Json.stringify(toJson(lineItemIds)), defaultJsonEncoding)
+ S3.putPrivate(dfpLineItemsKey, everything, defaultJsonEncoding)
}
val now: String = DateTime.now().toHttpDateTimeString
@@ -83,13 +63,6 @@ trait Store extends GuLogging with Dates {
maybeLineItems getOrElse LineItemReport("Empty Report", Nil, Nil)
}
- def getDfpTemplateCreatives: Seq[GuCreative] = {
- val creatives = for (doc <- S3.get(dfpTemplateCreativesKey)) yield {
- Json.parse(doc).as[Seq[GuCreative]]
- }
- creatives getOrElse Nil
- }
-
def getDfpCustomTargetingKeyValues: Seq[GuCustomTargeting] = {
val targeting = for (doc <- S3.get(dfpCustomTargetingKey)) yield {
val json = Json.parse(doc)
@@ -101,18 +74,22 @@ trait Store extends GuLogging with Dates {
targeting getOrElse Nil
}
- object commercial {
-
- def getTakeoversWithEmptyMPUs(): Seq[TakeoverWithEmptyMPUs] = {
- S3.get(takeoversWithEmptyMPUsKey) map {
- Json.parse(_).as[Seq[TakeoverWithEmptyMPUs]]
- } getOrElse Nil
+ def getDfpCustomFields: Seq[GuCustomField] = {
+ val customFields = for (doc <- S3.get(dfpCustomFieldsKey)) yield {
+ Json.parse(doc).as[Seq[GuCustomField]]
}
+ customFields getOrElse Nil
+ }
+
+ def getAbTestFrameUrl: Option[String] = {
+ S3.getPresignedUrl(uiHtmlObjectKey)
+ }
- def putTakeoversWithEmptyMPUs(takeovers: Seq[TakeoverWithEmptyMPUs]): Unit = {
- val content = Json.stringify(toJson(takeovers))
- S3.putPrivate(takeoversWithEmptyMPUsKey, content, "application/json")
+ def getDfpSpecialAdUnits: Seq[(String, String)] = {
+ val specialAdUnits = for (doc <- S3.get(dfpSpecialAdUnitsKey)) yield {
+ Json.parse(doc).as[Seq[(String, String)]]
}
+ specialAdUnits getOrElse Nil
}
}
diff --git a/admin/app/tools/charts/charts.scala b/admin/app/tools/charts/charts.scala
index 2be583f82d92..060b5405d754 100644
--- a/admin/app/tools/charts/charts.scala
+++ b/admin/app/tools/charts/charts.scala
@@ -1,11 +1,10 @@
package tools
import java.util.{Date, UUID}
-
-import com.amazonaws.services.cloudwatch.model.{Datapoint, GetMetricStatisticsResult}
import common.editions.Uk
import org.joda.time.{DateTime, DateTimeZone}
import play.api.libs.json._
+import software.amazon.awssdk.services.cloudwatch.model.{Datapoint, GetMetricStatisticsResponse}
import scala.jdk.CollectionConverters._
import scala.collection.mutable.{Map => MutableMap}
@@ -22,7 +21,6 @@ class ChartTable(private val labels: Seq[String]) {
MutableMap.empty[String, ChartColumn].withDefaultValue(ChartColumn(Nil))
def column(label: String): ChartColumn = datapoints(label)
- def allColumns: Seq[ChartColumn] = datapoints.values.toSeq
def addColumn(label: String, data: ChartColumn): Unit = {
datapoints += ((label, data))
@@ -36,8 +34,8 @@ class ChartTable(private val labels: Seq[String]) {
label <- labels
datapoint <- column(label).values
} yield {
- val oldRow = rows(datapoint.getTimestamp)
- rows.update(datapoint.getTimestamp, oldRow ::: List(toValue(datapoint)))
+ val oldRow = rows(Date.from(datapoint.timestamp()))
+ rows.update(Date.from(datapoint.timestamp()), oldRow ::: List(toValue(datapoint)))
}
val chartRows = for {
@@ -78,31 +76,11 @@ trait Chart[K] {
case class ChartFormat(colours: Seq[String], cssClass: String = "charts", timezone: DateTimeZone = Uk.timezone)
-object Colour {
- val `tone-news-1` = "#005689"
- val `tone-news-2` = "#4bc6df"
- val `tone-features-1` = "#951c55"
- val `tone-features-2` = "#f66980"
- val `tone-features-3` = "#b82266"
- val `tone-features-4` = "#7d0068"
- val `tone-comment-1` = "#e6711b"
- val `tone-comment-2` = "#ffbb00"
- val `tone-comment-3` = "#ffcf4c"
- val `tone-live-1` = "#b51800"
- val `tone-live-2` = "#cc2b12"
- val error = "#d61d00"
- val success = "#4a7801"
-}
-
object ChartFormat {
val SingleLineBlack = ChartFormat(colours = Seq("#000000"))
val SingleLineBlue = ChartFormat(colours = Seq("#0033CC"))
- val SingleLineGreen = ChartFormat(colours = Seq("#00CC33"))
val SingleLineRed = ChartFormat(colours = Seq("#FF0000"))
- val DoubleLineBlueRed = ChartFormat(colours = Seq("#0033CC", "#FF0000"))
- val MultiLine =
- ChartFormat(colours = Seq("#FF6600", "#99CC33", "#CC0066", "#660099", "#0099FF"), cssClass = "charts charts-full")
def apply(colour: String*): ChartFormat = ChartFormat(colour)
}
@@ -111,7 +89,7 @@ class AwsLineChart(
override val name: String,
override val labels: Seq[String],
override val format: ChartFormat,
- val charts: GetMetricStatisticsResult*,
+ val charts: GetMetricStatisticsResponse*,
) extends Chart[String] {
override def dataset: Seq[ChartRow[String]] = {
@@ -121,15 +99,15 @@ class AwsLineChart(
dataColumns
.lazyZip(charts.toList)
.map((column, chart) => {
- table.addColumn(column, ChartColumn(chart.getDatapoints.asScala.toSeq))
+ table.addColumn(column, ChartColumn(chart.datapoints().asScala.toSeq))
})
table.asChartRow(toLabel, toValue)
}
protected def toValue(dataPoint: Datapoint): Double =
- Option(dataPoint.getAverage)
- .orElse(Option(dataPoint.getSum))
+ Option(dataPoint.average())
+ .orElse(Option(dataPoint.sum()))
.getOrElse(throw new IllegalStateException(s"Don't know how to get a value for $dataPoint"))
protected def toLabel(date: DateTime): String = date.withZone(format.timezone).toString("HH:mm")
@@ -139,47 +117,6 @@ class AwsLineChart(
def formatRowKey(key: String): String = s"'$key'"
}
-class AwsDailyLineChart(name: String, labels: Seq[String], format: ChartFormat, charts: GetMetricStatisticsResult*)
- extends AwsLineChart(name, labels, format, charts: _*) {
- override def toLabel(date: DateTime): String = date.withZone(format.timezone).toString("dd/MM")
-}
-
-class AwsDualYLineChart(
- name: String,
- labels: (String, String, String),
- format: ChartFormat,
- chartOne: GetMetricStatisticsResult,
- chartTwo: GetMetricStatisticsResult,
-) extends AwsLineChart(name, Seq(labels._1, labels._2, labels._3), format, chartOne, chartTwo) {
- override def dualY: Boolean = true
-}
-
-class ABDataChart(name: String, ablabels: Seq[String], format: ChartFormat, charts: GetMetricStatisticsResult*)
- extends AwsLineChart(name, ablabels, format, charts: _*) {
-
- private val dataColumns: Seq[(String, ChartColumn)] = {
-
- // Do not consider any metrics that have less than three data points.
- ablabels.tail
- .lazyZip(charts.toList)
- .map((column, chart) => (column, ChartColumn(chart.getDatapoints.asScala.toSeq)))
- .filter { case (label, column) => column.values.length > 3 }
- }
-
- override def dataset: Seq[ChartRow[String]] = {
-
- val filteredTable = new ChartTable(dataColumns.map(_._1))
-
- for (column <- dataColumns) {
- filteredTable.addColumn(column._1, column._2)
- }
-
- filteredTable.asChartRow(toLabel, toValue)
- }
-
- override val labels: Seq[String] = Seq(ablabels.headOption.getOrElse("X axis")) ++ dataColumns.map(_._1)
-}
-
object FormattedChart {
case class DataTable(cols: Seq[Column], rows: Seq[Row])
diff --git a/admin/app/tools/errors.scala b/admin/app/tools/errors.scala
index 75cbb5e41a8d..198195a69191 100644
--- a/admin/app/tools/errors.scala
+++ b/admin/app/tools/errors.scala
@@ -1,86 +1,80 @@
package tools
import CloudWatch._
-import com.amazonaws.services.cloudwatch.model.{Dimension, GetMetricStatisticsRequest}
import org.joda.time.DateTime
-import awswrappers.cloudwatch._
-import conf.Configuration._
+import software.amazon.awssdk.services.cloudwatch.model.{Dimension, GetMetricStatisticsRequest, Statistic}
import scala.concurrent.{ExecutionContext, Future}
+import scala.jdk.FutureConverters._
object HttpErrors {
- def global4XX()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
- euWestClient.getMetricStatisticsFuture(metric("HTTPCode_Backend_4XX")) map { metric =>
- new AwsLineChart("Global 4XX", Seq("Time", "4xx/min"), ChartFormat.SingleLineBlue, metric)
- }
- private val stage = new Dimension().withName("Stage").withValue(environment.stage)
+ val v1Metric4XX = "HTTPCode_Backend_4XX"
+ val v2Metric4XX = "HTTPCode_Target_4XX_Count"
+
+ val v1Metric5XX = "HTTPCode_Backend_5XX"
+ val v2Metric5XX = "HTTPCode_Target_5XX_Count"
- def googlebot404s()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] =
+ def legacyElb4XXs()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
withErrorLogging(
- Future.sequence(
- Seq(
- euWestClient.getMetricStatisticsFuture(
- metric("googlebot-404s")
- .withStartTime(new DateTime().minusHours(12).toDate)
- .withNamespace("ArchiveMetrics")
- .withDimensions(stage),
- ) map { metric =>
- new AwsLineChart("12 hours", Seq("Time", "404/min"), ChartFormat(Colour.`tone-live-1`), metric)
- },
- euWestClient.getMetricStatisticsFuture(
- metric("googlebot-404s")
- .withNamespace("ArchiveMetrics")
- .withDimensions(stage)
- .withPeriod(900)
- .withStartTime(new DateTime().minusDays(14).toDate),
- ) map { metric =>
- new AwsLineChart("2 weeks", Seq("Time", "404/15min"), ChartFormat(Colour.`tone-live-2`), metric)
- },
- ),
- ),
- )
+ euWestClient.getMetricStatistics(metric(v1Metric4XX, v1LoadBalancerNamespace)).asScala,
+ ) map { metric =>
+ new AwsLineChart("Legacy ELB 4XXs", Seq("Time", "4xx/min"), ChartFormat.SingleLineBlue, metric)
+ }
- def global5XX()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
+ def legacyElb5XXs()(implicit executionContext: ExecutionContext): Future[AwsLineChart] =
withErrorLogging(
- euWestClient.getMetricStatisticsFuture(
- metric("HTTPCode_Backend_5XX"),
- ) map { metric =>
- new AwsLineChart("Global 5XX", Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, metric)
+ euWestClient.getMetricStatistics(metric(v1Metric5XX, v1LoadBalancerNamespace)).asScala map { metric =>
+ new AwsLineChart("Legacy ELB 5XXs", Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, metric)
},
)
def notFound()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] =
withErrorLogging(Future.traverse(primaryLoadBalancers ++ secondaryLoadBalancers) { loadBalancer =>
- euWestClient.getMetricStatisticsFuture(
- metric("HTTPCode_Backend_4XX", Some(loadBalancer.id)),
- ) map { metric =>
- new AwsLineChart(loadBalancer.name, Seq("Time", "4XX/ min"), ChartFormat.SingleLineBlue, metric)
+ euWestClient.getMetricStatistics(loadBalancerMetric(v1Metric4XX, v2Metric4XX, loadBalancer)).asScala map {
+ metric =>
+ new AwsLineChart(loadBalancer.name, Seq("Time", "4XX/ min"), ChartFormat.SingleLineBlue, metric)
}
})
def errors()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] =
withErrorLogging(Future.traverse(primaryLoadBalancers ++ secondaryLoadBalancers) { loadBalancer =>
- euWestClient.getMetricStatisticsFuture(
- metric("HTTPCode_Backend_5XX", Some(loadBalancer.id)),
- ) map { metric =>
- new AwsLineChart(loadBalancer.name, Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, metric)
+ euWestClient.getMetricStatistics(loadBalancerMetric(v1Metric5XX, v2Metric5XX, loadBalancer)).asScala map {
+ metric =>
+ new AwsLineChart(loadBalancer.name, Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, metric)
}
})
- def metric(metricName: String, loadBalancer: Option[String] = None)(implicit
- executionContext: ExecutionContext,
- ): GetMetricStatisticsRequest = {
- val metric = new GetMetricStatisticsRequest()
- .withStartTime(new DateTime().minusHours(2).toDate)
- .withEndTime(new DateTime().toDate)
- .withPeriod(60)
- .withStatistics("Sum")
- .withNamespace("AWS/ELB")
- .withMetricName(metricName)
+ def metric(metricName: String, namespace: String): GetMetricStatisticsRequest =
+ GetMetricStatisticsRequest
+ .builder()
+ .startTime(new DateTime().minusHours(2).toDate.toInstant)
+ .endTime(new DateTime().toDate.toInstant)
+ .period(60)
+ .statistics(Statistic.SUM)
+ .namespace(namespace)
+ .metricName(metricName)
+ .build()
- loadBalancer
- .map(lb => metric.withDimensions(new Dimension().withName("LoadBalancerName").withValue(lb)))
- .getOrElse(metric)
+ def loadBalancerMetric(
+ v1MetricName: String,
+ v2MetricName: String,
+ loadBalancer: LoadBalancer,
+ ): GetMetricStatisticsRequest = {
+ loadBalancer.targetGroup match {
+ case None =>
+ metric(v1MetricName, v1LoadBalancerNamespace).toBuilder
+ .dimensions(
+ Dimension.builder().name("LoadBalancerName").value(loadBalancer.id).build(),
+ )
+ .build()
+ case Some(_) =>
+ metric(v2MetricName, v2LoadBalancerNamespace).toBuilder
+ .dimensions(
+ Dimension.builder().name("LoadBalancer").value(loadBalancer.id).build(),
+ )
+ .build()
+ }
}
+
}
diff --git a/admin/app/views/abTests.scala.html b/admin/app/views/abTests.scala.html
new file mode 100644
index 000000000000..ee509237e605
--- /dev/null
+++ b/admin/app/views/abTests.scala.html
@@ -0,0 +1,7 @@
+@(frameUrl: Option[String])(implicit request: RequestHeader, context: model.ApplicationContext)
+
+@import views.support.CamelCase
+
+@admin_embed("A/B Tests", isAuthed = true, hasCharts = true) {
+
+}
diff --git a/admin/app/views/admin.scala.html b/admin/app/views/admin.scala.html
index 3a94f0ce1a90..f4f7c11a86ee 100644
--- a/admin/app/views/admin.scala.html
+++ b/admin/app/views/admin.scala.html
@@ -14,8 +14,8 @@
Analytics
@@ -55,21 +55,13 @@ Metrics
diff --git a/admin/app/views/admin_embed.scala.html b/admin/app/views/admin_embed.scala.html
new file mode 100644
index 000000000000..355f49ac648e
--- /dev/null
+++ b/admin/app/views/admin_embed.scala.html
@@ -0,0 +1,73 @@
+@(title: String,
+ isAuthed: Boolean = false,
+ hasCharts: Boolean = false,
+ autoRefresh: Boolean = false,
+ loadJquery: Boolean = true,
+ container: Option[String] = None )(content: Html)(implicit request: RequestHeader, context: model.ApplicationContext)
+
+@import controllers.admin.routes.UncachedAssets
+@import controllers.admin.routes.UncachedWebAssets
+@import templates.inlineJS.blocking.js._
+@import play.api.Mode.Dev
+@import conf.Static
+@import conf.Configuration
+
+
+
+
+
+ @title
+
+
+ @if(autoRefresh){
+
+ }
+
+ @if(context.environment.mode == Dev){
+
+ } else {
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @admin_head()
+
+
+ @content
+
+
+
+
+
+
+
+
+
+ @if(loadJquery){
+
+ }
+
+
+
+
diff --git a/admin/app/views/afg.scala.html b/admin/app/views/afg.scala.html
deleted file mode 100644
index 714e935fd634..000000000000
--- a/admin/app/views/afg.scala.html
+++ /dev/null
@@ -1,10 +0,0 @@
-@(body: String)(implicit request: RequestHeader, context: model.ApplicationContext)
-
-@admin_main("Dashboard", isAuthed = true, hasCharts = true) {
-
-
-
-
- @Html(body)
-
-}
diff --git a/admin/app/views/commercial/adTests.scala.html b/admin/app/views/commercial/adTests.scala.html
index 8272d65b612f..21a97e835456 100644
--- a/admin/app/views/commercial/adTests.scala.html
+++ b/admin/app/views/commercial/adTests.scala.html
@@ -1,7 +1,7 @@
@import common.dfp.GuLineItem
@import tools.DfpLink
-@(timestamp: String, groupedLineItems: Seq[(String, Seq[GuLineItem])])(implicit request: RequestHeader, context: model.ApplicationContext)
+@(timestamp: String, commDevLineItems: Seq[(String, Seq[GuLineItem])], groupedLineItems: Seq[(String, Seq[GuLineItem])])(implicit request: RequestHeader, context: model.ApplicationContext)
@admin_main("Ad Tests", isAuthed = true) {
@@ -11,16 +11,40 @@ Current Ad Tests
Last updated: @timestamp
This page shows ready and delivering line items that are hidden behind a test cookie.
+CommDev Test Order Line Items
- | Cookie | Line items |
+ | Cookie | Line items |
+
+ @for((testValue, lineItems) <- commDevLineItems) {
+
+ @testValue |
+
+ @for(lineItem <- lineItems) {
+ @{lineItem.name} (@{lineItem.id})
+
+ }
+ |
+
+ }
+
+
+
+Line Items
+
+
+
+ | Cookie |
+ Line items |
+
+
@for((testValue, lineItems) <- groupedLineItems) {
- |
+ @testValue |
@for(lineItem <- lineItems) {
- @{lineItem.name} (@{lineItem.id}
- )
+ @{lineItem.name} (@{lineItem.id})
+
}
|
diff --git a/admin/app/views/commercial/commercialMenu.scala.html b/admin/app/views/commercial/commercialMenu.scala.html
index 5d106dcaadaf..a32351c3b8c0 100644
--- a/admin/app/views/commercial/commercialMenu.scala.html
+++ b/admin/app/views/commercial/commercialMenu.scala.html
@@ -23,7 +23,6 @@ Targeting
Surging Content
Ad Tests
Key Values
- Refresh all cached DFP data
Invalid Line Items
Custom Fields
@@ -37,9 +36,7 @@ Display
Preview ad formats, merchandising components and paid-for content.
@@ -51,7 +48,6 @@ Ad Ops
Tools for the Ad Ops team.
diff --git a/admin/app/views/commercial/dfpFlush.scala.html b/admin/app/views/commercial/dfpFlush.scala.html
deleted file mode 100644
index 44328d89979a..000000000000
--- a/admin/app/views/commercial/dfpFlush.scala.html
+++ /dev/null
@@ -1,21 +0,0 @@
-@()(implicit flash: Flash, request: RequestHeader, context: model.ApplicationContext)
-
-@link(cssClass: String) = {
-
- Flush!
-}
-
-@admin_main("DFP Data Cache Flush", isAuthed = true) {
-
-
DFP Data Cache Flush
-
-
Click this button to refresh all cached DFP data
-
- @if(flash.get("triggered") == Some("true")) {
- @link("btn btn-danger disabled")
-
Data is being refreshed now.
- } else {
- @link("btn btn-danger")
- }
-
-}
diff --git a/admin/app/views/commercial/fluidAds.scala.html b/admin/app/views/commercial/fluidAds.scala.html
deleted file mode 100644
index dfb0f48dbe3e..000000000000
--- a/admin/app/views/commercial/fluidAds.scala.html
+++ /dev/null
@@ -1,61 +0,0 @@
-@()(implicit request: RequestHeader, context: model.ApplicationContext)
-
-@admin_main("Commercial", isAuthed = true, hasCharts = true) {
-
-
-
-
Commercial
-
-
Responsive advertising
-
-
These are links to the responsive ads that we have created so far. All are served from DFP and are hidden behind cookies.
-
Click here to clear the cookie.
-
-
- -
-
- Responsive super-header & footer, responsive expandable mid-stream and fixed-scroll MPU
-
DFP Line item
-
- -
-
- Responsive super-header, midstream & footer and MPU
-
DFP Line item
-
- -
-
- Responsive super-header, midstream & footer
-
DFP Line item
-
- -
-
- Parallax scroll: responsive super-header & footer, responsive expandable mid-stream and parallax-scroll MPU
-
DFP Line item
-
- -
-
- Fixed scroll: responsive super-header & footer, responsive expandable mid-stream and fixed-scroll MPU
-
DFP Line item
-
- -
-
- Responsive super-header, midstream & footer and MPU
-
DFP Line item
-
- -
-
- Responsive super-header & footer, responsive expandable mid-stream and fixed-scroll MPU
-
DFP Line item
-
- -
-
- Responsive super-header, midstream & footer
-
DFP Line item
-
- -
-
- Responsive super-header & footer, responsive expandable mid-stream with fixed scroll, and fixed-scroll MPU
-
DFP Line item
-
-
-}
diff --git a/admin/app/views/commercial/fragments/slot.scala.html b/admin/app/views/commercial/fragments/slot.scala.html
index 99c77e5a9f48..6d59634b6376 100644
--- a/admin/app/views/commercial/fragments/slot.scala.html
+++ b/admin/app/views/commercial/fragments/slot.scala.html
@@ -1,6 +1,6 @@
@import common.dfp.LineItemReport
@(slotReport: LineItemReport)
-@import _root_.dfp.printUniversalTime
+@import _root_.gam.printUniversalTime
@import common.dfp.GuLineItem
@import tools.DfpLink
@import views.commercial.LineItemSupport.targetedAdUnits
diff --git a/admin/app/views/commercial/invalidLineItems.scala.html b/admin/app/views/commercial/invalidLineItems.scala.html
index 5a74f6cae3b9..1ab2eee6e33b 100644
--- a/admin/app/views/commercial/invalidLineItems.scala.html
+++ b/admin/app/views/commercial/invalidLineItems.scala.html
@@ -3,7 +3,6 @@
@import common.dfp.{GuLineItem}
@(invalidPageskins: Seq[PageSkinSponsorship],
- sonobiItems: Seq[GuLineItem],
unknownInvalidLineItems: Seq[GuLineItem])(implicit request: RequestHeader, context: model.ApplicationContext)
@admin_main("Line Item Problems", isAuthed = true, hasCharts = false) {
@@ -68,30 +67,4 @@
Unidentified Line Items
}
-
- Sonobi Line Items
-
-
- These line items are used by the Sonobi SSP to pass winning bids through DFP. They can be ignored.
-
-
- @if(sonobiItems.isEmpty) {None} else {
-
-
-
- | Line Item Name |
- DFP link |
-
-
-
- @for(lineItem <- sonobiItems) {
-
- | @{lineItem.name} |
- @{lineItem.id} |
-
- }
-
-
- }
-
}
diff --git a/admin/app/views/commercial/surgingpages.scala.html b/admin/app/views/commercial/surgingpages.scala.html
index 803c0e129b92..bd3e8f6f345d 100644
--- a/admin/app/views/commercial/surgingpages.scala.html
+++ b/admin/app/views/commercial/surgingpages.scala.html
@@ -1,5 +1,5 @@
@(surgingContent: services.ophan.SurgingContent)(implicit request: RequestHeader, context: model.ApplicationContext)
-@import _root_.dfp.printLondonTime
+@import _root_.gam.printLondonTime
@admin_main("Commercial", isAuthed = true, hasCharts = true) {
diff --git a/admin/app/views/commercial/surveySponsorships.scala.html b/admin/app/views/commercial/surveySponsorships.scala.html
index 5b02602958fd..eb6753725a33 100644
--- a/admin/app/views/commercial/surveySponsorships.scala.html
+++ b/admin/app/views/commercial/surveySponsorships.scala.html
@@ -20,19 +20,22 @@ Survey Sponsorships
Last updated: @if(report.updatedTimeStamp) { @{report.updatedTimeStamp} } else { never }
- Pages will show a survey slot if you set up a line item in GAM with the following parameters:
+ Pages can show a survey slot if you set up a line item in GAM with the following parameters:
- - Is a Sponsorship
- Targets the
survey
slot
- - Targets the
theguardian.com
except front
adUnit
- - Targets the
theguardian.com
except front
content type
- Targets the
desktop
breakpoint
- - Targets the
connected TV
device category in GAM
ANY OTHER TARGETING WILL CAUSE THE SLOT TO APPEAR UNINTENTIONALLY
If you are unsure please contact the commercial dev team first.
+ Limitations
+ Regardless of the targeting applied to the line item, survey slots:
+
+ - Will not appear on front pages, tag pages or the all newsletters page
+ - Will only appear on desktop breakpoints and above
+
+
Sponsorships
Line items that match the above targeting:
@if(report.sponsorships.isEmpty) {None
} else {
diff --git a/admin/app/views/commercial/takeoverWithEmptyMPUs.scala.html b/admin/app/views/commercial/takeoverWithEmptyMPUs.scala.html
deleted file mode 100644
index 73ed9d7396c2..000000000000
--- a/admin/app/views/commercial/takeoverWithEmptyMPUs.scala.html
+++ /dev/null
@@ -1,27 +0,0 @@
-@import common.dfp.TakeoverWithEmptyMPUs
-@(takeovers: Seq[TakeoverWithEmptyMPUs])(implicit request: RequestHeader, context: model.ApplicationContext)
-@import TakeoverWithEmptyMPUs.timeViewFormatter
-
-@admin_main("Takeovers with Empty MPUs", isAuthed = true) {
-
- Takeovers with Empty MPUs
- This list shows URLs where a takeover is taking place and container content should automatically reflow to take the place of MPUs.
-
-
- @for(takeover <- takeovers) {
- -
-
-
Editions: @takeover.editions.map(_.id).mkString(", ")
- Starts: @timeViewFormatter.print(takeover.startTime)
- Ends: @timeViewFormatter.print(takeover.endTime)
- @helper.form(action = controllers.admin.commercial.routes.TakeoverWithEmptyMPUsController.remove(takeover.url)) {
-
- }
-
- }
-
-
- @helper.form(action = controllers.admin.commercial.routes.TakeoverWithEmptyMPUsController.viewForm()) {
-
- }
-}
diff --git a/admin/app/views/commercial/takeoverWithEmptyMPUsCreate.scala.html b/admin/app/views/commercial/takeoverWithEmptyMPUsCreate.scala.html
deleted file mode 100644
index d9c2ed69baff..000000000000
--- a/admin/app/views/commercial/takeoverWithEmptyMPUsCreate.scala.html
+++ /dev/null
@@ -1,37 +0,0 @@
-@import common.Edition
-@import common.dfp.TakeoverWithEmptyMPUs
-@import helper._
-@(takeoverForm: Form[TakeoverWithEmptyMPUs])(
- implicit messages: Messages,
- request: RequestHeader,
- context: model.ApplicationContext
-)
-
-@admin_main("Create takeover with Empty MPUs", isAuthed = true) {
-
- New Takeover with Empty MPUs
- @form(action = controllers.admin.commercial.routes.TakeoverWithEmptyMPUsController.create()) {
- Fill in the details of a front takeover in which MPUs should not appear on the page.
- @if(takeoverForm.hasGlobalErrors) {
-
- @for(error <- takeoverForm.globalErrors) {
- - @Messages(error.messages, error.args)
- }
-
- }
- @inputText(takeoverForm("url"), Symbol("_label")-> "Paste URL here:", Symbol("size") -> 100, Symbol("_help") -> "")
- @select(
- takeoverForm("editions"),
- options = for(e <- Edition.allEditions) yield { e.id -> e.displayName },
- Symbol("multiple") -> true,
- Symbol("_label") -> "Editions this applies to (one or multiple):"
- )
- @input(takeoverForm("startTime"), Symbol("_label") -> "Takeover starts (UTC):", Symbol("_help") -> "") { (id, name, value, args) =>
-
- }
- @input(takeoverForm("endTime"), Symbol("_label") -> "Takeover ends (UTC):", Symbol("_help") -> "") { (id, name, value, args) =>
-
- }
-
- }
-}
diff --git a/admin/app/views/commercial/templates.scala.html b/admin/app/views/commercial/templates.scala.html
deleted file mode 100644
index 9718f033a0ec..000000000000
--- a/admin/app/views/commercial/templates.scala.html
+++ /dev/null
@@ -1,57 +0,0 @@
-@(templates: Seq[common.dfp.GuCreativeTemplate])(implicit request: RequestHeader, context: model.ApplicationContext)
-@import model.{MetaData, SectionId, SimplePage}
-@import tools.DfpLink
-
-@mainLegacy(
- SimplePage(MetaData.make(
- id = "commercial-templates",
- section = Some(SectionId.fromId("admin")),
- webTitle = "Commercial Templates"
- ))
-) { } {
-
-
-
-
-
-
Creative Templates
-
This dashboard is to help debug DFP creative templates.
- All unarchived custom creative templates are shown; native templates are indicated with a *
-
-
-
-
Creative Templates: Contents
-
-
-
-
- @for(template <- templates) {
- -
-
-
- @template.name (@template.id)@if(template.isNative){*}
-
-
@{template.description}
- @if(template.creatives.isEmpty){
-
This template is not in use.
- }
- @if(template.creatives.nonEmpty){
-
Creatives built from this template (see preview here):
-
- @for(creative <- template.creatives){
- - @{creative.name} (@{creative.id})
- }
-
- }
-
-
- }
-
-
-
-
-}
diff --git a/admin/app/views/abtests.scala.html b/admin/app/views/legacyAbTests.scala.html
similarity index 100%
rename from admin/app/views/abtests.scala.html
rename to admin/app/views/legacyAbTests.scala.html
diff --git a/admin/conf/logback.xml b/admin/conf/logback.xml
index ce08bf16805c..ca6735645aab 100644
--- a/admin/conf/logback.xml
+++ b/admin/conf/logback.xml
@@ -7,7 +7,9 @@
logs/frontend-admin.log.%d{yyyy-MM-dd}.%i.gz
- 7512MB256MB
+ 7
+ 512MB
+ 256MB
@@ -23,9 +25,6 @@
-
-
-
diff --git a/admin/conf/routes b/admin/conf/routes
index 5dc33f8f1730..ed3117cfcbdc 100644
--- a/admin/conf/routes
+++ b/admin/conf/routes
@@ -58,8 +58,8 @@ GET /dev/switchboard
POST /dev/switchboard controllers.admin.SwitchboardController.save()
# Analytics
-GET /analytics/abtests controllers.admin.AnalyticsController.abtests()
-GET /analytics/confidence controllers.admin.AnalyticsConfidenceController.renderConfidence()
+GET /analytics/abtests controllers.admin.AnalyticsController.legacyAbTests()
+GET /analytics/ab-testing controllers.admin.AnalyticsController.abTests()
# Commercial
GET /commercial controllers.admin.CommercialController.renderCommercialMenu()
@@ -68,21 +68,12 @@ GET /commercial/pageskins
GET /commercial/surging controllers.admin.CommercialController.renderSurgingContent()
GET /commercial/liveblog-top controllers.admin.CommercialController.renderLiveBlogTopSponsorships()
GET /commercial/survey controllers.admin.CommercialController.renderSurveySponsorships()
-GET /commercial/templates controllers.admin.CommercialController.renderCreativeTemplates()
-GET /commercial/fluid250 controllers.admin.CommercialController.renderFluidAds()
GET /commercial/adtests controllers.admin.CommercialController.renderAdTests()
GET /commercial/keyvalues controllers.admin.CommercialController.renderKeyValues()
GET /commercial/keyvalues/csv/*key controllers.admin.CommercialController.renderKeyValuesCsv(key)
-GET /commercial/dfp/flush/view controllers.admin.commercial.DfpDataController.renderCacheFlushPage()
-GET /commercial/dfp/flush controllers.admin.commercial.DfpDataController.flushCache()
-GET /commercial/adops/takeovers-empty-mpus controllers.admin.commercial.TakeoverWithEmptyMPUsController.viewList()
-GET /commercial/adops/takeovers-empty-mpus/create controllers.admin.commercial.TakeoverWithEmptyMPUsController.viewForm()
-POST /commercial/adops/takeovers-empty-mpus/create controllers.admin.commercial.TakeoverWithEmptyMPUsController.create()
-POST /commercial/adops/takeovers-empty-mpus/remove controllers.admin.commercial.TakeoverWithEmptyMPUsController.remove(t)
GET /commercial/invalid-lineitems controllers.admin.CommercialController.renderInvalidItems()
GET /commercial/custom-fields controllers.admin.CommercialController.renderCustomFields()
GET /commercial/adgrabber/order/:orderId controllers.admin.CommercialController.getLineItemsForOrder(orderId: String)
-GET /commercial/adgrabber/previewUrls/:lineItemId/:section controllers.admin.CommercialController.getCreativesListing(lineItemId: String, section: String)
GET /commercial/adops/ads-txt controllers.admin.commercial.AdsDotTextEditController.renderAdsDotText()
POST /commercial/adops/ads-txt controllers.admin.commercial.AdsDotTextEditController.postAdsDotText()
GET /commercial/adops/app-ads-txt controllers.admin.commercial.AdsDotTextEditController.renderAppAdsDotText()
@@ -94,15 +85,9 @@ GET /config
GET /config/parameter/*key controllers.AppConfigController.findParameter(key: String)
# Metrics
-GET /metrics/loadbalancers controllers.admin.MetricsController.renderLoadBalancers()
-GET /metrics/fastly controllers.admin.FastlyController.renderFastly()
GET /metrics/errors controllers.admin.MetricsController.renderErrors()
GET /metrics/errors/4xx controllers.admin.MetricsController.render4XX()
GET /metrics/errors/5xx controllers.admin.MetricsController.render5XX()
-GET /metrics/googlebot/404 controllers.admin.MetricsController.renderGooglebot404s()
-GET /metrics/afg controllers.admin.MetricsController.renderAfg()
-GET /metrics/webpack-bundle-visualization controllers.admin.MetricsController.renderBundleVisualization()
-GET /metrics/webpack-bundle-analyzer controllers.admin.MetricsController.renderBundleAnalyzer()
# Redirects
GET /redirects controllers.admin.RedirectController.redirect()
diff --git a/admin/public/css/commercial.css b/admin/public/css/commercial.css
index 732d30b2c1f0..3f8f52a66bc8 100644
--- a/admin/public/css/commercial.css
+++ b/admin/public/css/commercial.css
@@ -95,3 +95,8 @@ dl.error, dd.error {
font-size: 12px;
overflow-wrap: break-word
}
+
+.cookie {
+ text-align: right;
+ vertical-align: top;
+}
diff --git a/admin/test/dfp/DfpApiValidationTest.scala b/admin/test/dfp/DfpApiValidationTest.scala
deleted file mode 100644
index 6f439265b1e7..000000000000
--- a/admin/test/dfp/DfpApiValidationTest.scala
+++ /dev/null
@@ -1,73 +0,0 @@
-package dfp
-
-import concurrent.BlockingOperations
-import common.dfp.{GuAdUnit, GuLineItem, GuTargeting, Sponsorship}
-import com.google.api.ads.admanager.axis.v202405._
-import org.joda.time.DateTime
-import org.apache.pekko.actor.ActorSystem
-import org.scalatest.flatspec.AnyFlatSpec
-import org.scalatest.matchers.should.Matchers
-
-class DfpApiValidationTest extends AnyFlatSpec with Matchers {
-
- private def lineItem(adUnitIds: Seq[String]): GuLineItem = {
- val adUnits = adUnitIds.map(adUnitId => {
- GuAdUnit(id = adUnitId, path = Nil, status = GuAdUnit.ACTIVE)
- })
-
- GuLineItem(
- id = 0L,
- orderId = 0L,
- name = "test line item",
- Sponsorship,
- startTime = DateTime.now.withTimeAtStartOfDay,
- endTime = None,
- isPageSkin = false,
- sponsor = None,
- status = "COMPLETED",
- costType = "CPM",
- creativePlaceholders = Nil,
- targeting = GuTargeting(
- adUnitsIncluded = adUnits,
- adUnitsExcluded = Nil,
- geoTargetsIncluded = Nil,
- geoTargetsExcluded = Nil,
- customTargetSets = Nil,
- ),
- lastModified = DateTime.now.withTimeAtStartOfDay,
- )
- }
-
- private def makeDfpLineItem(adUnitIds: Seq[String]): LineItem = {
- val dfpLineItem = new LineItem()
- val targeting = new Targeting()
- val inventoryTargeting = new InventoryTargeting()
-
- val adUnitTargeting = adUnitIds.map(adUnit => {
- val adUnitTarget = new AdUnitTargeting()
- adUnitTarget.setAdUnitId(adUnit)
- adUnitTarget
- })
-
- inventoryTargeting.setTargetedAdUnits(adUnitTargeting.toArray)
- targeting.setInventoryTargeting(inventoryTargeting)
- dfpLineItem.setTargeting(targeting)
- dfpLineItem
- }
-
- val dataValidation = new DataValidation(new AdUnitService(new AdUnitAgent(new BlockingOperations(ActorSystem()))))
-
- "isGuLineItemValid" should "return false when the adunit targeting does not match the dfp line item" in {
- val guLineItem = lineItem(List("1", "2", "3"))
- val dfpLineItem = makeDfpLineItem(List("1", "2", "3", "4"))
-
- dataValidation.isGuLineItemValid(guLineItem, dfpLineItem) shouldBe false
- }
-
- "isGuLineItemValid" should "return true when the adunit targeting does match the dfp line item" in {
- val guLineItem = lineItem(List("1", "2", "3"))
- val dfpLineItem = makeDfpLineItem(List("1", "2", "3"))
-
- dataValidation.isGuLineItemValid(guLineItem, dfpLineItem) shouldBe true
- }
-}
diff --git a/admin/test/dfp/DfpDataCacheJobTest.scala b/admin/test/dfp/DfpDataCacheJobTest.scala
deleted file mode 100644
index a5a0bceb5b04..000000000000
--- a/admin/test/dfp/DfpDataCacheJobTest.scala
+++ /dev/null
@@ -1,128 +0,0 @@
-package dfp
-
-import common.dfp.{GuLineItem, GuTargeting, Sponsorship}
-import org.joda.time.DateTime
-import org.scalatest._
-import org.scalatest.flatspec.AnyFlatSpec
-import org.scalatest.matchers.should.Matchers
-import org.scalatestplus.mockito.MockitoSugar
-import test._
-
-class DfpDataCacheJobTest
- extends AnyFlatSpec
- with Matchers
- with SingleServerSuite
- with BeforeAndAfterAll
- with WithMaterializer
- with WithTestWsClient
- with MockitoSugar
- with WithTestContentApiClient {
-
- val dfpDataCacheJob = new DfpDataCacheJob(
- mock[AdUnitAgent],
- mock[CustomFieldAgent],
- mock[CustomTargetingAgent],
- mock[PlacementAgent],
- mock[DfpApi],
- )
-
- private def lineItem(id: Long, name: String, completed: Boolean = false): GuLineItem = {
- GuLineItem(
- id,
- 0L,
- name,
- Sponsorship,
- startTime = DateTime.now.withTimeAtStartOfDay,
- endTime = None,
- isPageSkin = false,
- sponsor = None,
- status = if (completed) "COMPLETED" else "READY",
- costType = "CPM",
- creativePlaceholders = Nil,
- targeting = GuTargeting(
- adUnitsIncluded = Nil,
- adUnitsExcluded = Nil,
- geoTargetsIncluded = Nil,
- geoTargetsExcluded = Nil,
- customTargetSets = Nil,
- ),
- lastModified = DateTime.now.withTimeAtStartOfDay,
- )
- }
-
- private val cachedLineItems = DfpLineItems(
- validItems = Seq(lineItem(1, "a-cache"), lineItem(2, "b-cache"), lineItem(3, "c-cache")),
- invalidItems = Seq.empty,
- )
-
- private val allReadyOrDeliveringLineItems = DfpLineItems(Seq.empty, Seq.empty)
-
- "loadLineItems" should "dedupe line items that have changed in an unknown way" in {
- def lineItemsModifiedSince(threshold: DateTime): DfpLineItems =
- DfpLineItems(
- validItems = Seq(
- lineItem(1, "a-fresh"),
- lineItem(2, "b-fresh"),
- lineItem(3, "c-fresh"),
- ),
- invalidItems = Seq.empty,
- )
-
- val lineItems = dfpDataCacheJob.loadLineItems(
- cachedLineItems,
- lineItemsModifiedSince,
- allReadyOrDeliveringLineItems,
- )
-
- lineItems.validLineItems.size shouldBe 3
- lineItems.validLineItems shouldBe Seq(lineItem(1, "a-fresh"), lineItem(2, "b-fresh"), lineItem(3, "c-fresh"))
- lineItems.invalidLineItems shouldBe empty
- }
-
- it should "dedupe line items that have changed in a known way" in {
- def lineItemsModifiedSince(threshold: DateTime): DfpLineItems =
- DfpLineItems(
- validItems = Seq(
- lineItem(1, "d"),
- lineItem(2, "e"),
- lineItem(4, "f"),
- ),
- invalidItems = Seq.empty,
- )
-
- val lineItems = dfpDataCacheJob.loadLineItems(
- cachedLineItems,
- lineItemsModifiedSince,
- allReadyOrDeliveringLineItems,
- )
-
- lineItems.validLineItems.size shouldBe 4
- lineItems.validLineItems shouldBe Seq(
- lineItem(1, "d"),
- lineItem(2, "e"),
- lineItem(3, "c-cache"),
- lineItem(4, "f"),
- )
- }
-
- it should "omit line items whose state has changed to no longer be ready or delivering" in {
- def lineItemsModifiedSince(threshold: DateTime): DfpLineItems =
- DfpLineItems(
- validItems = Seq(
- lineItem(1, "a", completed = true),
- lineItem(2, "e"),
- lineItem(4, "f"),
- ),
- invalidItems = Seq.empty,
- )
-
- val lineItems = dfpDataCacheJob.loadLineItems(
- cachedLineItems,
- lineItemsModifiedSince,
- allReadyOrDeliveringLineItems,
- )
-
- lineItems.validLineItems.size shouldBe 3
- lineItems.validLineItems shouldBe Seq(lineItem(2, "e"), lineItem(3, "c-cache"), lineItem(4, "f"))
- }
-}
diff --git a/admin/test/dfp/ReaderTest.scala b/admin/test/dfp/ReaderTest.scala
deleted file mode 100644
index 80068ee030f5..000000000000
--- a/admin/test/dfp/ReaderTest.scala
+++ /dev/null
@@ -1,31 +0,0 @@
-package dfp
-
-import com.google.api.ads.admanager.axis.utils.v202405.StatementBuilder
-import dfp.Reader.read
-import org.scalatest.flatspec.AnyFlatSpec
-import org.scalatest.matchers.should.Matchers
-
-class ReaderTest extends AnyFlatSpec with Matchers {
-
- "load" should "load a single page of results" in {
- val stmtBuilder = new StatementBuilder()
- val result = read[Int](stmtBuilder) { statement =>
- (Array(1, 2, 3, 4, 5), 5)
- }
- result shouldBe Seq(1, 2, 3, 4, 5)
- }
-
- it should "load multiple pages of results" in {
- val stmtBuilder = new StatementBuilder()
- val result = read[Int](stmtBuilder) { statement =>
- ((1 to 10).toArray, 30)
- }
- result shouldBe Seq.fill[Seq[Int]](3)((1 to 10)).flatten
- }
-
- it should "cope with a null result" in {
- val stmtBuilder = new StatementBuilder()
- val result = read[Int](stmtBuilder) { statement => (null, 0) }
- result shouldBe empty
- }
-}
diff --git a/applications/app/controllers/ApplicationsControllers.scala b/applications/app/controllers/ApplicationsControllers.scala
index 9a06f1589c07..0e291bb0c54a 100644
--- a/applications/app/controllers/ApplicationsControllers.scala
+++ b/applications/app/controllers/ApplicationsControllers.scala
@@ -18,6 +18,7 @@ trait ApplicationsControllers {
lazy val remoteRender = wire[renderers.DotcomRenderingService]
lazy val siteMapController = wire[SiteMapController]
+ lazy val dCARAssetsController = wire[DCARAssetsController]
lazy val crosswordPageController = wire[CrosswordPageController]
lazy val crosswordSearchController = wire[CrosswordSearchController]
lazy val crosswordEditionsController = wire[CrosswordEditionsController]
@@ -39,6 +40,7 @@ trait ApplicationsControllers {
lazy val siteVerificationController = wire[SiteVerificationController]
lazy val youtubeController = wire[YoutubeController]
lazy val nx1ConfigController = wire[Nx1ConfigController]
+ lazy val diagnosticsController = wire[DiagnosticsController]
// A fake geolocation controller to test it locally
lazy val geolocationController = wire[FakeGeolocationController]
diff --git a/applications/app/controllers/CrosswordsController.scala b/applications/app/controllers/CrosswordsController.scala
index 2bfd618daf4c..bd6bcf881cc5 100644
--- a/applications/app/controllers/CrosswordsController.scala
+++ b/applications/app/controllers/CrosswordsController.scala
@@ -10,7 +10,7 @@ import com.gu.contentapi.client.model.v1.{
import common.{Edition, GuLogging, ImplicitControllerExecutionContext}
import conf.Static
import contentapi.ContentApiClient
-import com.gu.contentapi.client.model.SearchQuery
+import com.gu.contentapi.client.model.{ContentApiError, SearchQuery}
import crosswords.{
AccessibleCrosswordPage,
AccessibleCrosswordRows,
@@ -37,6 +37,7 @@ import renderers.DotcomRenderingService
import services.dotcomrendering.{CrosswordsPicker, RemoteRender}
import services.{IndexPage, IndexPageItem}
+import scala.collection.immutable
import scala.concurrent.Future
import scala.concurrent.duration._
@@ -64,9 +65,15 @@ trait CrosswordController extends BaseController with GuLogging with ImplicitCon
crossword <- content.crossword
} yield f(crossword, content)
maybeCrossword getOrElse Future.successful(noResults())
- } recover { case t: Throwable =>
- logErrorWithRequestId(s"Error retrieving $crosswordType crossword id $id from API", t)
- noResults()
+ } recover {
+ case capiError: ContentApiError if capiError.httpStatus == 404 => {
+ logInfoWithRequestId(s"The $crosswordType crossword with id $id was not found in CAPI")
+ noResults()
+ }
+ case t: Throwable => {
+ logErrorWithRequestId(s"Error retrieving $crosswordType crossword id $id from API", t)
+ noResults()
+ }
}
}
@@ -343,13 +350,21 @@ class CrosswordEditionsController(
"crosswords/series/cryptic",
"crosswords/series/prize",
"crosswords/series/weekend-crossword",
+ "crosswords/series/sunday-quick",
"crosswords/series/quick-cryptic",
"crosswords/series/everyman",
"crosswords/series/speedy",
"crosswords/series/quiptic",
).mkString("|")
- private def parseCrosswords(response: SearchResponse): EditionsCrosswordRenderingDataModel =
- EditionsCrosswordRenderingDataModel(response.results.flatMap(_.crossword))
-
+ private def parseCrosswords(response: SearchResponse): EditionsCrosswordRenderingDataModel = {
+ val collectedItems = response.results.collect {
+ case content if content.crossword.isDefined =>
+ CrosswordData.fromCrossword(content.crossword.get, content)
+ }
+ val crosswordDataItems: immutable.Seq[CrosswordData] = collectedItems.toList
+ EditionsCrosswordRenderingDataModel(
+ crosswordDataItems,
+ )
+ }
}
diff --git a/applications/app/controllers/DCARAssetsController.scala b/applications/app/controllers/DCARAssetsController.scala
new file mode 100644
index 000000000000..81f567d22e6b
--- /dev/null
+++ b/applications/app/controllers/DCARAssetsController.scala
@@ -0,0 +1,17 @@
+package controllers
+
+import play.api.libs.ws.WSClient
+import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
+import renderers.DotcomRenderingService
+
+class DCARAssetsController(
+ wsClient: WSClient,
+ val controllerComponents: ControllerComponents,
+ remoteRenderer: renderers.DotcomRenderingService = DotcomRenderingService(),
+) extends BaseController {
+ def renderAsset(): Action[AnyContent] = {
+ Action.async { implicit request =>
+ remoteRenderer.getDCARAssets(wsClient, "/assets/rendered-items-assets")
+ }
+ }
+}
diff --git a/applications/app/controllers/DiagnosticsController.scala b/applications/app/controllers/DiagnosticsController.scala
new file mode 100644
index 000000000000..cd2b153bd733
--- /dev/null
+++ b/applications/app/controllers/DiagnosticsController.scala
@@ -0,0 +1,21 @@
+package controllers
+
+import model.Cached.RevalidatableResult
+import model.{ApplicationContext, Cached, DiagnosticsPageMetadata}
+import pages.TagIndexHtmlPage
+import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
+
+/** Browser diagnostics was introduced as a temporary page on 15/09/2025 If you still see this comment in 2026, please
+ * notify @cemms1 or feel free to remove See https://github.com/guardian/frontend/pull/28220
+ */
+class DiagnosticsController(val controllerComponents: ControllerComponents)(implicit context: ApplicationContext)
+ extends BaseController
+ with common.ImplicitControllerExecutionContext {
+
+ def renderDiagnosticsPage(): Action[AnyContent] =
+ Action { implicit request =>
+ Cached(300) {
+ RevalidatableResult.Ok(TagIndexHtmlPage.html(new DiagnosticsPageMetadata()))
+ }
+ }
+}
diff --git a/applications/app/controllers/GalleryController.scala b/applications/app/controllers/GalleryController.scala
index 7f9833631703..b9b1703410f4 100644
--- a/applications/app/controllers/GalleryController.scala
+++ b/applications/app/controllers/GalleryController.scala
@@ -56,12 +56,16 @@ class GalleryController(
) = {
val pageType = PageType(model, request, context)
- remoteRenderer.getGallery(
- wsClient,
- model,
- pageType,
- blocks,
- )
+ if (request.isApps) {
+ remoteRenderer.getAppsGallery(wsClient, model, pageType, blocks)
+ } else {
+ remoteRenderer.getGallery(
+ wsClient,
+ model,
+ pageType,
+ blocks,
+ )
+ }
}
def lightboxJson(path: String): Action[AnyContent] =
diff --git a/applications/app/controllers/InteractiveController.scala b/applications/app/controllers/InteractiveController.scala
index 86010cf512ea..041f4a573cbd 100644
--- a/applications/app/controllers/InteractiveController.scala
+++ b/applications/app/controllers/InteractiveController.scala
@@ -7,16 +7,17 @@ import conf.switches.Switches
import contentapi.ContentApiClient
import implicits.{AmpFormat, AppsFormat, HtmlFormat, JsonFormat}
import model.Cached.{RevalidatableResult, WithoutRevalidationResult}
-import model.{InteractivePage, _}
import model.content.InteractiveAtom
import model.dotcomrendering.{DotcomRenderingDataModel, PageType}
+import model.meta.BlocksOn
+import model._
import org.apache.commons.lang.StringEscapeUtils
import pages.InteractiveHtmlPage
import play.api.libs.ws.WSClient
import play.api.mvc._
import renderers.DotcomRenderingService
import services.dotcomrendering.{DotcomRendering, InteractivePicker, PressedInteractive}
-import services.{CAPILookup, USElection2020AmpPages, _}
+import services.{CAPILookup, USElection2020AmpPages}
import scala.concurrent.Future
import scala.concurrent.duration._
@@ -72,17 +73,19 @@ class InteractiveController(
capiLookup.lookup(path, range = Some(ArticleBlocks))
}
- def toModel(response: ItemResponse)(implicit
- request: RequestHeader,
- ): Either[Result, (InteractivePage, Blocks)] = {
- val interactive = response.content map { Interactive.make }
- val blocks = response.content.flatMap(_.blocks).getOrElse(Blocks())
- val page = interactive.map(i => InteractivePage(i, StoryPackages(i.metadata.id, response)))
-
- ModelOrResult(page, response) match {
- case Right(page) => Right((page, blocks))
- case Left(exception) => Left(exception)
- }
+ def modelAndRenderHtml(response: ItemResponse)(
+ modifier: BlocksOn[InteractivePage] => BlocksOn[InteractivePage] = identity,
+ )(implicit req: RequestHeader): Future[Result] =
+ modelAndRender(response)(pageBlocks => renderHtml(modifier(pageBlocks)))
+
+ def modelAndRender(
+ response: ItemResponse,
+ )(render: BlocksOn[InteractivePage] => Future[Result])(implicit req: RequestHeader): Future[Result] = {
+ val content = response.content
+ ModelOrResult(
+ content.map(Interactive.make).map(i => InteractivePage(i, StoryPackages(i.metadata.id, response))),
+ response,
+ ).fold(Future.successful, page => render(BlocksOn(page, content.flatMap(_.blocks).getOrElse(Blocks()))))
}
override def canRender(i: ItemResponse): Boolean = i.content.exists(_.isInteractive)
@@ -94,47 +97,43 @@ class InteractiveController(
Future.successful(res)
}
- def renderHtml(page: InteractivePage, blocks: Blocks)(implicit request: RequestHeader): Future[Result] = {
- val pageType = PageType.apply(page, request, context)
- remoteRenderer.getInteractive(wsClient, page, blocks, pageType)
+ def renderHtml(pageBlocks: BlocksOn[InteractivePage])(implicit request: RequestHeader): Future[Result] = {
+ val pageType = PageType.apply(pageBlocks.page, request, context)
+ remoteRenderer.getInteractive(wsClient, pageBlocks, pageType)
}
- def renderJson(page: InteractivePage, blocks: Blocks)(implicit request: RequestHeader): Future[Result] = {
+ def renderJson(pageBlocks: BlocksOn[InteractivePage])(implicit request: RequestHeader): Future[Result] = {
val data =
- DotcomRenderingDataModel.forInteractive(page, blocks, request, PageType.apply(page, request, context))
+ DotcomRenderingDataModel.forInteractive(pageBlocks, request, PageType.apply(pageBlocks.page, request, context))
val dataJson = DotcomRenderingDataModel.toJson(data)
- val res = common.renderJson(dataJson, page).as("application/json")
+ val res = common.renderJson(dataJson, pageBlocks.page).as("application/json")
Future.successful(res)
}
- def renderAmp(page: InteractivePage, blocks: Blocks)(implicit request: RequestHeader): Future[Result] = {
- val pageType = PageType.apply(page, request, context)
- remoteRenderer.getAMPInteractive(wsClient, page, blocks, pageType)
+ def renderAmp(pageBlocks: BlocksOn[InteractivePage])(implicit request: RequestHeader): Future[Result] = {
+ val pageType = PageType.apply(pageBlocks.page, request, context)
+ remoteRenderer.getAMPInteractive(wsClient, pageBlocks, pageType)
}
- def renderApps(page: InteractivePage, blocks: Blocks)(implicit request: RequestHeader): Future[Result] = {
- val pageType = PageType.apply(page, request, context)
- remoteRenderer.getAppsInteractive(wsClient, page, blocks, pageType)
+ def renderApps(pageBlocks: BlocksOn[InteractivePage])(implicit request: RequestHeader): Future[Result] = {
+ val pageType = PageType.apply(pageBlocks.page, request, context)
+ remoteRenderer.getAppsInteractive(wsClient, pageBlocks, pageType)
}
override def renderItem(path: String)(implicit request: RequestHeader): Future[Result] = {
val requestFormat = request.getRequestFormat
val isUSElectionAMP = USElection2020AmpPages.pathIsSpecialHanding(path) && requestFormat == AmpFormat
- def render(model: Either[Result, (InteractivePage, Blocks)]): Future[Result] = {
- model match {
- case Right((page, blocks)) => {
- val tier = InteractivePicker.getRenderingTier(path)
- (requestFormat, tier) match {
- case (AppsFormat, DotcomRendering) => renderApps(page, blocks)
- case (AmpFormat, DotcomRendering) => renderAmp(page, blocks)
- case (JsonFormat, DotcomRendering) => renderJson(page, blocks)
- case (HtmlFormat, PressedInteractive) => servePressedPage(path)
- case (HtmlFormat, DotcomRendering) => renderHtml(page, blocks)
- case _ => renderNonDCR(page)
- }
- }
- case Left(result) => Future.successful(result)
+ def render(pageBlocks: BlocksOn[InteractivePage]): Future[Result] = {
+ val page = pageBlocks.page
+ val tier = InteractivePicker.getRenderingTier(path)
+ (requestFormat, tier) match {
+ case (AppsFormat, DotcomRendering) => renderApps(pageBlocks)
+ case (AmpFormat, DotcomRendering) if page.interactive.content.shouldAmplify => renderAmp(pageBlocks)
+ case (HtmlFormat | AmpFormat, DotcomRendering) => renderHtml(pageBlocks)
+ case (JsonFormat, DotcomRendering) => renderJson(pageBlocks)
+ case (HtmlFormat, PressedInteractive) => servePressedPage(path)
+ case _ => renderNonDCR(page)
}
}
@@ -143,8 +142,7 @@ class InteractiveController(
} else {
val res = for {
resp <- lookupItemResponse(path)
- model = toModel(resp)
- result <- render(model)
+ result <- modelAndRender(resp)(render)
} yield result
res.recover(convertApiExceptionsWithoutEither)
diff --git a/applications/app/controllers/LatestIndexController.scala b/applications/app/controllers/LatestIndexController.scala
index 59c4d1e76de7..2d2093c94b58 100644
--- a/applications/app/controllers/LatestIndexController.scala
+++ b/applications/app/controllers/LatestIndexController.scala
@@ -25,7 +25,7 @@ class LatestIndexController(contentApiClient: ContentApiClient, val controllerCo
index.page match {
case tag: Tag if tag.isSeries || tag.isBlog => handleSeriesBlogs(index)
case tag: Tag => MovedPermanently(s"${tag.metadata.url}/all")
- case section: Section =>
+ case section: Section =>
val url =
if (section.isEditionalised) Paths.stripEditionIfPresent(section.metadata.url)
else section.metadata.url
diff --git a/applications/app/controllers/NewspaperController.scala b/applications/app/controllers/NewspaperController.scala
index 8f61cf1406d4..51005c8fe1b3 100644
--- a/applications/app/controllers/NewspaperController.scala
+++ b/applications/app/controllers/NewspaperController.scala
@@ -34,15 +34,6 @@ class NewspaperController(
}
- def latestObserverNewspaper(): Action[AnyContent] = {
- // A request was made by Central Production on the 12th July 2022 to redirect this page to
- // /observer rather than create a generated page here.
- // Issue: https://github.com/guardian/frontend/issues/25223
- Action { implicit request =>
- Cached(300)(WithoutRevalidationResult(MovedPermanently("/observer")))
- }
- }
-
def newspaperForDate(path: String, day: String, month: String, year: String): Action[AnyContent] =
Action.async { implicit request =>
val metadata = path match {
diff --git a/applications/app/controllers/OptInController.scala b/applications/app/controllers/OptInController.scala
index c33feab601ca..e661b8659bf3 100644
--- a/applications/app/controllers/OptInController.scala
+++ b/applications/app/controllers/OptInController.scala
@@ -25,7 +25,8 @@ class OptInController(val controllerComponents: ControllerComponents) extends Ba
case "delete" => optDelete(feature)
}
def optIn(cookieName: String): Result = SeeOther("/").withCookies(Cookie(cookieName, "true", maxAge = Some(lifetime)))
- def optOut(cookieName: String): Result = SeeOther("/").discardingCookies(DiscardingCookie(cookieName))
+ def optOut(cookieName: String): Result =
+ SeeOther("/").withCookies(Cookie(cookieName, "false", maxAge = Some(lifetime)))
def optDelete(cookieName: String): Result = SeeOther("/").discardingCookies(DiscardingCookie(cookieName))
def reset(): Action[AnyContent] =
diff --git a/applications/app/controllers/TagIndexController.scala b/applications/app/controllers/TagIndexController.scala
index a3ee703daa1e..644f8f0ca7d5 100644
--- a/applications/app/controllers/TagIndexController.scala
+++ b/applications/app/controllers/TagIndexController.scala
@@ -18,7 +18,7 @@ class TagIndexController(val controllerComponents: ControllerComponents)(implici
Action { implicit request =>
TagIndexesS3.getIndex(keywordType, page) match {
case Left(TagIndexNotFound) =>
- logErrorWithRequestId(s"404 error serving tag index page for $keywordType $page")
+ logInfoWithRequestId(s"404 error serving tag index page for $keywordType $page")
NotFound
case Left(TagIndexReadError(error)) =>
diff --git a/applications/app/controllers/YoutubeController.scala b/applications/app/controllers/YoutubeController.scala
index b91d7fc88487..67a299c84f2f 100644
--- a/applications/app/controllers/YoutubeController.scala
+++ b/applications/app/controllers/YoutubeController.scala
@@ -28,7 +28,7 @@ class YoutubeController(
response.transform {
case result @ Success(_) => result
- case Failure(error) =>
+ case Failure(error) =>
logErrorWithRequestId(s"Failed to get atom ID for youtube ID $youtubeId", error)
Failure(error)
}
diff --git a/applications/app/model/DiagnosticsPageMetadata.scala b/applications/app/model/DiagnosticsPageMetadata.scala
new file mode 100644
index 000000000000..e38f1d680d7c
--- /dev/null
+++ b/applications/app/model/DiagnosticsPageMetadata.scala
@@ -0,0 +1,13 @@
+package model
+
+import play.api.libs.json.JsBoolean
+
+class DiagnosticsPageMetadata extends StandalonePage {
+ override val metadata = MetaData.make(
+ id = "Browser Diagnostics",
+ section = Some(SectionId.fromId("Index")),
+ webTitle = "Browser Diagnostics",
+ javascriptConfigOverrides = Map("isDiagnosticsPage" -> JsBoolean(true)),
+ shouldGoogleIndex = false,
+ )
+}
diff --git a/applications/app/pages/ContentHtmlPage.scala b/applications/app/pages/ContentHtmlPage.scala
index d4deb210e493..8937b5d6ae66 100644
--- a/applications/app/pages/ContentHtmlPage.scala
+++ b/applications/app/pages/ContentHtmlPage.scala
@@ -60,7 +60,7 @@ object ContentHtmlPage extends HtmlPage[Page] {
case p: MediaPage => mediaOrAudioBody(p)
case p: TodayNewspaper => newspaperContent(p)
case p: QuizAnswersPage => quizAnswerContent(p)
- case unsupported =>
+ case unsupported =>
throw new RuntimeException(
s"Type of content '${unsupported.getClass.getName}' is not supported by ${this.getClass.getName}",
)
diff --git a/applications/app/pages/TagIndexHtmlPage.scala b/applications/app/pages/TagIndexHtmlPage.scala
index 0af38725ae8b..e98d43bf3dd2 100644
--- a/applications/app/pages/TagIndexHtmlPage.scala
+++ b/applications/app/pages/TagIndexHtmlPage.scala
@@ -6,6 +6,7 @@ import html.{HtmlPage, Styles}
import model.{
ApplicationContext,
ContributorsListing,
+ DiagnosticsPageMetadata,
PreferencesMetaData,
StandalonePage,
SubjectsListing,
@@ -20,7 +21,8 @@ import views.html.fragments.page.head.stylesheets.{criticalStyleInline, critical
import views.html.fragments.page.head._
import views.html.fragments.page.{devTakeShot, htmlTag}
import views.html.preferences.index
-import html.HtmlPageHelpers.{ContentCSSFile}
+import views.html.browserDiagnostics.diagnosticsPage
+import html.HtmlPageHelpers.ContentCSSFile
object TagIndexHtmlPage extends HtmlPage[StandalonePage] {
@@ -39,10 +41,11 @@ object TagIndexHtmlPage extends HtmlPage[StandalonePage] {
implicit val p: StandalonePage = page
val content: Html = page match {
- case p: TagIndexPage => tagIndexBody(p)
- case p: PreferencesMetaData => index(p)
- case p: ContributorsListing => tagIndexListingBody("contributors", p.metadata.webTitle, p.listings)
- case p: SubjectsListing => tagIndexListingBody("subjects", p.metadata.webTitle, p.listings)
+ case p: TagIndexPage => tagIndexBody(p)
+ case p: PreferencesMetaData => index(p)
+ case p: ContributorsListing => tagIndexListingBody("contributors", p.metadata.webTitle, p.listings)
+ case p: SubjectsListing => tagIndexListingBody("subjects", p.metadata.webTitle, p.listings)
+ case p: DiagnosticsPageMetadata => diagnosticsPage(p)
case unsupported =>
throw new RuntimeException(
diff --git a/applications/app/services/NewspaperQuery.scala b/applications/app/services/NewspaperQuery.scala
index 5a3ba584b829..a3d8b392db4b 100644
--- a/applications/app/services/NewspaperQuery.scala
+++ b/applications/app/services/NewspaperQuery.scala
@@ -31,11 +31,6 @@ class NewspaperQuery(contentApiClient: ContentApiClient) extends Dates with GuLo
bookSectionContainers("theguardian/mainsection", getLatestGuardianPageFor(now), "theguardian")
}
- def fetchLatestObserverNewspaper()(implicit executionContext: ExecutionContext): Future[List[FaciaContainer]] = {
- val now = DateTime.now(DateTimeZone.UTC)
- bookSectionContainers("theobserver/news", getPastSundayDateFor(now), "theobserver")
- }
-
def fetchNewspaperForDate(path: String, day: String, month: String, year: String)(implicit
executionContext: ExecutionContext,
): Future[List[FaciaContainer]] = {
@@ -207,6 +202,7 @@ class NewspaperQuery(contentApiClient: ContentApiClient) extends Dates with GuLo
byline = None,
kicker = None,
brandingByEdition = Map.empty,
+ mediaAtom = None,
)
LinkSnap.make(fapiSnap)
}
diff --git a/applications/app/services/TagPagePicker.scala b/applications/app/services/TagPagePicker.scala
index 3a96103aa103..64c27f7ff325 100644
--- a/applications/app/services/TagPagePicker.scala
+++ b/applications/app/services/TagPagePicker.scala
@@ -10,9 +10,7 @@ import services.IndexPage
object TagPagePicker extends GuLogging {
def getTier(tagPage: IndexPage)(implicit request: RequestHeader): RenderType = {
- lazy val isSwitchedOn = DCRTagPages.isSwitchedOn;
-
- val checks = dcrChecks(tagPage)
+ lazy val isSwitchedOn = DCRTagPages.isSwitchedOn
val tier = decideTier(
request.isRss,
@@ -20,31 +18,19 @@ object TagPagePicker extends GuLogging {
request.forceDCROff,
request.forceDCR,
isSwitchedOn,
- dcrCouldRender(checks),
)
- logTier(tagPage, isSwitchedOn, dcrCouldRender(checks), checks, tier)
+ logTier(tagPage, isSwitchedOn, tier)
tier
}
- private def dcrCouldRender(checks: Map[String, Boolean]): Boolean = {
- checks.values.forall(identity)
- }
-
- private def dcrChecks(tagPage: IndexPage): Map[String, Boolean] = {
- Map(
- ("isNotTagCombiner", !tagPage.page.isInstanceOf[TagCombiner]),
- )
- }
-
private def decideTier(
isRss: Boolean,
isJson: Boolean,
forceDCROff: Boolean,
forceDCR: Boolean,
isSwitchedOn: Boolean,
- dcrCouldRender: Boolean,
): RenderType = {
if (isRss) LocalRender
else if (isJson) {
@@ -53,28 +39,22 @@ object TagPagePicker extends GuLogging {
else LocalRender
} else if (forceDCROff) LocalRender
else if (forceDCR) RemoteRender
- else if (dcrCouldRender && isSwitchedOn) RemoteRender
+ else if (isSwitchedOn) RemoteRender
else LocalRender
}
private def logTier(
tagPage: IndexPage,
isSwitchedOn: Boolean,
- dcrCouldRender: Boolean,
- checks: Map[String, Boolean],
tier: RenderType,
)(implicit request: RequestHeader): Unit = {
val tierReadable = if (tier == RemoteRender) "dotcomcomponents" else "web"
- val checksToString = checks.map { case (key, value) =>
- (key, value.toString)
- }
val properties =
Map(
"isSwitchedOn" -> isSwitchedOn.toString,
- "dcrCouldRender" -> dcrCouldRender.toString,
"isTagPage" -> "true",
"tier" -> tierReadable,
- ) ++ checksToString
+ )
DotcomFrontsLogger.logger.logRequest(s"tag front executing in $tierReadable", properties, tagPage)
}
diff --git a/applications/app/services/dotcomrendering/GalleryPicker.scala b/applications/app/services/dotcomrendering/GalleryPicker.scala
index 717f99f2df21..0b0861575fee 100644
--- a/applications/app/services/dotcomrendering/GalleryPicker.scala
+++ b/applications/app/services/dotcomrendering/GalleryPicker.scala
@@ -1,6 +1,7 @@
package services.dotcomrendering
import common.GuLogging
+import conf.switches.Switches.DCARGalleyPages
import model.Cors.RichRequestHeader
import model.GalleryPage
import play.api.mvc.RequestHeader
@@ -12,8 +13,20 @@ object GalleryPicker extends GuLogging {
)(implicit
request: RequestHeader,
): RenderType = {
- DotcomponentsLogger.logger.logRequest(s"path executing in web", Map.empty, galleryPage.gallery)
- LocalRender
+ val tier = {
+ if (request.forceDCROff) LocalRender
+ else if (request.forceDCR) RemoteRender
+ else if (DCARGalleyPages.isSwitchedOn) RemoteRender
+ else LocalRender
+ }
+
+ if (tier == RemoteRender) {
+ DotcomponentsLogger.logger.logRequest(s"path executing in dotcomponents", Map.empty, galleryPage.gallery)
+ } else {
+ DotcomponentsLogger.logger.logRequest(s"path executing in web", Map.empty, galleryPage.gallery)
+ }
+
+ tier
}
}
diff --git a/applications/app/views/browserDiagnostics/diagnosticsPage.scala.html b/applications/app/views/browserDiagnostics/diagnosticsPage.scala.html
new file mode 100644
index 000000000000..173e63711ef0
--- /dev/null
+++ b/applications/app/views/browserDiagnostics/diagnosticsPage.scala.html
@@ -0,0 +1,7 @@
+@(metaData: model.DiagnosticsPageMetadata)(implicit request: RequestHeader, context: model.ApplicationContext)
+
+@import views.html.fragments.containers.facia_cards.containerScaffold
+
+@containerScaffold("User benefits cookies", "user-benefits-cookies") {
+ Loading…
+}
diff --git a/applications/app/views/fragments/crosswords/crosswordContent.scala.html b/applications/app/views/fragments/crosswords/crosswordContent.scala.html
index 2eee10bf2172..e5384db55fa1 100644
--- a/applications/app/views/fragments/crosswords/crosswordContent.scala.html
+++ b/applications/app/views/fragments/crosswords/crosswordContent.scala.html
@@ -46,11 +46,6 @@
@fragments.commercial.standardAd("right", Seq("mpu-banner-ad"), Map())
- @if(crosswordPage.item.trail.isCommentable) {
-
- @fragments.commercial.standardAd("crossword-banner", Seq("crossword-banner"), Map())
-
- }
}
diff --git a/applications/app/views/fragments/galleryHeader.scala.html b/applications/app/views/fragments/galleryHeader.scala.html
index 4be88f075aa8..48ec58171893 100644
--- a/applications/app/views/fragments/galleryHeader.scala.html
+++ b/applications/app/views/fragments/galleryHeader.scala.html
@@ -32,8 +32,11 @@ @Html(gallery.item.trail.headline)
{
- Main image:
- @masterImage.caption.map(Html(_))
+ @if(masterImage.caption.isDefined) {
+ Main image:
+ @masterImage.caption.map(Html(_))
+ }
+
@if(masterImage.displayCredit && !masterImage.creditEndsWithCaption) {
@masterImage.credit.map(Html(_))
}
diff --git a/applications/app/views/package.scala b/applications/app/views/package.scala
index 3afd0e4cc08c..7818e3615893 100644
--- a/applications/app/views/package.scala
+++ b/applications/app/views/package.scala
@@ -45,6 +45,7 @@ object GalleryCaptionCleaners {
page.gallery.content.fields.showAffiliateLinks,
appendDisclaimer = Some(isFirstRow && page.item.lightbox.containsAffiliateableLinks),
tags = page.gallery.content.tags.tags.map(_.id),
+ page.gallery.content.isUSProductionOffice,
),
)
diff --git a/applications/conf/routes b/applications/conf/routes
index 2cc50e84c62f..1c602d172af1 100644
--- a/applications/conf/routes
+++ b/applications/conf/routes
@@ -7,6 +7,9 @@ GET /assets/*path
GET /_healthcheck controllers.HealthCheck.healthCheck()
+# Returns a page to pre-warm assets for the WebView cache on iOS
+GET /dcar-assets/rendered-items-assets controllers.DCARAssetsController.renderAsset()
+
GET /sitemaps/news.xml controllers.SiteMapController.renderNewsSiteMap()
GET /sitemaps/video.xml controllers.SiteMapController.renderVideoSiteMap()
@@ -17,11 +20,11 @@ GET /survey/:formName/show
GET /survey/thankyou controllers.SurveyPageController.thankYou()
# NOTE: Leave this as it is, otherwise we don't render /crosswords/series/prize, for example.
-GET /crosswords/$crosswordType/:id.svg controllers.CrosswordPageController.thumbnail(crosswordType: String, id: Int)
-GET /crosswords/$crosswordType/:id.json controllers.CrosswordPageController.renderJson(crosswordType: String, id: Int)
-GET /crosswords/$crosswordType/:id controllers.CrosswordPageController.crossword(crosswordType: String, id: Int)
-GET /crosswords/$crosswordType/:id/print controllers.CrosswordPageController.printableCrossword(crosswordType: String, id: Int)
-GET /crosswords/accessible/$crosswordType/:id controllers.CrosswordPageController.accessibleCrossword(crosswordType: String, id: Int)
+GET /crosswords/$crosswordType/:id.svg controllers.CrosswordPageController.thumbnail(crosswordType: String, id: Int)
+GET /crosswords/$crosswordType/:id.json controllers.CrosswordPageController.renderJson(crosswordType: String, id: Int)
+GET /crosswords/$crosswordType/:id controllers.CrosswordPageController.crossword(crosswordType: String, id: Int)
+GET /crosswords/$crosswordType/:id/print controllers.CrosswordPageController.printableCrossword(crosswordType: String, id: Int)
+GET /crosswords/accessible/$crosswordType/:id controllers.CrosswordPageController.accessibleCrossword(crosswordType: String, id: Int)
# Crosswords search
GET /crosswords/search controllers.CrosswordSearchController.search()
@@ -64,6 +67,10 @@ OPTIONS /story-questions/answers/signup
# Preferences
GET /preferences controllers.PreferencesController.indexPrefs()
+
+# Cookies
+GET /browser-diagnostics controllers.DiagnosticsController.renderDiagnosticsPage()
+
# opt-in/out routes
GET /opt/$choice/:feature controllers.OptInController.handle(feature, choice)
GET /opt/reset controllers.OptInController.reset()
@@ -73,7 +80,6 @@ GET /getprev/$tag<[\w\d-]*(/[\w\d-]*)?(/[\w\d-]*)?>/*path
# Newspaper pages
GET /theguardian controllers.NewspaperController.latestGuardianNewspaper()
-GET /theobserver controllers.NewspaperController.latestObserverNewspaper()
GET /$path/$year<\d\d\d\d>/$month<\w\w\w>/$day<\d\d> controllers.NewspaperController.newspaperForDate(path, day, month, year)
GET /$path/$year<\d\d\d\d>/$month<\w\w\w>/$day<\d\d>/all controllers.NewspaperController.allOn(path, day, month, year)
@@ -119,6 +125,7 @@ GET /$path<[\w\d-]*(/[\w\d-]*)+>/$file
# Interactive paths
GET /$path<[\w\d-]*(/[\w\d-]*)?/(interactive|ng-interactive)/.*>.json controllers.InteractiveController.renderInteractiveJson(path)
GET /$path<[\w\d-]*(/[\w\d-]*)?/(interactive|ng-interactive)/.*> controllers.InteractiveController.renderInteractive(path)
+GET /interactive/$path<[\w\d-]+(/[\w\d-]*)*> controllers.InteractiveController.renderInteractive(path)
# Interactive test (removing ng-interactive in the url)
GET /$path controllers.InteractiveController.renderInteractive(path)
@@ -137,6 +144,7 @@ GET /$path<[\w\d-]*(/[\w\d-]*)?(/[\w\d-]*)?>.json
GET /$path<[\w\d-]*(/[\w\d-]*)?(/[\w\d-]*)?> controllers.IndexController.render(path)
# Tag combiners
+GET /$leftSide<[^+]+>+*rightSide.json controllers.IndexController.renderCombiner(leftSide, rightSide)
GET /$leftSide<[^+]+>+*rightSide controllers.IndexController.renderCombiner(leftSide, rightSide)
# Google site verification
diff --git a/applications/test/IndexControllerTest.scala b/applications/test/IndexControllerTest.scala
index 91c7e1f786ed..02cdb53d8826 100644
--- a/applications/test/IndexControllerTest.scala
+++ b/applications/test/IndexControllerTest.scala
@@ -149,12 +149,12 @@ import play.api.libs.ws.WSClient
it should "resolve uk-news combiner pages" in {
val result = indexController.renderCombiner("uk-news/series/writlarge", "law/trial-by-jury")(
- TestRequest("/uk-news/series/writlarge+law/trial-by-jury"),
+ TestRequest("/uk-news/series/writlarge+law/trial-by-jury?dcr=false"),
)
status(result) should be(200)
val result2 = indexController.renderCombiner("uk-news/the-northerner", "blackpool")(
- TestRequest("/uk-news/the-northerner+blackpool"),
+ TestRequest("/uk-news/the-northerner+blackpool?dcr=false"),
)
status(result2) should be(200)
}
diff --git a/applications/test/InteractiveControllerTest.scala b/applications/test/InteractiveControllerTest.scala
index 1c4fef9c6737..2c5675c68858 100644
--- a/applications/test/InteractiveControllerTest.scala
+++ b/applications/test/InteractiveControllerTest.scala
@@ -1,24 +1,23 @@
package test
-import controllers.InteractiveController
-import play.api.test.Helpers._
-import org.scalatest.{BeforeAndAfterAll, DoNotDiscover, PrivateMethodTester}
import conf.Configuration.interactive.cdnPath
-import play.api.libs.ws.WSClient
-import com.gu.contentapi.client.model.v1.Blocks
-import model.dotcomrendering.PageType
+import controllers.InteractiveController
import model.InteractivePage
+import model.dotcomrendering.PageType
+import model.meta.BlocksOn
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
+import org.scalatest.{BeforeAndAfterAll, DoNotDiscover, PrivateMethodTester}
+import play.api.libs.ws.WSClient
import play.api.mvc.{RequestHeader, Result, Results}
+import play.api.test.Helpers._
import scala.concurrent.Future
class DCRFake() extends renderers.DotcomRenderingService {
override def getInteractive(
ws: WSClient,
- page: InteractivePage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[InteractivePage],
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
Future.successful(Results.Ok("test"))
diff --git a/applications/test/SectionTemplateTest.scala b/applications/test/SectionTemplateTest.scala
deleted file mode 100644
index c969679eddb7..000000000000
--- a/applications/test/SectionTemplateTest.scala
+++ /dev/null
@@ -1,55 +0,0 @@
-package test
-
-import java.net.URI
-import io.fluentlenium.core.domain.FluentWebElement
-import org.scalatest.flatspec.AnyFlatSpec
-import org.scalatest.matchers.should.Matchers
-import org.scalatest.DoNotDiscover
-import play.api.test.TestBrowser
-
-import scala.jdk.CollectionConverters._
-
-@DoNotDiscover class SectionTemplateTest extends AnyFlatSpec with Matchers with ConfiguredTestSuite {
-
- it should "render front title" in goTo("/uk-news?dcr=false") { browser =>
- browser.el("[data-test-id=header-title]").text should be("UK news")
- }
-
- it should "add alternate pages to editionalised sections for /uk/culture" in goTo("/uk/culture?dcr=false") {
- browser =>
- val alternateLinks = getAlternateLinks(browser)
- alternateLinks.size should be(3)
- alternateLinks.exists(link =>
- toPath(link.attribute("href")) == "/us/culture" && link.attribute("hreflang") == "en-US",
- ) should be(true)
- alternateLinks.exists(link =>
- toPath(link.attribute("href")) == "/au/culture" && link.attribute("hreflang") == "en-AU",
- ) should be(true)
- alternateLinks.exists(link =>
- toPath(link.attribute("href")) == "/uk/culture" && link.attribute("hreflang") == "en-GB",
- ) should be(true)
-
- }
-
- def getAlternateLinks(browser: TestBrowser): Seq[FluentWebElement] = {
- import browser._
- $("link[rel='alternate']").asScala.toList
- .filterNot(_.attribute("type") == "application/rss+xml")
- .filter(element => {
- val href: Option[String] = Option(element.attribute("href"))
- href.isDefined && !href.exists(_.contains("ios-app"))
- })
- }
-
- it should "not add alternate pages to non editionalised sections" in goTo("/books?dcr=false") { browser =>
- val alternateLinks = getAlternateLinks(browser)
- alternateLinks should be(empty)
- }
-
- it should "not add alternate pages to 'all' pages for a section" in goTo("/business/1929/oct/24/all") { browser =>
- val alternateLinks = getAlternateLinks(browser)
- alternateLinks should be(empty)
- }
-
- private def toPath(url: String) = new URI(url).getPath
-}
diff --git a/applications/test/TagFeatureTest.scala b/applications/test/TagFeatureTest.scala
deleted file mode 100644
index 9f3004d5f941..000000000000
--- a/applications/test/TagFeatureTest.scala
+++ /dev/null
@@ -1,117 +0,0 @@
-package test
-
-import org.scalatest.{DoNotDiscover, GivenWhenThen}
-import services.IndexPagePagination
-
-import scala.jdk.CollectionConverters._
-import conf.switches.Switches
-import io.fluentlenium.core.domain.{FluentList, FluentWebElement}
-import org.scalatest.featurespec.AnyFeatureSpec
-import org.scalatest.matchers.should.Matchers
-
-@DoNotDiscover class TagFeatureTest extends AnyFeatureSpec with GivenWhenThen with Matchers with ConfiguredTestSuite {
-
- Feature("Tag Series, Blogs and Contributors Pages trail size") {
-
- Scenario("Tag Series, Blogs and Contributors pages should show 50 trails (includes leadContent if present)") {
-
- Given("I visit a tag page")
-
- goTo("/technology/askjack?dcr=false") { browser =>
- val trails = browser.$(".fc-item__container")
- trails.asScala.length should be(IndexPagePagination.pageSize)
- }
- }
- }
-
- Feature("Contributor pages") {
-
- Scenario("Should display the profile images") {
-
- Given("I visit the 'Jemima Kiss' contributor page")
- Switches.ImageServerSwitch.switchOn()
-
- goTo("/profile/jemimakiss?dcr=false") { browser =>
- Then("I should see her profile image")
- val profileImage = browser.el("[data-test-id=header-image]")
- profileImage.attribute("src") should include(s"42593747/Jemima-Kiss.jpg")
- }
- }
-
- Scenario("Should not not display profiles where they don't exist") {
-
- Given("I visit the 'Sam Jones' contributor page")
- goTo("/profile/samjones?dcr=false") { browser =>
- Then("I should not see her profile image")
- val profileImages = browser.find(".profile__img img")
- profileImages.asScala.length should be(0)
- }
-
- }
- }
-
- Feature("Tag Pages") {
-
- Scenario("Pagination") {
-
- /*
- This test is consistently failing locally, and thus does not generate the required data/database/xxx file
- and it seems to be linked to the browser .click() behaviour, so I'm trimming it down a bit to test the
- basics in two goes.
-
- I've left the commented code in below so we can reinstate it as and when we can figure out how to make it
- work properly again :(
- */
-
- Given("I visit the 'Cycling' tag page")
-
- goTo("/sport/cycling?dcr=false") { browser =>
- import browser._
-
- val cardsOnFirstPage = browser.find("[data-test-id=facia-card]")
- val dataIdsOnFirstPage = cardsOnFirstPage.asScala.map(_.attribute("data-id")).toSet
- cardsOnFirstPage.size should be > 10
- findByRel($("link"), "next").head.attribute("href") should endWith("/sport/cycling?page=2")
- findByRel($("link"), "prev") should be(None)
-
-// Then("I should be able to navigate to the 'next' page")
-// el(".pagination").$("[rel=next]").click()
-// val cardsOnNextPage = browser.find("[data-test-id=facia-card]")
-// val dataIdsOnNextPage = cardsOnNextPage.asScala.map(_.attribute("data-id"))
-// cardsOnNextPage.size should be > 10
-//
-// findByRel($("link"), "next").head.attribute("href") should endWith ("/sport/cycling?page=3")
-// findByRel($("link"), "prev").head.attribute("href") should endWith ("/sport/cycling")
-//
-// dataIdsOnFirstPage intersect dataIdsOnNextPage.toSet should be(Set.empty)
-//
-// And("The title should reflect the page number")
-// browser.window.title should include ("| Page 2 of")
-//
-// And("I should be able to navigate to the 'previous' page")
-// el(".pagination").$("[rel=prev]").click()
-// val cardsOnPreviousPage = browser.find("[data-test-id=facia-card]")
-// cardsOnPreviousPage.asScala.map(_.attribute("data-id")).toSet should be(dataIdsOnFirstPage)
- }
-
- Given("I visit page 2 of the 'Cycling' tag page")
-
- goTo("/sport/cycling?page=2&dcr=false") { browser =>
- import browser._
-
- val cardsOnNextPage = browser.find("[data-test-id=facia-card]")
- cardsOnNextPage.size should be > 10
-
- findByRel($("link"), "next").head.attribute("href") should endWith("/sport/cycling?page=3")
- findByRel($("link"), "prev").head.attribute("href") should endWith("/sport/cycling")
-
- And("The title should reflect the page number")
- browser.window.title should include("| Page 2 of")
- }
- }
- }
-
- // I'm not having a happy time with the selectors on links...
- private def findByRel(elements: FluentList[FluentWebElement], rel: String) =
- elements.asScala.find(_.attribute("rel") == rel)
-}
diff --git a/applications/test/common/CombinerFeatureTest.scala b/applications/test/common/CombinerFeatureTest.scala
index bcb6406e6511..137df0b60e4f 100644
--- a/applications/test/common/CombinerFeatureTest.scala
+++ b/applications/test/common/CombinerFeatureTest.scala
@@ -19,7 +19,7 @@ import scala.jdk.CollectionConverters._
Given("I visit a combiner page")
- goTo("/world/iraq+tone/comment") { browser =>
+ goTo("/world/iraq+tone/comment?dcr=false") { browser =>
import browser._
val trails = $(".fc-slice__item")
Then("I should see content tagged with both tags")
@@ -31,7 +31,7 @@ import scala.jdk.CollectionConverters._
Given("I visit a combiner page")
- goTo("/science+technology/apple") { browser =>
+ goTo("/science+technology/apple?dcr=false") { browser =>
import browser._
val trails = $(".fromage, .fc-slice__item, .linkslist__item")
Then("I should see content tagged with both the section and the tag")
@@ -44,7 +44,7 @@ import scala.jdk.CollectionConverters._
Given("I visit a combiner page with tags in the same section")
- goTo("/books/jkrowling+harrypotter") { browser =>
+ goTo("/books/jkrowling+harrypotter?dcr=false") { browser =>
import browser._
val trails = $(".fromage, .fc-slice__item, .linkslist__item")
Then("I should see content tagged with both tags")
@@ -57,7 +57,7 @@ import scala.jdk.CollectionConverters._
Given("I visit a combiner page with a series tag in the same seciton")
goTo(
- "/lifeandstyle/series/quick-and-healthy-recipes+series/hugh-fearnley-whittingstall-quick-and-healthy-lunches",
+ "/lifeandstyle/series/quick-and-healthy-recipes+series/hugh-fearnley-whittingstall-quick-and-healthy-lunches?dcr=false",
) { browser =>
import browser._
val trails = $(".fromage, .fc-slice__item, .linkslist__item")
diff --git a/applications/test/package.scala b/applications/test/package.scala
index 05ae91bbee48..f4bdbc98395d 100644
--- a/applications/test/package.scala
+++ b/applications/test/package.scala
@@ -33,8 +33,6 @@ class ApplicationsTestSuite
new LatestIndexControllerTest,
new MediaControllerTest,
new MediaFeatureTest,
- new SectionTemplateTest,
- new TagFeatureTest,
new TagTemplateTest,
new ShareLinksTest,
new CrosswordDataTest,
diff --git a/archive/app/AppLoader.scala b/archive/app/AppLoader.scala
index 1d2721c5ffb6..bafe8a72d570 100644
--- a/archive/app/AppLoader.scala
+++ b/archive/app/AppLoader.scala
@@ -14,7 +14,7 @@ import play.api.http.{HttpErrorHandler, HttpRequestHandler}
import play.api.libs.ws.WSClient
import play.api.mvc.EssentialFilter
import play.api.routing.Router
-import services.{ArchiveMetrics, RedirectService}
+import services.RedirectService
import router.Routes
class AppLoader extends FrontendApplicationLoader {
@@ -33,7 +33,6 @@ trait AppComponents extends FrontendComponents {
override lazy val lifecycleComponents = List(
wire[CloudWatchMetricsLifecycle],
- wire[ArchiveMetrics],
wire[SwitchboardLifecycle],
wire[CachedHealthCheckLifeCycle],
)
diff --git a/archive/app/controllers/ArchiveController.scala b/archive/app/controllers/ArchiveController.scala
index 936909d45cc4..4b100afb4307 100644
--- a/archive/app/controllers/ArchiveController.scala
+++ b/archive/app/controllers/ArchiveController.scala
@@ -4,7 +4,7 @@ import commercial.campaigns.ShortCampaignCodes
import common._
import model.Cached.{CacheableResult, WithoutRevalidationResult}
import play.api.mvc._
-import services.{GoogleBotMetric, RedirectService}
+import services.RedirectService
import java.net.URLDecoder
import javax.ws.rs.core.UriBuilder
@@ -31,6 +31,7 @@ class ArchiveController(redirects: RedirectService, val controllerComponents: Co
private val NewspaperPage = "^(/theguardian|/theobserver)/(\\d{4}/\\w{3}/\\d{2})/(.+)".r
private val redirectHttpStatus = HttpStatus.SC_MOVED_PERMANENTLY
+ private val tempRedirectHttpStatus = HttpStatus.SC_TEMPORARY_REDIRECT
def getLocal404Page(implicit request: RequestHeader): Future[Result] =
Future {
@@ -122,6 +123,12 @@ class ArchiveController(redirects: RedirectService, val controllerComponents: Co
}
}
+ private def tempRedirectTo(path: String)(implicit request: RequestHeader): Result = {
+ val redirect = LinkTo(path)
+
+ logInfoWithRequestId(s"""Archive $tempRedirectHttpStatus, redirect to $redirect""")
+ Cached(CacheTime.ArchiveRedirect)(WithoutRevalidationResult(Redirect(redirect, tempRedirectHttpStatus)))
+ }
private def redirectTo(path: String, pathSuffixes: String*)(implicit request: RequestHeader): Result = {
val endOfPath = if (pathSuffixes.isEmpty) "" else s"/${pathSuffixes.mkString("/")}"
val redirect = LinkTo(path) + endOfPath
@@ -135,7 +142,9 @@ class ArchiveController(redirects: RedirectService, val controllerComponents: Co
private def redirectForPath(path: String)(implicit request: RequestHeader): Option[Result] =
path match {
- case Gallery(gallery) => Some(redirectTo(gallery))
+ // gallery has a temp redirect as the regex may catch people trying to open modern galleries
+ // before they are published, and store the incorrect redirect in their browser forevermore
+ case Gallery(gallery) => Some(tempRedirectTo(gallery))
case Century(century) => Some(redirectTo(century))
case Guardian(endOfUrl) => Some(redirectTo(endOfUrl))
case Lowercase(lower) => Some(redirectTo(lower))
diff --git a/archive/app/services/ArchiveMetrics.scala b/archive/app/services/ArchiveMetrics.scala
deleted file mode 100644
index ed47c6499aa7..000000000000
--- a/archive/app/services/ArchiveMetrics.scala
+++ /dev/null
@@ -1,36 +0,0 @@
-package services
-
-import app.LifecycleComponent
-import common.JobScheduler
-import metrics.CountMetric
-import model.diagnostics.CloudWatch
-import play.api.inject.ApplicationLifecycle
-
-import scala.concurrent.{ExecutionContext, Future}
-
-object GoogleBotMetric {
- val Googlebot404Count = CountMetric("googlebot-404s", "Googlebot 404s")
-}
-
-class ArchiveMetrics(appLifecycle: ApplicationLifecycle, jobs: JobScheduler)(implicit ec: ExecutionContext)
- extends LifecycleComponent {
-
- appLifecycle.addStopHook { () =>
- Future {
- jobs.deschedule("ArchiveSystemMetricsJob")
- }
- }
-
- private def report(): Unit = {
- CloudWatch.putMetrics("ArchiveMetrics", List(GoogleBotMetric.Googlebot404Count), List.empty)
- }
-
- override def start(): Unit = {
- jobs.deschedule("ArchiveSystemMetricsJob")
-
- jobs.schedule("ArchiveSystemMetricsJob", "0 * * * * ?") {
- report()
- }
- }
-
-}
diff --git a/archive/test/ArchiveControllerTest.scala b/archive/test/ArchiveControllerTest.scala
index eccd1de46354..69687e2790d4 100644
--- a/archive/test/ArchiveControllerTest.scala
+++ b/archive/test/ArchiveControllerTest.scala
@@ -80,7 +80,7 @@ import services.RedirectService.{ArchiveRedirect, PermanentRedirect}
it should "redirect old style galleries" in {
val result = archiveController.lookup("/arts/gallery/0,")(TestRequest())
- status(result) should be(301)
+ status(result) should be(307)
location(result) should be(s"${Configuration.site.host}/arts/pictures/0,")
}
diff --git a/article/app/AppLoader.scala b/article/app/AppLoader.scala
index fd8418a3eb21..a35b87b69fd6 100644
--- a/article/app/AppLoader.scala
+++ b/article/app/AppLoader.scala
@@ -1,11 +1,9 @@
import _root_.commercial.targeting.TargetingLifecycle
-import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem}
import app.{FrontendApplicationLoader, FrontendBuildInfo, FrontendComponents}
import com.softwaremill.macwire._
import common._
import common.dfp.DfpAgentLifecycle
-import concurrent.BlockingOperations
-import conf.{CachedHealthCheckLifeCycle, Configuration}
+import conf.CachedHealthCheckLifeCycle
import conf.switches.SwitchboardLifecycle
import contentapi.{CapiHttpClient, ContentApiClient, HttpClient}
import controllers.{ArticleControllers, HealthCheck}
@@ -13,16 +11,16 @@ import dev.{DevAssetsController, DevParametersHttpRequestHandler}
import http.{CommonFilters, CorsHttpErrorHandler}
import jobs.StoreNavigationLifecycleComponent
import model.ApplicationIdentity
+import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem}
import play.api.ApplicationLoader.Context
import play.api.BuiltInComponentsFromContext
import play.api.http.{HttpErrorHandler, HttpRequestHandler}
import play.api.mvc.EssentialFilter
import play.api.routing.Router
import router.Routes
-import services.fronts.FrontJsonFapiLive
import services.newsletters.{NewsletterApi, NewsletterSignupAgent, NewsletterSignupLifecycle}
import services.ophan.SurgingContentAgentLifecycle
-import services.{NewspaperBooksAndSectionsAutoRefresh, OphanApi, S3Client, S3ClientImpl, SkimLinksCacheLifeCycle}
+import services.{NewspaperBooksAndSectionsAutoRefresh, OphanApi, SkimLinksCacheLifeCycle}
class AppLoader extends FrontendApplicationLoader {
override def buildComponents(context: Context): FrontendComponents =
diff --git a/article/app/controllers/ArticleController.scala b/article/app/controllers/ArticleController.scala
index f2ae934b5532..bed5abc2991a 100644
--- a/article/app/controllers/ArticleController.scala
+++ b/article/app/controllers/ArticleController.scala
@@ -3,18 +3,19 @@ package controllers
import com.gu.contentapi.client.model.v1.{Blocks, ItemResponse, Content => ApiContent}
import common._
import contentapi.ContentApiClient
-import implicits.{AmpFormat, AppsFormat, EmailFormat, HtmlFormat, JsonFormat}
+import implicits._
import model.Cached.{RevalidatableResult, WithoutRevalidationResult}
-import model.dotcomrendering.{DotcomRenderingDataModel, PageType}
import model._
+import model.dotcomrendering.{DotcomRenderingDataModel, PageType}
+import model.meta.BlocksOn
import pages.{ArticleEmailHtmlPage, ArticleHtmlPage}
-import play.api.libs.json.{Json, JsValue}
+import play.api.libs.json.{JsValue, Json}
import play.api.libs.ws.WSClient
import play.api.mvc._
import renderers.DotcomRenderingService
-import services.{CAPILookup, NewsletterService}
import services.dotcomrendering.{ArticlePicker, PressedArticle, RemoteRender}
-import views.support._
+import services.{CAPILookup, NewsletterService}
+import views.support.RenderOtherStatus
import scala.concurrent.Future
@@ -34,32 +35,17 @@ class ArticleController(
private def isSupported(c: ApiContent) = c.isArticle || c.isLiveBlog || c.isSudoku
override def canRender(i: ItemResponse): Boolean = i.content.exists(isSupported)
- override def renderItem(path: String)(implicit request: RequestHeader): Future[Result] =
- mapModel(path, GenericFallback)((article, blocks) => render(path, article, blocks))
-
- def renderJson(path: String): Action[AnyContent] = {
- Action.async { implicit request =>
- mapModel(path, ArticleBlocks) { (article, blocks) =>
- render(path, article, blocks)
- }
- }
- }
+ override def renderItem(path: String)(implicit req: RequestHeader): Future[Result] =
+ mapAndRender(path, GenericFallback)()
- def renderArticle(path: String): Action[AnyContent] = {
- Action.async { implicit request =>
- mapModel(path, ArticleBlocks) { (article, blocks) =>
- render(path, article, blocks)
- }
- }
- }
+ def mapAndRender(path: String, range: BlockRange)(
+ modifier: BlocksOn[ArticlePage] => BlocksOn[ArticlePage] = identity,
+ )(implicit req: RequestHeader): Future[Result] =
+ mapModel(path, range) { pageBlocks => render(path, modifier(pageBlocks)) }
- def renderEmail(path: String): Action[AnyContent] = {
- Action.async { implicit request =>
- mapModel(path, ArticleBlocks) { (article, blocks) =>
- render(path, article, blocks)
- }
- }
- }
+ def renderArticle(path: String): Action[AnyContent] = Action.async(mapAndRender(path, ArticleBlocks)()(_))
+ def renderJson(path: String): Action[AnyContent] = renderArticle(path)
+ def renderEmail(path: String): Action[AnyContent] = renderArticle(path)
def renderHeadline(path: String): Action[AnyContent] =
Action.async { implicit request =>
@@ -91,20 +77,19 @@ class ArticleController(
/** Returns a JSON representation of the payload that's sent to DCR when rendering the Article.
*/
- private def getDCRJson(article: ArticlePage, blocks: Blocks)(implicit request: RequestHeader): JsValue = {
- val pageType: PageType = PageType(article, request, context)
- val newsletter = newsletterService.getNewsletterForArticle(article)
+ private def getDCRJson(pageBlocks: BlocksOn[ArticlePage])(implicit request: RequestHeader): JsValue = {
+ val pageType: PageType = PageType(pageBlocks.page, request, context)
+ val newsletter = newsletterService.getNewsletterForArticle(pageBlocks.page)
DotcomRenderingDataModel.toJson(
- DotcomRenderingDataModel
- .forArticle(article, blocks, request, pageType, newsletter),
+ DotcomRenderingDataModel.forArticle(pageBlocks, request, pageType, newsletter),
)
}
- private def render(path: String, article: ArticlePage, blocks: Blocks)(implicit
+ private def render(path: String, pageBlocks: BlocksOn[ArticlePage])(implicit
request: RequestHeader,
): Future[Result] = {
-
+ val article = pageBlocks.page
val newsletter = newsletterService.getNewsletterForArticle(article)
val tier = ArticlePicker.getTier(article, path)
@@ -112,7 +97,7 @@ class ArticleController(
val pageType: PageType = PageType(article, request, context)
request.getRequestFormat match {
case JsonFormat if request.forceDCR =>
- Future.successful(common.renderJson(getDCRJson(article, blocks), article).as("application/json"))
+ Future.successful(common.renderJson(getDCRJson(pageBlocks), article).as("application/json"))
case JsonFormat =>
Future.successful(common.renderJson(getJson(article), article))
case EmailFormat =>
@@ -120,12 +105,11 @@ class ArticleController(
case HtmlFormat | AmpFormat if tier == PressedArticle =>
servePressedPage(path)
case AmpFormat if isAmpSupported =>
- remoteRenderer.getAMPArticle(ws, article, blocks, pageType, newsletter)
+ remoteRenderer.getAMPArticle(ws, pageBlocks, pageType, newsletter)
case HtmlFormat | AmpFormat if tier == RemoteRender =>
remoteRenderer.getArticle(
ws,
- article,
- blocks,
+ pageBlocks,
pageType,
newsletter,
)
@@ -134,8 +118,7 @@ class ArticleController(
case AppsFormat =>
remoteRenderer.getAppsArticle(
ws,
- article,
- blocks,
+ pageBlocks,
pageType,
newsletter,
)
@@ -143,27 +126,27 @@ class ArticleController(
}
private def mapModel(path: String, range: BlockRange)(
- render: (ArticlePage, Blocks) => Future[Result],
+ render: BlocksOn[ArticlePage] => Future[Result],
)(implicit request: RequestHeader): Future[Result] = {
capiLookup
.lookup(path, Some(range))
.map(responseToModelOrResult)
.recover(convertApiExceptions)
.flatMap {
- case Right((model, blocks)) => render(model, blocks)
- case Left(other) => Future.successful(RenderOtherStatus(other))
+ case Right(pageBlocks) => render(pageBlocks)
+ case Left(other) => Future.successful(RenderOtherStatus(other))
}
}
private def responseToModelOrResult(
response: ItemResponse,
- )(implicit request: RequestHeader): Either[Result, (ArticlePage, Blocks)] = {
+ )(implicit request: RequestHeader): Either[Result, BlocksOn[ArticlePage]] = {
val supportedContent: Option[ContentType] = response.content.filter(isSupported).map(Content(_))
val blocks = response.content.flatMap(_.blocks).getOrElse(Blocks())
ModelOrResult(supportedContent, response) match {
case Right(article: Article) =>
- Right((ArticlePage(article, StoryPackages(article.metadata.id, response)), blocks))
+ Right(BlocksOn(ArticlePage(article, StoryPackages(article.metadata.id, response)), blocks))
case Left(r) => Left(r)
case _ => Left(NotFound)
}
diff --git a/article/app/controllers/ArticleControllers.scala b/article/app/controllers/ArticleControllers.scala
index 9223368ce5c4..0be2a700fd43 100644
--- a/article/app/controllers/ArticleControllers.scala
+++ b/article/app/controllers/ArticleControllers.scala
@@ -6,8 +6,8 @@ import model.ApplicationContext
import play.api.libs.ws.WSClient
import play.api.mvc.ControllerComponents
import renderers.DotcomRenderingService
-import services.{NewsletterService, NewspaperBookSectionTagAgent, NewspaperBookTagAgent, S3Client}
import services.newsletters.NewsletterSignupAgent
+import services.{NewsletterService, NewspaperBookSectionTagAgent, NewspaperBookTagAgent}
trait ArticleControllers {
def contentApiClient: ContentApiClient
diff --git a/article/app/controllers/LiveBlogController.scala b/article/app/controllers/LiveBlogController.scala
index ab16ea2c2888..0c071a4aac26 100644
--- a/article/app/controllers/LiveBlogController.scala
+++ b/article/app/controllers/LiveBlogController.scala
@@ -12,6 +12,7 @@ import model.dotcomrendering.{DotcomRenderingDataModel, PageType}
import model.liveblog.BodyBlock
import model.liveblog.BodyBlock.{KeyEvent, SummaryEvent}
import model._
+import model.meta.BlocksOn
import pages.{ArticleEmailHtmlPage, LiveBlogHtmlPage, MinuteHtmlPage}
import play.api.libs.ws.WSClient
import play.api.mvc._
@@ -46,10 +47,12 @@ class LiveBlogController(
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)
+ _.page match {
+ 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)
+ }
}
}
}
@@ -96,29 +99,31 @@ class LiveBlogController(
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))
- }
+ mapModel(path, range, filter) { pageBlocks =>
+ pageBlocks.page match {
+ 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 if request.forceDCR && lastUpdate.isEmpty =>
+ Future.successful(renderDCRJson(pageBlocks.copy(page = blog), filter))
+ case blog: LiveBlogPage =>
+ getJson(
+ blog,
+ range,
+ isLivePage,
+ filter,
+ pageBlocks.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))
+ }
+ }
}
}
}
@@ -130,8 +135,9 @@ class LiveBlogController(
)(implicit
request: RequestHeader,
): Future[Result] = {
- mapModel(path, range, filterKeyEvents) { (page, blocks) =>
+ mapModel(path, range, filterKeyEvents) { pageBlocks =>
{
+ val page = pageBlocks.page
val isAmpSupported = page.article.content.shouldAmplify
val pageType: PageType = PageType(page, request, context)
(page, request.getRequestFormat) match {
@@ -161,8 +167,7 @@ class LiveBlogController(
val pageType: PageType = PageType(blog, request, context)
remoteRenderer.getArticle(
ws,
- blog,
- blocks,
+ pageBlocks,
pageType,
newsletter = None,
filterKeyEvents,
@@ -173,14 +178,13 @@ class LiveBlogController(
Future.successful(common.renderHtml(LiveBlogHtmlPage.html(blog), blog))
}
case (blog: LiveBlogPage, AmpFormat) if isAmpSupported =>
- remoteRenderer.getAMPArticle(ws, blog, blocks, pageType, newsletter = None, filterKeyEvents)
+ remoteRenderer.getAMPArticle(ws, pageBlocks, 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,
+ pageBlocks,
pageType,
newsletter = None,
filterKeyEvents,
@@ -331,17 +335,16 @@ class LiveBlogController(
/** 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,
+ pageBlocks: BlocksOn[LiveBlogPage],
filterKeyEvents: Boolean,
)(implicit request: RequestHeader): Result = {
+ val blog = pageBlocks.page
val pageType: PageType = PageType(blog, request, context)
val newsletter = newsletterService.getNewsletterForLiveBlog(blog)
val model =
DotcomRenderingDataModel.forLiveblog(
- blog,
- blocks,
+ pageBlocks,
request,
pageType,
filterKeyEvents,
@@ -357,38 +360,38 @@ class LiveBlogController(
range: BlockRange,
filterKeyEvents: Boolean = false,
)(
- render: (PageWithStoryPackage, Blocks) => Future[Result],
+ render: BlocksOn[PageWithStoryPackage] => 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))
+ case Right(pageBlocks) => render(pageBlocks)
+ case Left(other) => Future.successful(RenderOtherStatus(other))
}
}
private[this] def responseToModelOrResult(
range: BlockRange,
filterKeyEvents: Boolean,
- )(response: ItemResponse)(implicit request: RequestHeader): Either[Result, (PageWithStoryPackage, Blocks)] = {
+ )(response: ItemResponse)(implicit request: RequestHeader): Either[Result, BlocksOn[PageWithStoryPackage]] = {
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)
+ Right(MinutePage(minute, StoryPackages(minute.metadata.id, response)))
case liveBlog: Article if liveBlog.isLiveBlog && request.isEmail =>
- Right(MinutePage(liveBlog, StoryPackages(liveBlog.metadata.id, response)), blocks)
+ Right(MinutePage(liveBlog, StoryPackages(liveBlog.metadata.id, response)))
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.
@@ -404,7 +407,7 @@ class LiveBlogController(
Left(InternalServerError)
}
- content
+ content.map(BlocksOn(_, blocks))
}
def shouldFilter(filterKeyEvents: Option[Boolean]): Boolean = {
diff --git a/article/app/services/S3Client.scala b/article/app/services/S3Client.scala
deleted file mode 100644
index 616b62734e98..000000000000
--- a/article/app/services/S3Client.scala
+++ /dev/null
@@ -1,97 +0,0 @@
-package services
-
-import com.amazonaws.services.s3.AmazonS3
-import com.amazonaws.services.s3.model.{GetObjectRequest, S3Object}
-import com.amazonaws.util.IOUtils
-import common.GuLogging
-import play.api.libs.json.{JsError, JsSuccess, Json, Reads}
-
-import scala.concurrent.Future
-import scala.jdk.CollectionConverters._
-import scala.reflect.ClassTag
-import scala.util.{Failure, Success, Try}
-
-trait S3Client[T] {
- def getListOfKeys(): Future[List[String]]
-
- def getObject(key: String)(implicit read: Reads[T]): Future[T]
-}
-
-class S3ClientImpl[T](optionalBucket: Option[String])(implicit genericType: ClassTag[T])
- extends S3Client[T]
- with S3
- with GuLogging {
-
- def getListOfKeys(): Future[List[String]] = {
- getClient { client =>
- Try {
- val s3ObjectList = client.listObjects(getBucket()).getObjectSummaries().asScala.toList
- s3ObjectList.map(_.getKey)
- } match {
- case Success(value) =>
- log.info(s"got list of ${value.length} $genericTypeName items from S3")
- Future.successful(value)
- case Failure(exception) =>
- log.error(s"failed in getting the list of $genericTypeName items from S3", exception)
- Future.failed(exception)
- }
- }
- }
-
- def getObject(key: String)(implicit read: Reads[T]): Future[T] = {
- getClient { client =>
- Try {
- val request = new GetObjectRequest(getBucket(), key)
- parseResponse(client.getObject(request))
- }.flatten match {
- case Success(value) =>
- log.info(s"got $genericTypeName response from S3 for key ${key}")
- Future.successful(value)
- case Failure(exception) =>
- log.error(s"S3 retrieval failed for $genericTypeName key ${key}", exception)
- Future.failed(exception)
- }
- }
- }
-
- private def getBucket() = {
- optionalBucket.getOrElse(
- throw new RuntimeException(s"bucket config is empty for $genericTypeName, make sure config parameter has value"),
- )
- }
-
- private def getClient[T](callS3: AmazonS3 => Future[T]) = {
- client
- .map { callS3(_) }
- .getOrElse(Future.failed(new RuntimeException("No client exists for S3Client")))
- }
-
- private def parseResponse(s3Object: S3Object)(implicit read: Reads[T]): Try[T] = {
- val json = Json.parse(asString(s3Object))
-
- Json.fromJson[T](json) match {
- case JsSuccess(response, __) =>
- log.debug(s"Parsed $genericTypeName response from S3 for key ${s3Object.getKey}")
- Success(response)
- case JsError(errors) =>
- val errorPaths = errors.map { error => error._1.toString() }.mkString(",")
- log.error(s"Error parsing $genericTypeName response from S3 for key ${s3Object.getKey} paths: ${errorPaths}")
- Failure(
- new Exception(
- s"could not parse S3 $genericTypeName response json. Errors paths(s): $errors",
- ),
- )
- }
- }
-
- private def asString(s3Object: S3Object): String = {
- val s3ObjectContent = s3Object.getObjectContent
- try {
- IOUtils.toString(s3ObjectContent)
- } finally {
- s3ObjectContent.close()
- }
- }
-
- private def genericTypeName = genericType.runtimeClass.getSimpleName
-}
diff --git a/article/app/views/package.scala b/article/app/views/package.scala
index 48b62b0a5db1..de27fabc7b85 100644
--- a/article/app/views/package.scala
+++ b/article/app/views/package.scala
@@ -82,6 +82,7 @@ object BodyProcessor {
pageUrl = request.uri,
showAffiliateLinks = article.content.fields.showAffiliateLinks,
tags = article.content.tags.tags.map(_.id),
+ isUSProductionOffice = article.content.isUSProductionOffice,
),
) ++
ListIf(true)(VideoEmbedCleaner(article))
diff --git a/article/test/AnalyticsFeatureTest.scala b/article/test/AnalyticsFeatureTest.scala
deleted file mode 100644
index 2113977fac92..000000000000
--- a/article/test/AnalyticsFeatureTest.scala
+++ /dev/null
@@ -1,45 +0,0 @@
-package test
-
-import org.scalatest.{DoNotDiscover, GivenWhenThen}
-
-import scala.jdk.CollectionConverters._
-import conf.Configuration
-import io.fluentlenium.core.domain.FluentWebElement
-import org.scalatest.featurespec.AnyFeatureSpec
-import org.scalatest.matchers.should.Matchers
-
-@DoNotDiscover class AnalyticsFeatureTest
- extends AnyFeatureSpec
- with GivenWhenThen
- with Matchers
- with ConfiguredTestSuite {
- implicit val config: Configuration.type = Configuration
-
- Feature("Analytics") {
-
- conf.switches.Switches.EnableDiscussionSwitch.switchOff()
- // Feature
-
- info("In order understand how people are using the website and provide data for auditing")
- info("As a product manager")
- info("I want record usage metrics")
-
- // Scenarios
-
- Scenario("Ensure all clicked links are recorded by Analytics") {
- Given("I am on an article entitled 'Olympic opening ceremony will recreate countryside with real animals'")
- goTo("/sport/2012/jun/12/london-2012-olympic-opening-ceremony") { browser =>
- Then("all links on the page should be decorated with the Omniture meta-data attribute")
- val anchorsWithNoDataLink = browser.find("a").asScala.filter(hasNoLinkName)
- anchorsWithNoDataLink should have length 0
- }
-
- }
-
- Scenario("Ophan tracks user actions")(pending)
-
- }
-
- private def hasNoLinkName(e: FluentWebElement) = e.attribute("data-link-name") == null
-
-}
diff --git a/article/test/DCRFake.scala b/article/test/DCRFake.scala
index 7e1bb987a51d..babd20a08a20 100644
--- a/article/test/DCRFake.scala
+++ b/article/test/DCRFake.scala
@@ -1,5 +1,6 @@
package test
+import model.meta.BlocksOn
import com.gu.contentapi.client.model.v1.{Block, Blocks}
import model.Cached.RevalidatableResult
import model.dotcomrendering.{OnwardCollectionResponse, PageType}
@@ -20,14 +21,14 @@ class DCRFake(implicit context: ApplicationContext) extends renderers.DotcomRend
override def getArticle(
ws: WSClient,
- article: PageWithStoryPackage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[PageWithStoryPackage],
pageType: PageType,
newsletter: Option[NewsletterData],
filterKeyEvents: Boolean,
forceLive: Boolean,
)(implicit request: RequestHeader): Future[Result] = {
implicit val ec = ExecutionContext.global
+ val article = pageBlocks.page
requestedBlogs.enqueue(article)
Future(
Cached(article)(RevalidatableResult.Ok(Html("FakeRemoteRender has found you out if you rely on this markup!"))),
diff --git a/article/test/TestAppLoader.scala b/article/test/TestAppLoader.scala
index cd9323177283..9665bf8f032e 100644
--- a/article/test/TestAppLoader.scala
+++ b/article/test/TestAppLoader.scala
@@ -1,12 +1,8 @@
import app.FrontendComponents
-import services.S3Client
-import org.mockito.Mockito._
-import org.scalatestplus.mockito.MockitoSugar
import play.api.ApplicationLoader.Context
import play.api.BuiltInComponentsFromContext
import renderers.DotcomRenderingService
-import test.WithTestContentApiClient
-import test.DCRFake
+import test.{DCRFake, WithTestContentApiClient}
trait TestComponents extends WithTestContentApiClient {
self: AppComponents =>
diff --git a/article/test/package.scala b/article/test/package.scala
index e572dce53577..483ceb701528 100644
--- a/article/test/package.scala
+++ b/article/test/package.scala
@@ -8,7 +8,6 @@ object ArticleComponents extends Tag("article components")
class ArticleTestSuite
extends Suites(
new MainMediaWidthsTest,
- new AnalyticsFeatureTest,
new ArticleControllerTest,
new CdnHealthCheckTest,
new PublicationControllerTest,
diff --git a/build.sbt b/build.sbt
index 3654624966cb..a95619610432 100644
--- a/build.sbt
+++ b/build.sbt
@@ -18,15 +18,12 @@ val common = library("common")
Test / javaOptions += "-Dconfig.file=common/conf/test.conf",
libraryDependencies ++= Seq(
apacheCommonsLang,
- awsCore,
awsCloudwatch,
awsDynamodb,
- awsEc2,
awsKinesis,
awsS3,
awsSns,
- awsSts, // AWS SDK v1 still used for CAPI-preview related code for now
- awsV2Sts, // AWS SDK v2 used for Fronts API access
+ awsSts,
awsSqs,
awsSsm,
eTagCachingS3,
@@ -72,7 +69,6 @@ val common = library("common")
pekkoActorTyped,
janino,
) ++ jackson,
- TestAssets / mappings ~= filterAssets,
)
val commonWithTests = withTests(common)
@@ -111,7 +107,6 @@ val admin = application("admin")
.settings(
libraryDependencies ++= Seq(
paClient,
- dfpAxis,
bootstrap,
jquery,
jqueryui,
diff --git a/commercial/app/AppLoader.scala b/commercial/app/AppLoader.scala
index 84718599b128..051b413b0192 100644
--- a/commercial/app/AppLoader.scala
+++ b/commercial/app/AppLoader.scala
@@ -1,3 +1,4 @@
+import agents.AdmiralAgent
import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem}
import app.{FrontendApplicationLoader, FrontendBuildInfo, FrontendComponents}
import com.softwaremill.macwire._
@@ -10,6 +11,7 @@ import conf.CachedHealthCheckLifeCycle
import contentapi.{CapiHttpClient, ContentApiClient, HttpClient}
import dev.{DevAssetsController, DevParametersHttpRequestHandler}
import http.{CommonFilters, CorsHttpErrorHandler}
+import jobs.AdmiralLifecycle
import model.ApplicationIdentity
import play.api.ApplicationLoader.Context
import play.api.BuiltInComponentsFromContext
@@ -35,6 +37,7 @@ trait CommercialServices {
lazy val contentApiClient = wire[ContentApiClient]
lazy val capiAgent = wire[CapiAgent]
+ lazy val admiralAgent = wire[AdmiralAgent]
}
trait AppComponents extends FrontendComponents with CommercialControllers with CommercialServices {
@@ -47,6 +50,7 @@ trait AppComponents extends FrontendComponents with CommercialControllers with C
wire[SwitchboardLifecycle],
wire[CloudWatchMetricsLifecycle],
wire[CachedHealthCheckLifeCycle],
+ wire[AdmiralLifecycle],
)
lazy val router: Router = wire[Routes]
diff --git a/commercial/app/agents/AdmiralAgent.scala b/commercial/app/agents/AdmiralAgent.scala
new file mode 100644
index 000000000000..9a3691f33b35
--- /dev/null
+++ b/commercial/app/agents/AdmiralAgent.scala
@@ -0,0 +1,44 @@
+package agents
+
+import common.{Box, GuLogging}
+import conf.Configuration
+import play.api.libs.ws.WSClient
+
+import scala.concurrent.duration.DurationInt
+import scala.concurrent.{ExecutionContext, Future}
+
+class AdmiralAgent(wsClient: WSClient) extends GuLogging with implicits.WSRequests {
+
+ private val scriptCache = Box[Option[String]](None)
+
+ private val environment = Configuration.environment.stage
+ private val admiralUrl = Configuration.commercial.admiralUrl
+
+ private def fetchBootstrapScript(implicit ec: ExecutionContext): Future[String] = {
+ log.info(s"Fetching Admiral's bootstrap script via the Install Tag API")
+ admiralUrl match {
+ case Some(baseUrl) =>
+ wsClient
+ .url(s"$baseUrl?cacheable=1&environment=$environment")
+ .withRequestTimeout(2.seconds)
+ .getOKResponse()
+ .map(_.body)
+
+ case None =>
+ val errorMessage = "No configuration value found for commercial.admiralUrl"
+ log.error(errorMessage)
+ Future.failed(new Throwable(errorMessage))
+ }
+ }
+
+ def refresh()(implicit ec: ExecutionContext): Future[Unit] = {
+ log.info("Commercial Admiral Agent refresh")
+ fetchBootstrapScript.map { script =>
+ scriptCache.alter(Some(script))
+ }
+ }
+
+ def getBootstrapScript: Option[String] = {
+ scriptCache.get()
+ }
+}
diff --git a/commercial/app/controllers/AdmiralController.scala b/commercial/app/controllers/AdmiralController.scala
new file mode 100644
index 000000000000..b907aebf38dd
--- /dev/null
+++ b/commercial/app/controllers/AdmiralController.scala
@@ -0,0 +1,27 @@
+package commercial.controllers
+
+import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
+import agents.AdmiralAgent
+import common.{GuLogging, ImplicitControllerExecutionContext}
+import model.{Cached, NoCache}
+import model.Cached.RevalidatableResult
+
+import scala.concurrent.duration._
+
+class AdmiralController(admiralAgent: AdmiralAgent, val controllerComponents: ControllerComponents)
+ extends BaseController
+ with ImplicitControllerExecutionContext
+ with GuLogging
+ with implicits.Requests {
+
+ def getBootstrapScript: Action[AnyContent] =
+ Action { implicit request =>
+ admiralAgent.getBootstrapScript match {
+ case Some(script) =>
+ Cached(1.hour)(
+ RevalidatableResult(Ok(script).as("text/javascript; charset=utf-8"), script),
+ )
+ case None => NotFound
+ }
+ }
+}
diff --git a/commercial/app/controllers/CommercialControllers.scala b/commercial/app/controllers/CommercialControllers.scala
index 0cf2d17cefc6..aa7174832b52 100644
--- a/commercial/app/controllers/CommercialControllers.scala
+++ b/commercial/app/controllers/CommercialControllers.scala
@@ -1,5 +1,6 @@
package commercial.controllers
+import agents.AdmiralAgent
import com.softwaremill.macwire._
import commercial.model.capi.CapiAgent
import contentapi.ContentApiClient
@@ -10,6 +11,7 @@ trait CommercialControllers {
def contentApiClient: ContentApiClient
def capiAgent: CapiAgent
def controllerComponents: ControllerComponents
+ def admiralAgent: AdmiralAgent
implicit def appContext: ApplicationContext
lazy val contentApiOffersController = wire[ContentApiOffersController]
lazy val hostedContentController = wire[HostedContentController]
@@ -18,4 +20,6 @@ trait CommercialControllers {
lazy val passbackController = wire[PassbackController]
lazy val ampIframeHtmlController = wire[AmpIframeHtmlController]
lazy val nonRefreshableLineItemsController = wire[nonRefreshableLineItemsController]
+ lazy val TemporaryAdLiteController = wire[TemporaryAdLiteController]
+ lazy val admiralController = wire[AdmiralController]
}
diff --git a/commercial/app/controllers/ContentApiOffersController.scala b/commercial/app/controllers/ContentApiOffersController.scala
index 45b055330aee..704019955d87 100644
--- a/commercial/app/controllers/ContentApiOffersController.scala
+++ b/commercial/app/controllers/ContentApiOffersController.scala
@@ -59,7 +59,7 @@ class ContentApiOffersController(
private def renderNative(isMulti: Boolean) =
Action.async { implicit request =>
retrieveContent().map {
- case Nil => Cached(componentNilMaxAge) { jsonFormat.nilResult }
+ case Nil => Cached(componentNilMaxAge) { jsonFormat.nilResult }
case content if isMulti =>
Cached(1.hour) {
JsonComponent.fromWritable(CapiMultiple.fromContent(content, Edition(request)))
diff --git a/commercial/app/controllers/HostedContentController.scala b/commercial/app/controllers/HostedContentController.scala
index b57cc88db12b..ef042b1ab281 100644
--- a/commercial/app/controllers/HostedContentController.scala
+++ b/commercial/app/controllers/HostedContentController.scala
@@ -2,18 +2,20 @@ package commercial.controllers
import com.gu.contentapi.client.model.ContentApiError
import com.gu.contentapi.client.model.ItemQuery
-import com.gu.contentapi.client.model.v1.ContentType.Video
+import com.gu.contentapi.client.model.v1.ContentType.{Article, Gallery, Video}
+import com.gu.contentapi.client.model.v1.Content
import commercial.model.hosted.HostedTrails
import common.commercial.hosted._
-import common.{Edition, ImplicitControllerExecutionContext, JsonComponent, JsonNotFound, GuLogging}
+import common.{Edition, GuLogging, ImplicitControllerExecutionContext, JsonComponent, JsonNotFound}
import contentapi.ContentApiClient
import model.Cached.{RevalidatableResult, WithoutRevalidationResult}
-import model.{ApplicationContext, Cached, NoCache}
+import model.{ApplicationContext, Cached, DotcomRenderingHostedContentModel, NoCache}
import play.api.libs.json.{JsArray, Json}
import play.api.mvc._
import play.twirl.api.Html
import views.html.commercialExpired
import views.html.hosted._
+import implicits.JsonFormat
import scala.concurrent.Future
import scala.util.control.NonFatal
@@ -67,22 +69,53 @@ class HostedContentController(
.showTags("all")
.showAtoms("all")
+ private def lookup(
+ campaignName: String,
+ pageName: String,
+ )(implicit request: Request[AnyContent]): Future[Option[Content]] = {
+ val itemId = s"advertiser-content/$campaignName/$pageName"
+
+ contentApiClient
+ .getResponse(baseQuery(itemId))
+ .map(_.content)
+ .recover { case NonFatal(e) =>
+ log.warn(s"Capi lookup of item '$itemId' failed: ${e.getMessage}", e)
+ None
+ }
+ .map { content =>
+ if (content.isEmpty) {
+ log.warn(s"Hosted content model not found for item '$itemId'")
+ }
+ content
+ }
+ }
+
def renderHostedPage(campaignName: String, pageName: String): Action[AnyContent] =
Action.async { implicit request =>
- val capiResponse = {
- val itemId = s"advertiser-content/$campaignName/$pageName"
- val response = contentApiClient.getResponse(baseQuery(itemId))
- response.failed.foreach { case NonFatal(e) =>
- log.warn(s"Capi lookup of item '$itemId' failed: ${e.getMessage}", e)
- }
- response
+ lookup(campaignName, pageName).flatMap {
+ case Some(content) if request.getRequestFormat == JsonFormat =>
+ renderJsonResponse(content)
+ case Some(content) =>
+ renderPage(Future.successful(HostedPage.fromContent(content)))
+ case None =>
+ Future.successful(NotFound)
}
+ }
- val page = capiResponse map {
- _.content flatMap HostedPage.fromContent
+ def renderJson(campaignName: String, pageName: String): Action[AnyContent] =
+ Action.async { implicit request =>
+ lookup(campaignName, pageName).flatMap {
+ case Some(content) => renderJsonResponse(content)
+ case None => Future.successful(NotFound)
}
+ }
- renderPage(page)
+ private def renderJsonResponse(content: Content): Future[Result] =
+ DotcomRenderingHostedContentModel.get(content) match {
+ case Some(model) =>
+ Future.successful(Ok(DotcomRenderingHostedContentModel.toJson(model)).as("application/json"))
+ case None =>
+ Future.successful(NotFound)
}
def renderOnwardComponent(campaignName: String, pageName: String, contentType: String): Action[AnyContent] =
diff --git a/commercial/app/controllers/TemporaryAdLiteController.scala b/commercial/app/controllers/TemporaryAdLiteController.scala
new file mode 100644
index 000000000000..8bb6f6abb762
--- /dev/null
+++ b/commercial/app/controllers/TemporaryAdLiteController.scala
@@ -0,0 +1,34 @@
+package commercial.controllers
+
+import play.api.mvc._
+
+import scala.concurrent.duration._
+import model.Cached
+import model.Cached.WithoutRevalidationResult
+
+/*
+ * Temporarily enable ad-lite for a user by setting a short lived cookie, used for demoing ad-lite to advertisers
+ */
+
+class TemporaryAdLiteController(val controllerComponents: ControllerComponents) extends BaseController {
+
+ private val lifetime: Int = 1.hours.toSeconds.toInt
+
+ def enable(): Action[AnyContent] = Action { implicit request =>
+ Cached(60)(
+ WithoutRevalidationResult(
+ SeeOther("/").withCookies(
+ Cookie("gu_allow_reject_all", lifetime.toString(), maxAge = Some(lifetime), httpOnly = false),
+ ),
+ ),
+ )
+ }
+
+ def disable(): Action[AnyContent] = Action { implicit request =>
+ Cached(60)(
+ WithoutRevalidationResult(
+ SeeOther("/").discardingCookies(DiscardingCookie("gu_allow_reject_all")),
+ ),
+ )
+ }
+}
diff --git a/commercial/app/jobs/AdmiralLifecycle.scala b/commercial/app/jobs/AdmiralLifecycle.scala
new file mode 100644
index 000000000000..a40a7b93e9c8
--- /dev/null
+++ b/commercial/app/jobs/AdmiralLifecycle.scala
@@ -0,0 +1,38 @@
+package jobs
+
+import agents.AdmiralAgent
+import app.LifecycleComponent
+import common.{JobScheduler, PekkoAsync}
+import play.api.inject.ApplicationLifecycle
+
+import scala.concurrent.duration._
+import scala.concurrent.{ExecutionContext, Future}
+
+class AdmiralLifecycle(
+ appLifecycle: ApplicationLifecycle,
+ jobs: JobScheduler,
+ pekkoAsync: PekkoAsync,
+ admiralAgent: AdmiralAgent,
+)(implicit ec: ExecutionContext)
+ extends LifecycleComponent {
+
+ appLifecycle.addStopHook { () =>
+ Future {
+ jobs.deschedule("AdmiralAgentRefreshJob")
+ }
+ }
+
+ override def start(): Unit = {
+ jobs.deschedule("AdmiralAgentRefreshJob")
+
+ // Why 6 hours?
+ // The Admiral script returned from the "Install Tag" API is unlikely to change frequently
+ jobs.scheduleEvery("AdmiralAgentRefreshJob", 6.hours) {
+ admiralAgent.refresh()
+ }
+
+ pekkoAsync.after1s {
+ admiralAgent.refresh()
+ }
+ }
+}
diff --git a/commercial/app/model/DotcomRenderingHostedContentModel.scala b/commercial/app/model/DotcomRenderingHostedContentModel.scala
new file mode 100644
index 000000000000..24f5315f8b96
--- /dev/null
+++ b/commercial/app/model/DotcomRenderingHostedContentModel.scala
@@ -0,0 +1,141 @@
+package model
+
+import com.gu.commercial.branding.Dimensions
+import com.gu.contentapi.client.model.v1.{Content => ApiContent}
+import common.commercial.hosted._
+import model.dotcomrendering.DotcomRenderingUtils._
+import play.api.libs.json._
+import play.api.mvc.RequestHeader
+import model.{Content, MetaData}
+import net.liftweb.json.Meta
+
+// -----------------------------------------------------------------
+// DCR DataModel
+// -----------------------------------------------------------------
+
+case class DotcomRenderingHostedContentModel(
+ // general / shared
+ id: String,
+ url: String,
+ encodedUrl: String,
+ campaign: Option[HostedCampaign],
+ title: String,
+ mainImageUrl: String,
+ thumbnailUrl: String,
+ standfirst: String,
+ cta: HostedCallToAction,
+ name: String,
+ owner: String,
+ logo: HostedLogo,
+ fontColour: Colour,
+
+ // article
+ body: Option[String],
+ mainPicture: Option[String],
+ mainPictureCaption: Option[String],
+
+ // video
+ video: Option[HostedVideo],
+
+ // gallery
+ images: List[HostedGalleryImage],
+)
+
+object DotcomRenderingHostedContentModel {
+
+ // Implicit Json writes
+ implicit val colourWrites: Writes[Colour] = Json.writes[Colour]
+ implicit val dimensionsWrites: Writes[Dimensions] = Json.writes[Dimensions]
+ implicit val logoWrites: Writes[HostedLogo] = Json.writes[HostedLogo]
+ implicit val campaignWrites: Writes[HostedCampaign] = Json.writes[HostedCampaign]
+ implicit val ctaWrites: Writes[HostedCallToAction] = Json.writes[HostedCallToAction]
+ implicit val encodingWrites: Writes[Encoding] = Json.writes[Encoding]
+ implicit val videoWrites: Writes[HostedVideo] = Json.writes[HostedVideo]
+ implicit val imagesWrites: Writes[HostedGalleryImage] = Json.writes[HostedGalleryImage]
+
+ implicit val dcrContentWrites: Writes[DotcomRenderingHostedContentModel] =
+ Json.writes[DotcomRenderingHostedContentModel]
+
+ def toJson(model: DotcomRenderingHostedContentModel): JsValue = {
+ val jsValue = Json.toJson(model)
+ withoutNull(jsValue)
+ }
+
+ def get(content: ApiContent): Option[DotcomRenderingHostedContentModel] = {
+ HostedPage.fromContent(content).flatMap {
+ case articlePage: HostedArticlePage => Some(forArticle(articlePage))
+ case videoPage: HostedVideoPage => Some(forVideo(videoPage))
+ case galleryPage: HostedGalleryPage => Some(forGallery(galleryPage))
+ case _ => None
+ }
+ }
+
+ def forArticle(page: HostedArticlePage): DotcomRenderingHostedContentModel = {
+ apply(
+ page = page,
+ body = Some(page.body),
+ mainPicture = Some(page.mainPicture),
+ mainPictureCaption = Some(page.mainPictureCaption),
+ video = None,
+ images = List.empty,
+ )
+ }
+
+ def forVideo(page: HostedVideoPage): DotcomRenderingHostedContentModel = {
+ apply(
+ page = page,
+ body = None,
+ mainPicture = None,
+ mainPictureCaption = None,
+ video = Some(page.video),
+ images = List.empty,
+ )
+ }
+
+ def forGallery(page: HostedGalleryPage): DotcomRenderingHostedContentModel = {
+ apply(
+ page = page,
+ body = None,
+ mainPicture = None,
+ mainPictureCaption = None,
+ video = None,
+ images = page.images,
+ )
+ }
+
+ def apply(
+ page: HostedPage,
+ body: Option[String] = None,
+ mainPicture: Option[String] = None,
+ mainPictureCaption: Option[String] = None,
+ video: Option[HostedVideo] = None,
+ images: List[HostedGalleryImage] = List.empty,
+ ): DotcomRenderingHostedContentModel = {
+ DotcomRenderingHostedContentModel(
+ id = page.id,
+ url = page.url,
+ encodedUrl = page.encodedUrl,
+ campaign = page.campaign,
+ title = page.title,
+ mainImageUrl = page.mainImageUrl,
+ thumbnailUrl = page.thumbnailUrl,
+ standfirst = page.standfirst,
+ cta = page.cta,
+ name = page.name,
+ owner = page.owner,
+ logo = page.logo,
+ fontColour = page.fontColour,
+
+ // article
+ body = body,
+ mainPicture = mainPicture,
+ mainPictureCaption = mainPictureCaption,
+
+ // video
+ video = video,
+
+ // gallery
+ images = images,
+ )
+ }
+}
diff --git a/commercial/app/model/capi/CapiImages.scala b/commercial/app/model/capi/CapiImages.scala
index f046fe8f177b..061f95068e03 100644
--- a/commercial/app/model/capi/CapiImages.scala
+++ b/commercial/app/model/capi/CapiImages.scala
@@ -19,6 +19,7 @@ object CapiImages {
// Puts together image source info using data from cAPI.
def buildImageData(imageData: Option[ImageMedia], noImages: Int = 1): ImageInfo = {
+ val altText = imageData flatMap (_.masterImage.flatMap(_.altText))
val fallbackImageUrl = imageData flatMap ImgSrc.getFallbackUrl
val imageType = noImages match {
case 3 => Third
@@ -47,7 +48,7 @@ object CapiImages {
)
}
- ImageInfo(sources, fallbackImageUrl)
+ ImageInfo(sources, fallbackImageUrl, altText)
}
}
@@ -64,8 +65,8 @@ object ImageSource {
implicit val writesImageSource: Writes[ImageSource] = Json.writes[ImageSource]
}
-// Holds all source element data, and the backup image src for older browsers.
-case class ImageInfo(sources: Seq[ImageSource], backupSrc: Option[String])
+// Holds all source element data, the backup image src for older browsers, and the alt text.
+case class ImageInfo(sources: Seq[ImageSource], backupSrc: Option[String], altText: Option[String])
object ImageInfo {
implicit val writesImageInfo: Writes[ImageInfo] = Json.writes[ImageInfo]
diff --git a/commercial/app/services/dotcomrendering/HostedContentPicker.scala b/commercial/app/services/dotcomrendering/HostedContentPicker.scala
new file mode 100644
index 000000000000..8b1bb50a9c62
--- /dev/null
+++ b/commercial/app/services/dotcomrendering/HostedContentPicker.scala
@@ -0,0 +1,88 @@
+package services.dotcomrendering
+
+import com.madgag.scala.collection.decorators.MapDecorator
+import common.commercial.hosted.{HostedArticlePage, HostedGalleryPage, HostedVideoPage}
+import implicits.Requests._
+import model.PageWithStoryPackage
+import play.api.mvc.RequestHeader
+import utils.DotcomponentsLogger
+import implicits.AppsFormat
+import conf.switches.Switches
+
+object HostedContentPageChecks {
+
+ def isSupportedType(page: PageWithStoryPackage): Boolean = {
+ page match {
+ case a: HostedArticlePage => false
+ case v: HostedVideoPage => false
+ case g: HostedGalleryPage => false
+ case _ => false
+ }
+ }
+}
+
+object HostedContentPicker {
+
+ def dcrChecks(page: PageWithStoryPackage): Map[String, Boolean] = {
+ Map(
+ ("isSupportedType", HostedContentPageChecks.isSupportedType(page)),
+ )
+ }
+
+ private[this] def dcr100PercentPage(page: PageWithStoryPackage): Boolean = {
+ val allowListFeatures = dcrChecks(page)
+ val hostedPage100PercentFeatures = allowListFeatures.view.filterKeys(
+ Set(
+ "isSupportedType",
+ ),
+ )
+
+ hostedPage100PercentFeatures.forall({ case (_, isMet) => isMet })
+ }
+
+ def getTier(page: PageWithStoryPackage)(implicit
+ request: RequestHeader,
+ ): RenderType = {
+ val checks = dcrChecks(page)
+ val dcrCanRender = checks.values.forall(identity)
+
+ val tier: RenderType = decideTier(dcrCanRender)
+
+ // include features that we wish to log but not allow-list against
+ val features = checks.mapV(_.toString) +
+ ("dcrCouldRender" -> dcrCanRender.toString)
+
+ if (tier == RemoteRender) {
+ if (request.getRequestFormat == AppsFormat)
+ DotcomponentsLogger.logger.logRequest(
+ s"[HostedContentRendering] path executing in dotcom rendering for apps (DCAR)",
+ features,
+ page.article,
+ )
+ else
+ DotcomponentsLogger.logger.logRequest(
+ s"[HostedContentRendering] path executing in dotcomponents",
+ features,
+ page.article,
+ )
+ } else {
+ DotcomponentsLogger.logger.logRequest(
+ s"[HostedContentRendering] path executing in web (frontend)",
+ features,
+ page.article,
+ )
+ }
+
+ tier
+ }
+
+ def decideTier(dcrCanRender: Boolean)(implicit
+ request: RequestHeader,
+ ): RenderType = {
+ if (Switches.DCRHostedContent.isSwitchedOff) LocalRender
+ else if (request.forceDCROff) LocalRender
+ else if (request.forceDCR) LocalRender // Prevent RemoteRender (DCR) for now
+ else if (dcrCanRender) LocalRender // Prevent RemoteRender (DCR) for now
+ else LocalRender
+ }
+}
diff --git a/commercial/app/services/dotcomrendering/RenderType.scala b/commercial/app/services/dotcomrendering/RenderType.scala
new file mode 100644
index 000000000000..367fc080f459
--- /dev/null
+++ b/commercial/app/services/dotcomrendering/RenderType.scala
@@ -0,0 +1,5 @@
+package services.dotcomrendering
+
+sealed trait RenderType
+case object RemoteRender extends RenderType
+case object LocalRender extends RenderType
diff --git a/commercial/app/views/debugger/allcreatives.scala.html b/commercial/app/views/debugger/allcreatives.scala.html
index f6967df6e0cb..6e1dc5836aba 100644
--- a/commercial/app/views/debugger/allcreatives.scala.html
+++ b/commercial/app/views/debugger/allcreatives.scala.html
@@ -179,80 +179,10 @@ Inline
-
+
-
+
@@ -292,11 +222,7 @@
});
-
+
}
-
- @Seq(
- Map(
- ("id", "manual1"),
- ("type", "single"),
- ("creativeId", "10025607"),
- ("args", Json.obj(
- ("creative", "manual-single"),
- ("toneClass", "commercial--tone-brand"),
- ("omnitureId", "[%omnitureid%]"),
- ("baseUrl", "http://www.theguardian.com/technology/2014/nov/20/apple-beats-music-iphone-ipad-spotify"),
- ("title", "title"),
- ("viewAllText", "View all"),
- ("offerTitle", "Scientists climb to bottom of Siberian sinkhole - in pictures"),
- ("offerImage", "http://pagead2.googlesyndication.com/pagead/imgad?id=CICAgKDjk-jQkgEQARgBMghE750kQXQwJg"),
- ("offerText", "A Russian research team including scientists, a medic and a professional climber has descended a giant sinkhole on the Yamal Peninsula in northern Siberia. Photographs by Vladimir Pushkarev/Siberian Times"),
- ("offerUrl", "http://www.theguardian.com/technology/2014/nov/20/apple-beats-music-iphone-ipad-spotify"),
- ("seeMoreUrl", "http://www.theguardian.com/technology/2014/nov/20/apple-beats-music-iphone-ipad-spotify"),
- ("showCtaLink", "show-cta-link"),
- ("offerLinkText", "See more"),
- ("clickMacro", "%%CLICK_URL_ESC%%")
- ))
- ),
- Map(
- ("id", "multiple1"),
- ("type", "multiple"),
- ("creativeId", "10025847"),
- ("args", Json.obj(
- ("creative", "manual-multiple"),
- ("title", "A Title"),
- ("explainer", "Explainer text"),
- ("base__url", "http://www.theguardian.com/uk"),
- ("offerlinktext", "Offer link text"),
- ("viewalltext", "View all text"),
- ("offeramount", "offer-amount"),
- ("relevance", "high"),
- ("Toneclass", "commercial--tone-brand"),
- ("prominent", "true"),
- ("offer1title", "Offer 1 Title"),
- ("offer1linktext", "Offer 1 Link Text"),
- ("offer1url", "http://www.theguardian.com/uk"),
- ("offer1meta", "Offer 1 Meta"),
- ("offer1image", "http://www.catgifpage.com/gifs/247.gif"),
- ("offer2title", "Offer 2 Title"),
- ("offer2linktext", "Offer 2 Link Text"),
- ("offer2url", "http://www.theguardian.com/uk"),
- ("offer2meta", "Offer 1 Meta"),
- ("offer2image", "http://www.catgifpage.com/gifs/247.gif"),
- ("offer3title", "Offer 3 Title"),
- ("offer3linktext", "Offer 3 Link Text"),
- ("offer3url", "http://www.theguardian.com/uk"),
- ("offer3meta", "Offer 1 Meta"),
- ("offer3image", "http://www.catgifpage.com/gifs/247.gif"),
- ("offer4title", "Offer 4 Title"),
- ("offer4linktext", "Offer 4 Link Text"),
- ("offer4url", "http://www.theguardian.com/uk"),
- ("offer4meta", "Offer 1 Meta"),
- ("offer4image", "http://www.catgifpage.com/gifs/247.gif"),
- ("omnitureId", "[%OmnitureID%]"),
- ("clickMacro", "%%CLICK_URL_ESC%%")
- ))
- ),
- Map(
- ("id", "multipleMembership"),
- ("type", "multiple"),
- ("creativeId", "10025847"),
- ("args", Json.obj(
- ("creative", "manual-multiple"),
- ("base__url", "https://memebrship.theguardian.com"),
- ("viewalltext", "Become a Supporter"),
- ("title", "Events for foodies from Guardian Live"),
- ("offeramount", "offer-amount"),
- ("relevance", "high"),
- ("Toneclass", "commercial--tone-membership"),
- ("offer1title", "Offer 1 Title"),
- ("offer1linktext", "Offer 1 Link Text"),
- ("offer1url", "http://www.theguardian.com/uk"),
- ("offer1meta", "Offer 1 Meta"),
- ("offer1image", "http://www.catgifpage.com/gifs/247.gif"),
- ("offer2title", "Offer 2 Title"),
- ("offer2linktext", "Offer 2 Link Text"),
- ("offer2url", "http://www.theguardian.com/uk"),
- ("offer2meta", "Offer 1 Meta"),
- ("offer2image", "http://www.catgifpage.com/gifs/247.gif"),
- ("offer3title", "Offer 3 Title"),
- ("offer3linktext", "Offer 3 Link Text"),
- ("offer3url", "http://www.theguardian.com/uk"),
- ("offer3meta", "Offer 1 Meta"),
- ("offer3image", "http://www.catgifpage.com/gifs/247.gif"),
- ("offer4title", "Offer 4 Title"),
- ("offer4linktext", "Offer 4 Link Text"),
- ("offer4url", "http://www.theguardian.com/uk"),
- ("offer4meta", "Offer 1 Meta"),
- ("offer4image", "http://www.catgifpage.com/gifs/247.gif"),
- ("omnitureId", "[%OmnitureID%]"),
- ("clickMacro", "%%CLICK_URL_ESC%%")
- ))
- )
- ).map { component =>
-
-
-
- }
-
-
-}
+}
\ No newline at end of file
diff --git a/commercial/conf/routes b/commercial/conf/routes
index 62cdc6f1cb4b..e10ab896a7dd 100644
--- a/commercial/conf/routes
+++ b/commercial/conf/routes
@@ -6,6 +6,9 @@
GET /assets/*path dev.DevAssetsController.at(path)
GET /_healthcheck commercial.controllers.HealthCheck.healthCheck()
+# Admiral Ad Block Recovery solution - fetching the bootstrap script
+GET /commercial/admiral-bootstrap.js commercial.controllers.AdmiralController.getBootstrapScript
+
# Content API merchandising components
# Attempting to remove ContentApiOffersController, discovered
# https://github.com/guardian/commercial-templates/blob/dba808f89127d4405f4f4f087208e6135400e61c/src/capi-single-paidfor/web/index.js#L30
@@ -17,6 +20,7 @@ GET /commercial/api/capi-multiple.json comm
GET /commercial/anx/anxresize.js commercial.controllers.PiggybackPixelController.resize()
# Hosted content
+GET /advertiser-content/:campaignName/:pageName.json commercial.controllers.HostedContentController.renderJson(campaignName, pageName)
GET /advertiser-content/:campaignName/:pageName commercial.controllers.HostedContentController.renderHostedPage(campaignName, pageName)
GET /advertiser-content/:campaignName/:pageName/:cType/onward.json commercial.controllers.HostedContentController.renderOnwardComponent(campaignName, pageName, cType)
GET /advertiser-content/:campaignName/:pageName/autoplay.json commercial.controllers.HostedContentController.renderAutoplayComponent(campaignName, pageName)
@@ -33,3 +37,7 @@ GET /commercial/amp-iframe.html comm
# DFP Non refreshable line items
GET /commercial/non-refreshable-line-items.json commercial.controllers.nonRefreshableLineItemsController.getIds
+
+# Ad-Lite opt in
+GET /commercial/ad-lite/enable commercial.controllers.TemporaryAdLiteController.enable()
+GET /commercial/ad-lite/disable commercial.controllers.TemporaryAdLiteController.disable()
diff --git a/commercial/test/services/dotcomrendering/HostedContentPickerTest.scala b/commercial/test/services/dotcomrendering/HostedContentPickerTest.scala
new file mode 100644
index 000000000000..ece680c04de2
--- /dev/null
+++ b/commercial/test/services/dotcomrendering/HostedContentPickerTest.scala
@@ -0,0 +1,41 @@
+package services.dotcomrendering
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.DoNotDiscover
+import services.dotcomrendering.{LocalRender, RemoteRender}
+import test.TestRequest
+
+@DoNotDiscover class HostedContentPickerTest extends AnyFlatSpec with Matchers {
+ // All tests should return LocalRender initially
+
+ "Hosted Content Picker decideTier" should "return LocalRender if forceDCROff and dcr cannot render" in {
+ val testRequest = TestRequest("hosted-content-path?dcr=false")
+ val tier = HostedContentPicker.decideTier(false)(testRequest)
+ tier should be(LocalRender)
+ }
+
+ it should "return LocalRender if forceDCROff and dcrCanRender" in {
+ val testRequest = TestRequest("hosted-content-path?dcr=false")
+ val tier = HostedContentPicker.decideTier(false)(testRequest)
+ tier should be(LocalRender)
+ }
+
+ it should "return LocalRender if force DCR" in {
+ val testRequest = TestRequest("hosted-content-path?dcr=true")
+ val tier = HostedContentPicker.decideTier(false)(testRequest)
+ tier should be(LocalRender)
+ }
+
+ it should "return LocalRender if force DCR and content should be served pressed" in {
+ val testRequest = TestRequest("hosted-content-path?dcr=true")
+ val tier = HostedContentPicker.decideTier(true)(testRequest)
+ tier should be(LocalRender)
+ }
+
+ it should "return LocalRender otherwise" in {
+ val testRequest = TestRequest("hosted-content-path")
+ val tier = HostedContentPicker.decideTier(false)(testRequest)
+ tier should be(LocalRender)
+ }
+}
diff --git a/common/app/ab/ABTests.scala b/common/app/ab/ABTests.scala
new file mode 100644
index 000000000000..f877e3fd31f1
--- /dev/null
+++ b/common/app/ab/ABTests.scala
@@ -0,0 +1,79 @@
+package ab
+
+import play.api.mvc.RequestHeader
+import play.api.libs.typedmap.TypedKey
+import java.util.concurrent.ConcurrentHashMap
+import scala.jdk.CollectionConverters._
+
+object ABTests {
+
+ type ABTest = (String, String)
+ type ABTestsHashMap = ConcurrentHashMap[ABTest, Unit]
+
+ private val attrKey: TypedKey[ConcurrentHashMap[ABTest, Unit]] =
+ TypedKey[ABTestsHashMap]("serverABTests")
+
+ /** Decorates the request with the AB tests defined in the request header. The header should be in the format:
+ * "testName1:variant1,testName2:variant2,..."
+ */
+ def decorateRequest(implicit request: RequestHeader, abTestHeader: String): RequestHeader = {
+ val tests = request.headers.get(abTestHeader).fold(Map.empty[String, String]) { tests =>
+ tests
+ .split(",")
+ .collect {
+ case test if test.split(":").length == 2 =>
+ val parts = test.split(":")
+ parts(0).trim -> parts(1).trim
+ }
+ .toMap
+ }
+ request.addAttr(
+ attrKey,
+ tests.foldLeft(new ABTestsHashMap) { case (map, (name, variant)) => map.put((name, variant), ()); map },
+ )
+ }
+
+ /** Checks if the request is participating in a specific AB test.
+ * @param testName
+ * The name of the AB test to check.
+ * @return
+ * true if the request is participating in the test, false otherwise.
+ */
+ def isParticipating(implicit request: RequestHeader, testName: String): Boolean = {
+ request.attrs.get(attrKey).exists(_.asScala.keys.exists { case (name, _) => name == testName })
+ }
+
+ /** Checks if the request is in a specific variant of an AB test.
+ * @param testName
+ * The name of the AB test to check.
+ * @param variant
+ * The variant to check.
+ * @return
+ * true if the request is in the specified variant, false otherwise.
+ */
+ def isInVariant(implicit request: RequestHeader, testName: String, variant: String): Boolean = {
+ request.attrs.get(attrKey).exists(_.containsKey((testName, variant)))
+ }
+
+ /** Retrieves all AB tests and their variants for the current request.
+ * @return
+ * A map of test names to their variants.
+ */
+ def allTests(implicit request: RequestHeader): Map[String, String] = {
+ request.attrs
+ .get(attrKey)
+ .map(_.asScala.keys.map { case (testName, variant) => testName -> variant }.toMap)
+ .getOrElse(Map.empty)
+ }
+
+ /** Generates a JavaScript object string representation of all AB tests and their variants. This is set on the window
+ * object for use in client-side JavaScript.
+ * @return
+ * A string in the format: {"testName1":"variant1","testName2":"variant2",...}
+ */
+ def getJavascriptConfig(implicit request: RequestHeader): String = {
+ allTests.toList
+ .map({ case (key, value) => s""""${key}":"${value}"""" })
+ .mkString(",")
+ }
+}
diff --git a/common/app/agents/DeeplyReadAgent.scala b/common/app/agents/DeeplyReadAgent.scala
index 3a3036f04082..ae1e41566639 100644
--- a/common/app/agents/DeeplyReadAgent.scala
+++ b/common/app/agents/DeeplyReadAgent.scala
@@ -1,6 +1,6 @@
package agents
-import com.gu.contentapi.client.model.v1.Content
+import com.gu.contentapi.client.model.v1.{Content, ElementType}
import com.gu.contentapi.client.utils.CapiModelEnrichment.RenderingFormat
import common._
import contentapi.ContentApiClient
@@ -123,6 +123,9 @@ class DeeplyReadAgent(contentApiClient: ContentApiClient, ophanApi: OphanApi) ex
avatarUrl = None,
branding = None,
discussion = DiscussionSettings.fromTrail(FaciaContentConvert.contentToFaciaContent(content)),
+ trailText = content.fields.flatMap(_.trailText),
+ galleryCount =
+ content.elements.map(_.count(el => el.`type` == ElementType.Image && el.relation == "gallery")).filter(_ > 0),
)
}
diff --git a/common/app/awswrappers/cloudwatch.scala b/common/app/awswrappers/cloudwatch.scala
deleted file mode 100644
index 95e84de86f89..000000000000
--- a/common/app/awswrappers/cloudwatch.scala
+++ /dev/null
@@ -1,25 +0,0 @@
-package awswrappers
-
-import com.amazonaws.services.cloudwatch.AmazonCloudWatchAsync
-import com.amazonaws.services.cloudwatch.model._
-
-import scala.concurrent.Future
-
-/** NB: We ought to switch to this library once we update to Scala 2.11.2:
- *
- * https://github.com/guardian/aws-sdk-scala-wrappers
- *
- * Then we can delete these manually written wrappers.
- */
-object cloudwatch {
- implicit class RichAsyncCloudWatchClient(client: AmazonCloudWatchAsync) {
- def putMetricDataFuture(request: PutMetricDataRequest): Future[PutMetricDataResult] =
- asFuture[PutMetricDataRequest, PutMetricDataResult](client.putMetricDataAsync(request, _))
-
- def getMetricStatisticsFuture(request: GetMetricStatisticsRequest): Future[GetMetricStatisticsResult] =
- asFuture[GetMetricStatisticsRequest, GetMetricStatisticsResult](client.getMetricStatisticsAsync(request, _))
-
- def listMetricsFuture(request: ListMetricsRequest): Future[ListMetricsResult] =
- asFuture[ListMetricsRequest, ListMetricsResult](client.listMetricsAsync(request, _))
- }
-}
diff --git a/common/app/awswrappers/kinesisfirehose.scala b/common/app/awswrappers/kinesisfirehose.scala
deleted file mode 100644
index 15da1553a66f..000000000000
--- a/common/app/awswrappers/kinesisfirehose.scala
+++ /dev/null
@@ -1,13 +0,0 @@
-package awswrappers
-
-import com.amazonaws.services.kinesisfirehose.AmazonKinesisFirehoseAsync
-import com.amazonaws.services.kinesisfirehose.model.{PutRecordRequest, PutRecordResult}
-
-import scala.concurrent.Future
-
-object kinesisfirehose {
- implicit class RichKinesisFirehoseAsyncClient(client: AmazonKinesisFirehoseAsync) {
- def putRecordFuture(request: PutRecordRequest): Future[PutRecordResult] =
- asFuture[PutRecordRequest, PutRecordResult](client.putRecordAsync(request, _))
- }
-}
diff --git a/common/app/awswrappers/package.scala b/common/app/awswrappers/package.scala
deleted file mode 100644
index 2fe99479c0b0..000000000000
--- a/common/app/awswrappers/package.scala
+++ /dev/null
@@ -1,29 +0,0 @@
-import com.amazonaws.handlers.AsyncHandler
-
-import scala.concurrent.{Future, Promise}
-import scala.util.{Success, Failure}
-import java.util.concurrent.{Future => JavaFuture}
-
-package object awswrappers {
- private[awswrappers] def createHandler[A <: com.amazonaws.AmazonWebServiceRequest, B]() = {
- val promise = Promise[B]()
-
- val handler = new AsyncHandler[A, B] {
- override def onSuccess(request: A, result: B): Unit = promise.complete(Success(result))
-
- override def onError(exception: Exception): Unit = promise.complete(Failure(exception))
- }
-
- (promise.future, handler)
- }
-
- private[awswrappers] def asFuture[A <: com.amazonaws.AmazonWebServiceRequest, B](
- block: AsyncHandler[A, B] => JavaFuture[B],
- ): Future[B] = {
- val (future, handler) = createHandler[A, B]()
-
- block(handler)
-
- future
- }
-}
diff --git a/common/app/awswrappers/sns.scala b/common/app/awswrappers/sns.scala
deleted file mode 100644
index 3d7b73fca6f6..000000000000
--- a/common/app/awswrappers/sns.scala
+++ /dev/null
@@ -1,13 +0,0 @@
-package awswrappers
-
-import com.amazonaws.services.sns.AmazonSNSAsync
-import com.amazonaws.services.sns.model.{PublishRequest, PublishResult}
-
-import scala.concurrent.Future
-
-object sns {
- implicit class RichSnsAsyncClient(client: AmazonSNSAsync) {
- def publishFuture(publishRequest: PublishRequest): Future[PublishResult] =
- asFuture[PublishRequest, PublishResult](client.publishAsync(publishRequest, _))
- }
-}
diff --git a/common/app/bindables/LocalDateBindable.scala b/common/app/bindables/LocalDateBindable.scala
index 478d74c0458b..c3bb87f419b1 100644
--- a/common/app/bindables/LocalDateBindable.scala
+++ b/common/app/bindables/LocalDateBindable.scala
@@ -14,7 +14,7 @@ class LocalDateBindable extends PathBindable[LocalDate] with GuLogging {
Try {
Option(LocalDate.parse(value, DateTimeFormat.forPattern(Format))).get
} match {
- case Success(date) => Right(date)
+ case Success(date) => Right(date)
case Failure(error) =>
log.error(s"Could not bind $key to $value", error)
Left(error.getMessage)
diff --git a/common/app/commercial/targeting/CampaignAgent.scala b/common/app/commercial/targeting/CampaignAgent.scala
index 2e965f396089..a4755ff54179 100644
--- a/common/app/commercial/targeting/CampaignAgent.scala
+++ b/common/app/commercial/targeting/CampaignAgent.scala
@@ -13,7 +13,7 @@ object CampaignAgent extends GuLogging {
def refresh()(implicit executionContext: ExecutionContext): Future[Unit] = {
// The maximum number of campaigns which will be fetched. If there are too many campaigns additional campaigns will be truncated.
// Which campaigns make it through is undefined
- val campaignLimit = 200
+ val campaignLimit = 300
// Total number of rules allowed per campaign, any campaigns with more than one rule will be filtered
val ruleLimit = 1
diff --git a/common/app/common/InlineStyles.scala b/common/app/common/InlineStyles.scala
index e7ad874c14a3..b05238959357 100644
--- a/common/app/common/InlineStyles.scala
+++ b/common/app/common/InlineStyles.scala
@@ -108,7 +108,7 @@ object InlineStyles extends GuLogging {
Retry(3)(cssParser.parseStyleSheet(source, null, null)) { (exception, attemptNumber) =>
log.error(s"Attempt $attemptNumber to parse stylesheet failed", exception)
} match {
- case Failure(_) => (inline, head :+ element.html)
+ case Failure(_) => (inline, head :+ element.html)
case Success(sheet) =>
val (styles, others) = seq(sheet.getCssRules).partition(isStyleRule)
val (inlineStyles, headStyles) = styles.flatMap(CSSRule.fromW3).flatten.partition(_.canInline)
diff --git a/common/app/common/JsonComponent.scala b/common/app/common/JsonComponent.scala
index c3b5d81f5e6c..33abad55cfb5 100644
--- a/common/app/common/JsonComponent.scala
+++ b/common/app/common/JsonComponent.scala
@@ -59,12 +59,12 @@ object JsonComponent extends Results with implicits.Requests {
toJson(
(items.toMap + ("refreshStatus" -> AutoRefreshSwitch.isSwitchedOn)).map {
// compress and take the body if value is Html
- case (name, html: Html) => name -> toJson(html.body)
- case (name, value: String) => name -> toJson(value)
- case (name, value: Boolean) => name -> toJson(value)
- case (name, value: Int) => name -> toJson(value)
- case (name, value: Double) => name -> toJson(value)
- case (name, value: Float) => name -> toJson(value)
+ case (name, html: Html) => name -> toJson(html.body)
+ case (name, value: String) => name -> toJson(value)
+ case (name, value: Boolean) => name -> toJson(value)
+ case (name, value: Int) => name -> toJson(value)
+ case (name, value: Double) => name -> toJson(value)
+ case (name, value: Float) => name -> toJson(value)
case (name, value: Seq[_]) if value.forall(_.isInstanceOf[String]) =>
name -> toJson(value.asInstanceOf[Seq[String]])
case (name, value: JsValue) => name -> value
diff --git a/common/app/common/ModelOrResult.scala b/common/app/common/ModelOrResult.scala
index a4bf0248b4fc..e277e3981770 100644
--- a/common/app/common/ModelOrResult.scala
+++ b/common/app/common/ModelOrResult.scala
@@ -1,8 +1,9 @@
package common
-import com.gu.contentapi.client.model.v1.{Section => ApiSection, ItemResponse}
+import com.gu.contentapi.client.model.v1
+import com.gu.contentapi.client.model.v1.{ItemResponse, Section => ApiSection}
import contentapi.Paths
-import play.api.mvc.{Result, RequestHeader, Results}
+import play.api.mvc.{RequestHeader, Result, Results}
import model._
import implicits.ItemResponses
import java.net.URI
@@ -57,6 +58,7 @@ private object ItemOrRedirect extends ItemResponses with GuLogging {
private def paramString(r: RequestHeader) = if (r.rawQueryString.isEmpty) "" else s"?${r.rawQueryString}"
private def canonicalPath(response: ItemResponse) = response.webUrl.map(new URI(_)).map(_.getPath)
+ def canonicalPath(content: v1.Content): String = new URI(content.webUrl).getPath
private def pathWithoutEdition(section: ApiSection) =
section.editions
@@ -77,29 +79,32 @@ object InternalRedirect extends implicits.Requests with GuLogging {
.orElse(response.tag.map(t => internalRedirect("facia", t.id)))
.orElse(response.section.map(s => internalRedirect("facia", s.id)))
- def contentTypes(response: ItemResponse)(implicit request: RequestHeader): Option[Result] = {
+ private def contentTypes(response: ItemResponse)(implicit request: RequestHeader): Option[Result] = {
response.content.map {
- case a if a.isArticle || a.isLiveBlog => internalRedirect("type/article", a.id)
- case v if v.isVideo => internalRedirect("applications", v.id)
- case g if g.isGallery => internalRedirect("applications", g.id)
- case a if a.isAudio => internalRedirect("applications", a.id)
+ case a if a.isArticle || a.isLiveBlog =>
+ internalRedirect("type/article", ItemOrRedirect.canonicalPath(a))
+ case a if a.isInteractive =>
+ internalRedirect("applications/interactive", ItemOrRedirect.canonicalPath(a))
+ case a if a.isVideo || a.isGallery || a.isAudio =>
+ internalRedirect("applications", ItemOrRedirect.canonicalPath(a))
case unsupportedContent =>
logInfoWithRequestId(s"unsupported content: ${unsupportedContent.id}")
NotFound
-
}
}
- def internalRedirect(base: String, id: String)(implicit request: RequestHeader): Result =
- internalRedirect(base, id, None)
+ private def internalRedirect(base: String, id: String)(implicit request: RequestHeader): Result =
+ internalRedirect(base, id, request.rawQueryStringOption.map("?" + _))
def internalRedirect(base: String, id: String, queryString: Option[String])(implicit
request: RequestHeader,
): Result = {
+ // remove any leading `/` from the ID before using in the redirect
+ val path = id.stripPrefix("/")
val qs: String = queryString.getOrElse("")
request.path match {
- case ShortUrl(_) => Found(s"/$id$qs")
- case _ => Ok.withHeaders("X-Accel-Redirect" -> s"/$base/$id$qs")
+ case ShortUrl(_) => Found(s"/$path$qs")
+ case _ => Ok.withHeaders("X-Accel-Redirect" -> s"/$base/$path$qs")
}
}
diff --git a/common/app/common/SQSQueues.scala b/common/app/common/SQSQueues.scala
index de8789cd0b52..8cd95d97d078 100644
--- a/common/app/common/SQSQueues.scala
+++ b/common/app/common/SQSQueues.scala
@@ -1,105 +1,84 @@
package common
-import java.util.concurrent.{Future => JavaFuture}
-
-import com.amazonaws.handlers.AsyncHandler
-import com.amazonaws.services.sqs.AmazonSQSAsync
-import com.amazonaws.services.sqs.model.{Message => AWSMessage, _}
import play.api.libs.json.{Json, Reads, Writes}
+import software.amazon.awssdk.services.sqs.SqsAsyncClient
+import software.amazon.awssdk.services.sqs.model.{
+ ChangeMessageVisibilityRequest,
+ ChangeMessageVisibilityResponse,
+ DeleteMessageRequest,
+ Message => AWSMessage,
+ ReceiveMessageRequest,
+ SendMessageRequest,
+ SendMessageResponse,
+}
import scala.jdk.CollectionConverters._
+import scala.jdk.FutureConverters._
import scala.collection.mutable
-import scala.concurrent.{ExecutionContext, Future, Promise}
-import scala.util.{Failure, Success}
-
-object SQSQueues {
- implicit class RichAmazonSQSAsyncClient(client: AmazonSQSAsync) {
- private def createHandler[A <: com.amazonaws.AmazonWebServiceRequest, B]() = {
- val promise = Promise[B]()
-
- val handler = new AsyncHandler[A, B] {
- override def onSuccess(request: A, result: B): Unit = promise.complete(Success(result))
-
- override def onError(exception: Exception): Unit = promise.complete(Failure(exception))
- }
-
- (promise.future, handler)
- }
-
- private def asFuture[A <: com.amazonaws.AmazonWebServiceRequest, B](f: AsyncHandler[A, B] => JavaFuture[B]) = {
- val (future, handler) = createHandler[A, B]()
- f(handler)
- future
- }
-
- def receiveMessageFuture(request: ReceiveMessageRequest): Future[ReceiveMessageResult] =
- asFuture[ReceiveMessageRequest, ReceiveMessageResult](client.receiveMessageAsync(request, _))
-
- def deleteMessageFuture(request: DeleteMessageRequest): Future[DeleteMessageResult] =
- asFuture[DeleteMessageRequest, DeleteMessageResult](client.deleteMessageAsync(request, _))
-
- def sendMessageFuture(request: SendMessageRequest): Future[SendMessageResult] =
- asFuture[SendMessageRequest, SendMessageResult](client.sendMessageAsync(request, _))
-
- def changeMessageVisibilityFuture(request: ChangeMessageVisibilityRequest): Future[ChangeMessageVisibilityResult] =
- asFuture[ChangeMessageVisibilityRequest, ChangeMessageVisibilityResult](
- client.changeMessageVisibilityAsync(request, _),
- )
- }
-}
+import scala.concurrent.{ExecutionContext, Future}
case class MessageId(get: String) extends AnyVal
case class ReceiptHandle(get: String) extends AnyVal
case class Message[A](id: MessageId, get: A, handle: ReceiptHandle)
-class MessageQueue[A](client: AmazonSQSAsync, queueUrl: String)(implicit executionContext: ExecutionContext) {
-
- import SQSQueues._
+class MessageQueue[A](client: SqsAsyncClient, queueUrl: String)(implicit executionContext: ExecutionContext) {
- protected def sendMessage(sendRequest: SendMessageRequest): Future[SendMessageResult] = {
- client.sendMessageFuture(sendRequest)
+ protected def sendMessage(sendRequest: SendMessageRequest): Future[SendMessageResponse] = {
+ client.sendMessage(sendRequest).asScala
}
- def retryMessageAfter(handle: ReceiptHandle, timeoutSeconds: Int): Future[ChangeMessageVisibilityResult] = {
- client.changeMessageVisibilityFuture(new ChangeMessageVisibilityRequest(queueUrl, handle.get, timeoutSeconds))
+ def retryMessageAfter(handle: ReceiptHandle, timeoutSeconds: Int): Future[ChangeMessageVisibilityResponse] = {
+ client
+ .changeMessageVisibility(
+ ChangeMessageVisibilityRequest
+ .builder()
+ .queueUrl(queueUrl)
+ .receiptHandle(handle.get)
+ .visibilityTimeout(timeoutSeconds)
+ .build(),
+ )
+ .asScala
}
protected def receiveMessages(receiveRequest: ReceiveMessageRequest): Future[mutable.Buffer[AWSMessage]] = {
- client.receiveMessageFuture(receiveRequest.withQueueUrl(queueUrl)) map { response =>
- response.getMessages.asScala
+ client.receiveMessage(receiveRequest.toBuilder.queueUrl(queueUrl).build()).asScala map { response =>
+ response.messages().asScala
}
}
protected def deleteMessage(handle: ReceiptHandle): Future[Unit] = {
client
- .deleteMessageFuture(
- new DeleteMessageRequest()
- .withQueueUrl(queueUrl)
- .withReceiptHandle(handle.get),
+ .deleteMessage(
+ DeleteMessageRequest
+ .builder()
+ .queueUrl(queueUrl)
+ .receiptHandle(handle.get)
+ .build(),
)
+ .asScala
.map(_ => ())
}
}
/** Utility class for SQS queues that pass simple string messages */
-case class TextMessageQueue[A](client: AmazonSQSAsync, queueUrl: String)(implicit executionContext: ExecutionContext)
+case class TextMessageQueue[A](client: SqsAsyncClient, queueUrl: String)(implicit executionContext: ExecutionContext)
extends MessageQueue[A](client, queueUrl)(executionContext) {
def receive(request: ReceiveMessageRequest): Future[Seq[Message[String]]] = {
receiveMessages(request) map { messages =>
messages.toSeq map { message =>
Message(
- MessageId(message.getMessageId),
- message.getBody,
- ReceiptHandle(message.getReceiptHandle),
+ MessageId(message.messageId()),
+ message.body(),
+ ReceiptHandle(message.receiptHandle()),
)
}
}
}
def receiveOne(request: ReceiveMessageRequest): Future[Option[Message[String]]] = {
- receive(request.withMaxNumberOfMessages(1)) map { messages =>
+ receive(request.toBuilder.maxNumberOfMessages(1).build()) map { messages =>
messages.toList match {
case message :: Nil => Some(message)
case Nil => None
@@ -113,30 +92,30 @@ case class TextMessageQueue[A](client: AmazonSQSAsync, queueUrl: String)(implici
}
/** Utility class for SQS queues that use JSON to serialize their messages */
-case class JsonMessageQueue[A](client: AmazonSQSAsync, queueUrl: String)(implicit executionContext: ExecutionContext)
+case class JsonMessageQueue[A](client: SqsAsyncClient, queueUrl: String)(implicit executionContext: ExecutionContext)
extends MessageQueue[A](client, queueUrl)(executionContext) {
- def send(a: A)(implicit writes: Writes[A]): Future[SendMessageResult] =
- sendMessage(new SendMessageRequest().withQueueUrl(queueUrl).withMessageBody(Json.stringify(Json.toJson(a))))
+ def send(a: A)(implicit writes: Writes[A]): Future[SendMessageResponse] =
+ sendMessage(SendMessageRequest.builder().queueUrl(queueUrl).messageBody(Json.stringify(Json.toJson(a))).build())
def receive(request: ReceiveMessageRequest)(implicit reads: Reads[A]): Future[Seq[Message[A]]] = {
receiveMessages(request) map { messages =>
messages.toSeq map { message =>
Message(
- MessageId(message.getMessageId),
- Json.fromJson[A](Json.parse(message.getBody)) getOrElse {
+ MessageId(message.messageId()),
+ Json.fromJson[A](Json.parse(message.body())) getOrElse {
throw new RuntimeException(
- s"Couldn't parse JSON for message with ID ${message.getMessageId}: '${message.getBody}'",
+ s"Couldn't parse JSON for message with ID ${message.messageId()}: '${message.body}'",
)
},
- ReceiptHandle(message.getReceiptHandle),
+ ReceiptHandle(message.receiptHandle),
)
}
}
}
def receiveOne(request: ReceiveMessageRequest)(implicit reads: Reads[A]): Future[Option[Message[A]]] = {
- receive(request.withMaxNumberOfMessages(1)) map { messages =>
+ receive(request.toBuilder.maxNumberOfMessages(1).build()) map { messages =>
messages.toList match {
case message :: Nil => Some(message)
case Nil => None
diff --git a/common/app/common/TrailsToRss.scala b/common/app/common/TrailsToRss.scala
index 96ccc529fca3..d2b7c2b4b9de 100644
--- a/common/app/common/TrailsToRss.scala
+++ b/common/app/common/TrailsToRss.scala
@@ -11,7 +11,7 @@ import model.liveblog.{Blocks, TextBlockElement}
import model.pressed.PressedStory
import org.jsoup.Jsoup
import play.api.mvc.RequestHeader
-import views.support.{ImageProfile, Item140, Item460}
+import views.support.{ImageProfile, Item140, Item460, Item700}
import java.io.StringWriter
import java.text.SimpleDateFormat
@@ -104,7 +104,7 @@ object TrailsToRss {
val description = makeEntryDescriptionUsing(standfirst, intro, trail.metadata.webUrl)
val mediaModules: Seq[MediaEntryModuleImpl] = for {
- profile: ImageProfile <- List(Item140, Item460)
+ profile: ImageProfile <- List(Item140, Item460, Item700)
trailPicture: ImageMedia <- trail.trailPicture
trailAsset: ImageAsset <- profile.bestFor(trailPicture)
resizedImage <- profile.bestSrcFor(trailPicture)
@@ -211,7 +211,7 @@ object TrailsToRss {
val description = makeEntryDescriptionUsing(standfirst, intro, webUrl)
val mediaModules: Seq[MediaEntryModuleImpl] = for {
- profile: ImageProfile <- List(Item140, Item460)
+ profile: ImageProfile <- List(Item140, Item460, Item700)
trailPicture: ImageMedia <- faciaContent.trail.trailPicture
trailAsset: ImageAsset <- profile.bestFor(trailPicture)
resizedImage <- profile.bestSrcFor(trailPicture)
diff --git a/common/app/common/commercial/hosted/HostedPage.scala b/common/app/common/commercial/hosted/HostedPage.scala
index b62b3910af54..ee54dd6ea2af 100644
--- a/common/app/common/commercial/hosted/HostedPage.scala
+++ b/common/app/common/commercial/hosted/HostedPage.scala
@@ -46,7 +46,7 @@ object HostedPage extends GuLogging {
case Video => HostedVideoPage.fromContent(item)
case Article => HostedArticlePage.fromContent(item)
case Gallery => HostedGalleryPage.fromContent(item)
- case _ =>
+ case _ =>
log.error(s"Failed to make unsupported hosted type: ${item.`type`}: ${item.id}")
None
}
diff --git a/common/app/common/configuration.scala b/common/app/common/configuration.scala
index 2eff1ec15ef6..21db8a160d42 100644
--- a/common/app/common/configuration.scala
+++ b/common/app/common/configuration.scala
@@ -1,8 +1,5 @@
package common
-import com.amazonaws.AmazonClientException
-import com.amazonaws.auth._
-import com.amazonaws.auth.profile.ProfileCredentialsProvider
import com.typesafe.config.{ConfigException, ConfigFactory}
import common.Environment.{app, awsRegion, stage}
import conf.{Configuration, Static}
@@ -18,6 +15,7 @@ import java.util.Map.Entry
import scala.concurrent.duration._
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Success, Try}
+import conf.switches.Switches.{LineItemJobs}
class BadConfigurationException(msg: String) extends RuntimeException(msg)
@@ -132,7 +130,7 @@ object GuardianConfiguration extends GuLogging {
Try(get(property)) match {
case Success(value) => Some(value)
case Failure(_: ConfigException.Missing) => None
- case Failure(e) =>
+ case Failure(e) =>
log.error(s"couldn't retrieve $property", e)
None
}
@@ -243,10 +241,12 @@ class GuardianConfiguration extends GuLogging {
lazy val capiPreviewRoleToAssume: Option[String] =
configuration.getStringProperty("content.api.preview.roleToAssume")
- lazy val capiPreviewCredentials: AWSCredentialsProvider = new AWSCredentialsProviderChain(
- Seq(new ProfileCredentialsProvider("capi")) ++
- capiPreviewRoleToAssume.map(new STSAssumeRoleSessionCredentialsProvider.Builder(_, "capi").build()): _*,
- )
+ lazy val capiPreviewCredentials: software.amazon.awssdk.auth.credentials.AwsCredentialsProvider = {
+ import software.amazon.awssdk.auth.credentials.ProfileCredentialsProvider
+ capiPreviewRoleToAssume
+ .map(roleArn => utils.AWSv2.stsCredentials("capi", roleArn, sessionName = "capi"))
+ .getOrElse(ProfileCredentialsProvider.create("capi"))
+ }
lazy val nextPreviousPageSize: Int =
configuration.getIntegerProperty("content.api.nextPreviousPageSize").getOrElse(50)
@@ -286,13 +286,18 @@ class GuardianConfiguration extends GuLogging {
lazy val subscribeWithGoogleApiUrl =
configuration.getStringProperty("google.subscribeWithGoogleApiUrl").getOrElse("https://swg.theguardian.com")
lazy val googleRecaptchaSiteKey = configuration.getMandatoryStringProperty("guardian.page.googleRecaptchaSiteKey")
+ lazy val googleRecaptchaSiteKeyVisible =
+ configuration.getMandatoryStringProperty("guardian.page.googleRecaptchaSiteKeyVisible")
lazy val googleRecaptchaSecret = configuration.getMandatoryStringProperty("google.googleRecaptchaSecret")
+ lazy val googleRecaptchaSecretVisible =
+ configuration.getMandatoryStringProperty("google.googleRecaptchaSecretVisible")
}
object affiliateLinks {
lazy val bucket: Option[String] = configuration.getStringProperty("skimlinks.bucket")
lazy val domainsKey = "skimlinks/skimlinks-domains.csv"
- lazy val skimlinksId = configuration.getMandatoryStringProperty("skimlinks.id")
+ lazy val skimlinksDefaultId = configuration.getMandatoryStringProperty("skimlinks.id")
+ lazy val skimlinksUSId = configuration.getMandatoryStringProperty("skimlinks.us.id")
lazy val alwaysOffTags: Set[String] =
configuration.getStringProperty("affiliatelinks.always.off.tags").getOrElse("").split(",").toSet
}
@@ -478,10 +483,6 @@ class GuardianConfiguration extends GuLogging {
else configuration.getStringProperty("guardian.page.host") getOrElse ""
lazy val dfpAdUnitGuRoot = configuration.getMandatoryStringProperty("guardian.page.dfpAdUnitRoot")
- lazy val dfpFacebookIaAdUnitRoot =
- configuration.getMandatoryStringProperty("guardian.page.dfp.facebookIaAdUnitRoot")
- lazy val dfpMobileAppsAdUnitRoot =
- configuration.getMandatoryStringProperty("guardian.page.dfp.mobileAppsAdUnitRoot")
lazy val dfpAccountId = configuration.getMandatoryStringProperty("guardian.page.dfpAccountId")
lazy val travelFeedUrl = configuration.getStringProperty("travel.feed.url")
@@ -492,19 +493,19 @@ class GuardianConfiguration extends GuLogging {
}
private lazy val dfpRoot = s"$commercialRoot/dfp"
- lazy val dfpPageSkinnedAdUnitsKey = s"$dfpRoot/pageskinned-adunits-v9.json"
- lazy val dfpLiveBlogTopSponsorshipDataKey = s"$dfpRoot/liveblog-top-sponsorships-v3.json"
- lazy val dfpSurveySponsorshipDataKey = s"$dfpRoot/survey-sponsorships.json"
- lazy val dfpNonRefreshableLineItemIdsKey = s"$dfpRoot/non-refreshable-lineitem-ids-v1.json"
- lazy val dfpLineItemsKey = s"$dfpRoot/lineitems-v7.json"
- lazy val dfpActiveAdUnitListKey = s"$dfpRoot/active-ad-units.csv"
- lazy val dfpMobileAppsAdUnitListKey = s"$dfpRoot/mobile-active-ad-units.csv"
- lazy val dfpFacebookIaAdUnitListKey = s"$dfpRoot/facebookia-active-ad-units.csv"
- lazy val dfpTemplateCreativesKey = s"$dfpRoot/template-creatives.json"
- lazy val dfpCustomTargetingKey = s"$dfpRoot/custom-targeting-key-values.json"
+ private lazy val gamRoot = s"$commercialRoot/gam"
+ def dfpPageSkinnedAdUnitsKey = s"$gamRoot/pageskins.json"
+ lazy val dfpLiveBlogTopSponsorshipDataKey = s"$gamRoot/liveblog-top-sponsorships.json"
+ def dfpSurveySponsorshipDataKey = s"$gamRoot/survey-sponsorships.json"
+ def dfpNonRefreshableLineItemIdsKey = s"$gamRoot/non-refreshable-line-items.json"
+ def dfpLineItemsKey =
+ if (LineItemJobs.isSwitchedOn) s"$gamRoot/line-items.json"
+ else s"$dfpRoot/lineitems-v7.json"
+ lazy val dfpSpecialAdUnitsKey = s"$gamRoot/special-ad-units.json"
+ lazy val dfpCustomFieldsKey = s"$gamRoot/custom-fields.json"
+ lazy val dfpCustomTargetingKey = s"$gamRoot/custom-targeting-key-values.json"
lazy val adsTextObjectKey = s"$commercialRoot/ads.txt"
lazy val appAdsTextObjectKey = s"$commercialRoot/app-ads.txt"
- lazy val takeoversWithEmptyMPUsKey = s"$commercialRoot/takeovers-with-empty-mpus.json"
private lazy val merchandisingFeedsRoot = s"$commercialRoot/merchandising"
lazy val merchandisingFeedsLatest = s"$merchandisingFeedsRoot/latest"
@@ -525,6 +526,12 @@ class GuardianConfiguration extends GuLogging {
lazy val overrideCommercialBundleUrl: Option[String] =
if (environment.isDev) configuration.getStringProperty("commercial.overrideCommercialBundleUrl")
else None
+
+ lazy val admiralUrl = configuration.getStringProperty("commercial.admiralUrl")
+ }
+
+ object abTesting {
+ lazy val uiHtmlObjectKey = s"${environment.stage.toUpperCase}/admin/ab-testing/ab-tests.html"
}
object journalism {
@@ -650,26 +657,6 @@ class GuardianConfiguration extends GuLogging {
lazy val frontPressSns: Option[String] = configuration.getStringProperty("frontpress.sns.topic")
lazy val r2PressSns: Option[String] = configuration.getStringProperty("r2press.sns.topic")
lazy val r2PressTakedownSns: Option[String] = configuration.getStringProperty("r2press.takedown.sns.topic")
-
- def mandatoryCredentials: AWSCredentialsProvider =
- credentials.getOrElse(throw new BadConfigurationException("AWS credentials are not configured"))
- val credentials: Option[AWSCredentialsProvider] = {
- val provider = new AWSCredentialsProviderChain(
- new ProfileCredentialsProvider("frontend"),
- InstanceProfileCredentialsProvider.getInstance(),
- )
-
- // this is a bit of a convoluted way to check whether we actually have credentials.
- // I guess in an ideal world there would be some sort of isConfigued() method...
- try {
- provider.getCredentials
- Some(provider)
- } catch {
- case ex: AmazonClientException =>
- log.error(ex.getMessage, ex)
- throw ex
- }
- }
}
object standalone {
diff --git a/common/app/common/dfp/AdSlotAgent.scala b/common/app/common/dfp/AdSlotAgent.scala
deleted file mode 100644
index d4a5bb353441..000000000000
--- a/common/app/common/dfp/AdSlotAgent.scala
+++ /dev/null
@@ -1,24 +0,0 @@
-package common.dfp
-
-import java.net.URI
-import common.Edition
-
-trait AdSlotAgent {
-
- protected def takeoversWithEmptyMPUs: Seq[TakeoverWithEmptyMPUs]
-
- def omitMPUsFromContainers(pageId: String, edition: Edition): Boolean = {
-
- def toPageId(url: String): String = new URI(url).getPath.tail
-
- val current = takeoversWithEmptyMPUs filter { takeover =>
- takeover.startTime.isBeforeNow && takeover.endTime.isAfterNow
- }
-
- current exists { takeover =>
- toPageId(takeover.url) == pageId && takeover.editions.contains(edition)
- }
- }
-}
-
-sealed abstract class AdSlot(val name: String)
diff --git a/common/app/common/dfp/DfpAgent.scala b/common/app/common/dfp/DfpAgent.scala
index 1d4b71ad8ed9..8f774167577e 100644
--- a/common/app/common/dfp/DfpAgent.scala
+++ b/common/app/common/dfp/DfpAgent.scala
@@ -8,24 +8,22 @@ import services.S3
import scala.concurrent.ExecutionContext
import scala.io.Codec.UTF8
+import org.checkerframework.checker.units.qual.s
-object DfpAgent extends PageskinAdAgent with LiveBlogTopSponsorshipAgent with SurveySponsorshipAgent with AdSlotAgent {
+object DfpAgent extends PageskinAdAgent with LiveBlogTopSponsorshipAgent with SurveySponsorshipAgent {
override protected val environmentIsProd: Boolean = environment.isProd
private lazy val liveblogTopSponsorshipAgent = Box[Seq[LiveBlogTopSponsorship]](Nil)
private lazy val surveyAdUnitAgent = Box[Seq[SurveySponsorship]](Nil)
private lazy val pageskinnedAdUnitAgent = Box[Seq[PageSkinSponsorship]](Nil)
- private lazy val lineItemAgent = Box[Map[AdSlot, Seq[GuLineItem]]](Map.empty)
- private lazy val takeoverWithEmptyMPUsAgent = Box[Seq[TakeoverWithEmptyMPUs]](Nil)
private lazy val nonRefreshableLineItemsAgent = Box[Seq[Long]](Nil)
+ private lazy val specialAdUnitsAgent = Box[Seq[(String, String)]](Nil)
+ private lazy val customFieldsAgent = Box[Seq[GuCustomField]](Nil)
protected def pageSkinSponsorships: Seq[PageSkinSponsorship] = pageskinnedAdUnitAgent.get()
protected def liveBlogTopSponsorships: Seq[LiveBlogTopSponsorship] = liveblogTopSponsorshipAgent.get()
protected def surveySponsorships: Seq[SurveySponsorship] = surveyAdUnitAgent.get()
- protected def lineItemsBySlot: Map[AdSlot, Seq[GuLineItem]] = lineItemAgent.get()
- protected def takeoversWithEmptyMPUs: Seq[TakeoverWithEmptyMPUs] =
- takeoverWithEmptyMPUsAgent.get()
def nonRefreshableLineItemIds(): Seq[Long] = nonRefreshableLineItemsAgent.get()
@@ -73,6 +71,20 @@ object DfpAgent extends PageskinAdAgent with LiveBlogTopSponsorshipAgent with Su
} yield lineItemIds) getOrElse Nil
}
+ def grabSpecialAdUnitsFromStore(): Seq[(String, String)] = {
+ (for {
+ jsonString <- stringFromS3(dfpSpecialAdUnitsKey)
+ report <- Json.parse(jsonString).validate[Seq[(String, String)]].asOpt
+ } yield report) getOrElse Nil
+ }
+
+ def grabCustomFieldsFromStore() = {
+ (for {
+ jsonString <- stringFromS3(dfpCustomFieldsKey)
+ customFields <- Json.parse(jsonString).validate[Seq[GuCustomField]].asOpt
+ } yield customFields) getOrElse Nil
+ }
+
update(pageskinnedAdUnitAgent)(grabPageSkinSponsorshipsFromStore(dfpPageSkinnedAdUnitsKey))
update(nonRefreshableLineItemsAgent)(grabNonRefreshableLineItemIdsFromStore())
@@ -81,10 +93,9 @@ object DfpAgent extends PageskinAdAgent with LiveBlogTopSponsorshipAgent with Su
update(surveyAdUnitAgent)(grabSurveySponsorshipsFromStore())
- }
+ update(specialAdUnitsAgent)(grabSpecialAdUnitsFromStore())
- def refreshFaciaSpecificData()(implicit executionContext: ExecutionContext): Unit = {
+ update(customFieldsAgent)(grabCustomFieldsFromStore())
- update(takeoverWithEmptyMPUsAgent)(TakeoverWithEmptyMPUs.fetch())
}
}
diff --git a/common/app/common/dfp/DfpAgentLifecycle.scala b/common/app/common/dfp/DfpAgentLifecycle.scala
index 40de6413596c..714c7e8bae33 100644
--- a/common/app/common/dfp/DfpAgentLifecycle.scala
+++ b/common/app/common/dfp/DfpAgentLifecycle.scala
@@ -30,13 +30,3 @@ class DfpAgentLifecycle(appLifeCycle: ApplicationLifecycle, jobs: JobScheduler,
}
}
}
-
-class FaciaDfpAgentLifecycle(appLifeCycle: ApplicationLifecycle, jobs: JobScheduler, pekkoAsync: PekkoAsync)(implicit
- ec: ExecutionContext,
-) extends DfpAgentLifecycle(appLifeCycle, jobs, pekkoAsync) {
-
- override def refreshDfpAgent(): Unit = {
- DfpAgent.refresh()
- DfpAgent.refreshFaciaSpecificData()
- }
-}
diff --git a/common/app/common/dfp/DfpData.scala b/common/app/common/dfp/DfpData.scala
index 3bae0e67a27f..b0c394c00d2d 100644
--- a/common/app/common/dfp/DfpData.scala
+++ b/common/app/common/dfp/DfpData.scala
@@ -76,9 +76,9 @@ case class CustomTarget(name: String, op: String, values: Seq[String]) {
def isPlatform(value: String): Boolean = isPositive("p") && values.contains(value)
def isNotPlatform(value: String): Boolean = isNegative("p") && values.contains(value)
- def matchesLiveBlogTopTargeting: Boolean = {
- val liveBlogTopSectionTargets = List("culture", "football", "sport", "tv-and-radio")
- values.intersect(liveBlogTopSectionTargets).nonEmpty
+ val allowedliveBlogTopSectionTargets = Seq("culture", "football", "sport", "tv-and-radio")
+ private def matchesLiveBlogTopTargeting: Boolean = {
+ values.intersect(allowedliveBlogTopSectionTargets).nonEmpty
}
val isLiveblogTopSlot = isSlot("liveblog-top")
@@ -265,10 +265,8 @@ case class GuLineItem(
target.name == "ct" && target.values.contains("liveblog")
}
- val allowedSections = Set("culture", "sport", "football")
-
val targetsOnlyAllowedSections = matchingLiveblogTargeting.exists { target =>
- target.name == "s" && target.values.forall(allowedSections.contains)
+ target.name == "s" && target.values.forall(target.allowedliveBlogTopSectionTargets.contains)
}
val isMobileBreakpoint = matchingLiveblogTargeting.exists { target =>
@@ -277,23 +275,25 @@ case class GuLineItem(
val isSponsorship = lineItemType == Sponsorship
- val hasEditionTargeting = targeting.editions.nonEmpty
-
- isLiveblogTopSlot && isLiveblogContentType && targetsOnlyAllowedSections && isMobileBreakpoint && isSponsorship && hasEditionTargeting
+ isLiveblogTopSlot && isLiveblogContentType && targetsOnlyAllowedSections && isMobileBreakpoint && isSponsorship
}
val targetsSurvey: Boolean = {
val matchingSurveyTargeting = for {
targetSet <- targeting.customTargetSets
target <- targetSet.targets
- if target.name == "slot" || target.values.contains("survey")
+ if target.name == "slot" || target.name == "bp"
} yield target
- val isSurveySlot = matchingSurveyTargeting.exists { target =>
+ val targetsSurveySlot = matchingSurveyTargeting.exists { target =>
target.name == "slot" && target.values.contains("survey")
}
- isSurveySlot
+ val targetsDesktopBreakpoint = matchingSurveyTargeting.exists { target =>
+ target.name == "bp" && target.values.contains("desktop")
+ }
+
+ targetsSurveySlot && targetsDesktopBreakpoint
}
lazy val targetsNetworkOrSectionFrontDirectly: Boolean = {
diff --git a/common/app/common/dfp/LiveBlogTopSponsorshipAgent.scala b/common/app/common/dfp/LiveBlogTopSponsorshipAgent.scala
index f8716f1728a0..9a6d48c46394 100644
--- a/common/app/common/dfp/LiveBlogTopSponsorshipAgent.scala
+++ b/common/app/common/dfp/LiveBlogTopSponsorshipAgent.scala
@@ -20,10 +20,13 @@ trait LiveBlogTopSponsorshipAgent {
adTest: Option[String],
): Seq[LiveBlogTopSponsorship] = {
liveBlogTopSponsorships.filter { sponsorship =>
- sponsorship.editions.contains(edition) && sponsorship.sections.contains(
- sectionId,
- ) && (keywords exists sponsorship.hasTag) && sponsorship
- .matchesTargetedAdTest(adTest)
+ // Section must match
+ sponsorship.sections.contains(sectionId) &&
+ // Edition, keywords & adtest are optional matches
+ // If specified on the line item, they must match
+ sponsorship.matchesEditionTargeting(edition) &&
+ sponsorship.matchesKeywordTargeting(keywords) &&
+ sponsorship.matchesTargetedAdTest(adTest)
}
}
@@ -32,7 +35,7 @@ trait LiveBlogTopSponsorshipAgent {
val adTest = request.getQueryString("adtest")
val edition = Edition(request)
- findSponsorships(edition, metadata.sectionId, tags, adTest).nonEmpty
+ findSponsorships(edition, metadata.sectionId, tags.filter(_.isKeyword), adTest).nonEmpty
} else {
false
}
diff --git a/common/app/common/dfp/LiveblogTopSponsorship.scala b/common/app/common/dfp/LiveblogTopSponsorship.scala
index 5e0eec3d7eff..342889f40a4d 100644
--- a/common/app/common/dfp/LiveblogTopSponsorship.scala
+++ b/common/app/common/dfp/LiveblogTopSponsorship.scala
@@ -13,19 +13,41 @@ case class LiveBlogTopSponsorship(
adTest: Option[String],
targetsAdTest: Boolean,
) {
- def matchesTargetedAdTest(adTest: Option[String]): Boolean =
- if (this.targetsAdTest) { adTest == this.adTest }
- else { true }
+ def matchesTargetedAdTest(adTest: Option[String]): Boolean = {
+ if (this.targetsAdTest) {
+ // If the sponsorship targets an adtest, check if it matches
+ adTest == this.adTest
+ } else {
+ // If no adtest targeting, return true
+ true
+ }
+ }
- private def hasTagId(tags: Seq[String], tagId: String): Boolean =
- tagId.split('/').lastOption exists { endPart =>
- tags contains endPart
+ def matchesEditionTargeting(edition: Edition) = {
+ if (this.editions.nonEmpty) {
+ // If the sponsorship targets an edition, check if it matches
+ this.editions.exists(_.id == edition.id)
+ } else {
+ // If no edition targeting, return true
+ true
+ }
+ }
+
+ def matchesKeywordTargeting(keywordTags: Seq[Tag]) = {
+ if (this.keywords.nonEmpty) {
+ // If the sponsorship targets a keyword, check if it matches
+ keywordTags exists { tag: Tag =>
+ tag.isKeyword && matchesTag(this.keywords, tag.id)
+ }
+ } else {
+ // If no keyword targeting, return true
+ true
}
+ }
- def hasTag(tag: Tag): Boolean =
- tag.properties.tagType match {
- case "Keyword" => hasTagId(keywords, tag.id)
- case _ => false
+ private def matchesTag(tags: Seq[String], tagId: String): Boolean =
+ tagId.split("/").lastOption exists { endPart =>
+ tags contains endPart
}
}
diff --git a/common/app/common/dfp/TakeoverWithEmptyMPUs.scala b/common/app/common/dfp/TakeoverWithEmptyMPUs.scala
deleted file mode 100644
index 7de28be96fac..000000000000
--- a/common/app/common/dfp/TakeoverWithEmptyMPUs.scala
+++ /dev/null
@@ -1,102 +0,0 @@
-package common.dfp
-
-import common.Edition
-import conf.Configuration.commercial._
-import org.joda.time.format.{DateTimeFormat, ISODateTimeFormat}
-import org.joda.time.{DateTime, DateTimeZone}
-import play.api.data.Forms._
-import play.api.data.JodaForms._
-import play.api.data.format.Formatter
-import play.api.data.validation.{Invalid, Valid, Constraint}
-import play.api.data.{Form, FormError}
-import play.api.libs.functional.syntax._
-import play.api.libs.json.Json._
-import play.api.libs.json._
-import services.S3
-import java.net.{MalformedURLException, URL}
-
-case class TakeoverWithEmptyMPUs(url: String, editions: Seq[Edition], startTime: DateTime, endTime: DateTime)
-
-object TakeoverWithEmptyMPUs {
-
- private val timeJsonFormatter = ISODateTimeFormat.dateTime().withZoneUTC()
-
- val timeViewFormatter = DateTimeFormat.forPattern("d MMM YYYY HH:mm:ss z").withZoneUTC()
-
- implicit val writes: Writes[TakeoverWithEmptyMPUs] = (takeover: TakeoverWithEmptyMPUs) => {
- Json.obj(
- "url" -> takeover.url,
- "editions" -> takeover.editions,
- "startTime" -> timeJsonFormatter.print(takeover.startTime),
- "endTime" -> timeJsonFormatter.print(takeover.endTime),
- )
- }
-
- val mustBeAtLeastOneDirectoryDeep = Constraint[String] { s: String =>
- try {
- val uri = new URL(s)
- uri.getPath.trim match {
- case "" => Invalid("Must be at least one directory deep. eg: http://www.theguardian.com/us")
- case "/" => Invalid("Must be at least one directory deep. eg: http://www.theguardian.com/us")
- case _ => Valid
- }
- } catch {
- case _: MalformedURLException => Invalid("Must be a valid URL. eg: http://www.theguardian.com/us")
- }
- }
-
- implicit val reads: Reads[TakeoverWithEmptyMPUs] = (
- (JsPath \ "url").read[String] and
- (JsPath \ "editions").read[Seq[Edition]] and
- (JsPath \ "startTime").read[String].map(timeJsonFormatter.parseDateTime) and
- (JsPath \ "endTime").read[String].map(timeJsonFormatter.parseDateTime)
- )(TakeoverWithEmptyMPUs.apply _)
-
- implicit val editionFormatter: Formatter[Edition] = new Formatter[Edition] {
- override def bind(key: String, data: Map[String, String]): Either[Seq[FormError], Edition] = {
- val editionId = data(key)
- Edition.byId(editionId) map (Right(_)) getOrElse
- Left(Seq(FormError(key, s"No such edition: $key")))
- }
- override def unbind(key: String, value: Edition): Map[String, String] = {
- Map(key -> value.id)
- }
- }
-
- val form = Form(
- mapping(
- "url" -> nonEmptyText.verifying(mustBeAtLeastOneDirectoryDeep),
- "editions" -> seq(of[Edition]),
- "startTime" -> jodaDate("yyyy-MM-dd'T'HH:mm", DateTimeZone.UTC),
- "endTime" -> jodaDate("yyyy-MM-dd'T'HH:mm", DateTimeZone.UTC),
- )(TakeoverWithEmptyMPUs.apply)(TakeoverWithEmptyMPUs.unapply),
- )
-
- def fetch(): Seq[TakeoverWithEmptyMPUs] = {
- val takeovers = S3.get(takeoversWithEmptyMPUsKey) map {
- Json.parse(_).as[Seq[TakeoverWithEmptyMPUs]]
- } getOrElse Nil
- takeovers filter { t => mustBeAtLeastOneDirectoryDeep(t.url) == Valid }
- }
-
- def fetchSorted(): Seq[TakeoverWithEmptyMPUs] = {
- fetch() sortBy { takeover =>
- (takeover.url, takeover.startTime.getMillis)
- }
- }
-
- private def put(takeovers: Seq[TakeoverWithEmptyMPUs]): Unit = {
- val content = Json.stringify(toJson(takeovers))
- S3.putPrivate(takeoversWithEmptyMPUsKey, content, "application/json")
- }
-
- def create(takeover: TakeoverWithEmptyMPUs): Unit = {
- val takeovers = fetch() :+ takeover
- put(takeovers)
- }
-
- def remove(url: String): Unit = {
- val takeovers = fetch() filterNot (_.url == url)
- put(takeovers)
- }
-}
diff --git a/common/app/common/editions/Us.scala b/common/app/common/editions/Us.scala
index 4032508673fa..1415f04438c1 100644
--- a/common/app/common/editions/Us.scala
+++ b/common/app/common/editions/Us.scala
@@ -12,6 +12,7 @@ object Us
timezone = DateTimeZone.forID("America/New_York"),
locale = Some(Locale.forLanguageTag("en-us")),
networkFrontId = "us",
+ editionalisedSections = Edition.commonEditionalisedSections :+ "thefilter",
navigationLinks = EditionNavLinks(
NavLinks.usNewsPillar,
NavLinks.usOpinionPillar,
diff --git a/common/app/common/metrics.scala b/common/app/common/metrics.scala
index 6e94833cf673..2e96ef867686 100644
--- a/common/app/common/metrics.scala
+++ b/common/app/common/metrics.scala
@@ -3,14 +3,13 @@ package common
import java.io.File
import java.lang.management.{GarbageCollectorMXBean, ManagementFactory}
import java.util.concurrent.atomic.AtomicLong
-
import app.LifecycleComponent
-import com.amazonaws.services.cloudwatch.model.{Dimension, StandardUnit}
import conf.Configuration
import metrics._
import model.ApplicationIdentity
import model.diagnostics.CloudWatch
import play.api.inject.ApplicationLifecycle
+import software.amazon.awssdk.services.cloudwatch.model.{Dimension, StandardUnit}
import scala.jdk.CollectionConverters._
import scala.concurrent.duration._
@@ -50,18 +49,6 @@ object SystemMetrics extends implicits.Numbers {
get = () => bytesAsMb(ManagementFactory.getMemoryMXBean.getHeapMemoryUsage.getUsed),
)
- val MaxNonHeapMemoryMetric = GaugeMetric(
- name = "max-non-heap-memory",
- description = "Max non heap memory (MB)",
- get = () => bytesAsMb(ManagementFactory.getMemoryMXBean.getNonHeapMemoryUsage.getMax),
- )
-
- val UsedNonHeapMemoryMetric = GaugeMetric(
- name = "used-non-heap-memory",
- description = "Used non heap memory (MB)",
- get = () => bytesAsMb(ManagementFactory.getMemoryMXBean.getNonHeapMemoryUsage.getUsed),
- )
-
val FreeDiskSpaceMetric = GaugeMetric(
name = "free-disk-space",
description = "Free disk space (MB)",
@@ -72,7 +59,7 @@ object SystemMetrics extends implicits.Numbers {
name = "thread-count",
description = "Thread Count",
get = () => ManagementFactory.getThreadMXBean.getThreadCount,
- metricUnit = StandardUnit.Count,
+ metricUnit = StandardUnit.COUNT,
)
// yeah, casting to com.sun.. ain't too pretty
@@ -105,7 +92,7 @@ object SystemMetrics extends implicits.Numbers {
name = "build-number",
description = "Build number",
get = () => buildNumber,
- metricUnit = StandardUnit.None,
+ metricUnit = StandardUnit.NONE,
)
}
@@ -138,33 +125,21 @@ object ContentApiMetrics {
}
-object DfpApiMetrics {
- val DfpSessionErrors = CountMetric(
- "dfp-session-errors",
- "Number of times the app failed to build a DFP session",
- )
-
- val DfpApiErrors = CountMetric(
- "dfp-api-errors",
- "Number of times a request to the DFP API results in an error",
- )
-}
-
object FaciaPressMetrics {
val FrontPressCronSuccess = CountMetric(
"front-press-cron-success",
"Number of times facia-tool cron job has successfully pressed",
)
- val UkPressLatencyMetric = DurationMetric("uk-press-latency", StandardUnit.Milliseconds)
- val UsPressLatencyMetric = DurationMetric("us-press-latency", StandardUnit.Milliseconds)
- val AuPressLatencyMetric = DurationMetric("au-press-latency", StandardUnit.Milliseconds)
- val AllFrontsPressLatencyMetric = DurationMetric("front-press-latency", StandardUnit.Milliseconds)
- val FrontPressContentSize = SamplerMetric("front-press-content-size", StandardUnit.Bytes)
- val FrontPressContentSizeLite = SamplerMetric("front-press-content-size-lite", StandardUnit.Bytes)
- val FrontDecodingLatency = DurationMetric("front-decoding-latency", StandardUnit.Milliseconds)
- val FrontDownloadLatency = DurationMetric("front-download-latency", StandardUnit.Milliseconds)
- val FrontNotModifiedDownloadLatency = DurationMetric("front-not-modified-download-latency", StandardUnit.Milliseconds)
+ val UkPressLatencyMetric = DurationMetric("uk-press-latency", StandardUnit.MILLISECONDS)
+ val UsPressLatencyMetric = DurationMetric("us-press-latency", StandardUnit.MILLISECONDS)
+ val AuPressLatencyMetric = DurationMetric("au-press-latency", StandardUnit.MILLISECONDS)
+ val AllFrontsPressLatencyMetric = DurationMetric("front-press-latency", StandardUnit.MILLISECONDS)
+ val FrontPressContentSize = SamplerMetric("front-press-content-size", StandardUnit.BYTES)
+ val FrontPressContentSizeLite = SamplerMetric("front-press-content-size-lite", StandardUnit.BYTES)
+ val FrontDecodingLatency = DurationMetric("front-decoding-latency", StandardUnit.MILLISECONDS)
+ val FrontDownloadLatency = DurationMetric("front-download-latency", StandardUnit.MILLISECONDS)
+ val FrontNotModifiedDownloadLatency = DurationMetric("front-not-modified-download-latency", StandardUnit.MILLISECONDS)
}
object EmailSubsciptionMetrics {
@@ -208,7 +183,7 @@ class CloudWatchMetricsLifecycle(
extends LifecycleComponent
with GuLogging {
val applicationMetricsNamespace: String = "Application"
- val applicationDimension = List(new Dimension().withName("ApplicationName").withValue(appIdentity.name))
+ val applicationDimension = List(Dimension.builder().name("ApplicationName").value(appIdentity.name).build())
appLifecycle.addStopHook { () =>
Future {
@@ -233,13 +208,13 @@ class CloudWatchMetricsLifecycle(
GaugeMetric(
s"${gc.name}-gc-count-per-min",
"Used heap memory (MB)",
- StandardUnit.Count,
+ StandardUnit.COUNT,
() => gc.gcCount,
),
GaugeMetric(
s"${gc.name}-gc-time-per-min",
"Used heap memory (MB)",
- StandardUnit.Count,
+ StandardUnit.COUNT,
() => gc.gcTime,
),
)
@@ -247,7 +222,6 @@ class CloudWatchMetricsLifecycle(
private def report(): Unit = {
val allMetrics: List[FrontendMetric] = this.systemMetrics ::: this.appMetrics.metrics
-
CloudWatch.putMetrics(applicationMetricsNamespace, allMetrics, applicationDimension)
}
diff --git a/common/app/concurrent/CircuitBreakerRegistry.scala b/common/app/concurrent/CircuitBreakerRegistry.scala
index b33d63dcb80f..7d2e5a87b404 100644
--- a/common/app/concurrent/CircuitBreakerRegistry.scala
+++ b/common/app/concurrent/CircuitBreakerRegistry.scala
@@ -25,7 +25,9 @@ object CircuitBreakerRegistry extends GuLogging {
)
cb.onOpen(
- log.error(s"Circuit breaker ($name) OPEN (exceeded $maxFailures failures)"),
+ log.error(
+ s"Circuit breaker ($name) OPEN (exceeded $maxFailures failures) with $callTimeout (${callTimeout}) and resetTimeout (${resetTimeout}).",
+ ),
)
cb.onHalfOpen(
diff --git a/common/app/conf/switches/ABTestSwitches.scala b/common/app/conf/switches/ABTestSwitches.scala
index 677b5a11dd90..4c9327bb3f20 100644
--- a/common/app/conf/switches/ABTestSwitches.scala
+++ b/common/app/conf/switches/ABTestSwitches.scala
@@ -7,66 +7,22 @@ import conf.switches.Expiry.never
trait ABTestSwitches {
Switch(
ABTests,
- "ab-sign-in-gate-main-control",
- "Control audience for the sign in gate to 9% audience. Will never see the sign in gate.",
- owners = Seq(Owner.withGithub("coldlink")),
- safeState = Off,
- sellByDate = Some(LocalDate.of(2025, 12, 1)),
- exposeClientSide = true,
- highImpact = false,
- )
-
- Switch(
- ABTests,
- "ab-sign-in-gate-main-variant",
- "Show sign in gate to 90% of users on 3rd article view, variant/full audience",
- owners = Seq(Owner.withGithub("coldlink")),
- safeState = Off,
- sellByDate = Some(LocalDate.of(2025, 12, 1)),
- exposeClientSide = true,
- highImpact = false,
- )
-
- Switch(
- ABTests,
- "ab-auxia-sign-in-gate",
- "Experimental use of Auxia to drive the client-side SignIn gate",
+ "ab-no-auxia-sign-in-gate",
+ "Defines a control group who should not have sign-in gate journeys handled by Auxia",
owners = Seq(Owner.withEmail("growth@guardian.co.uk")),
safeState = Off,
- sellByDate = Some(LocalDate.of(2026, 1, 30)),
- exposeClientSide = true,
- highImpact = false,
- )
-
- Switch(
- ABTests,
- "ab-defer-permutive-load",
- "Test the impact of deferring the Permutive script load",
- owners = Seq(Owner.withEmail("commercial.dev@theguardian.com")),
- safeState = Off,
- sellByDate = Some(LocalDate.of(2025, 3, 28)),
- exposeClientSide = true,
- highImpact = false,
- )
-
- Switch(
- ABTests,
- "ab-prebid-bid-cache",
- "Test the impact of enabling prebid bid caching",
- owners = Seq(Owner.withEmail("commercial.dev@theguardian.com")),
- safeState = Off,
- sellByDate = Some(LocalDate.of(2025, 3, 28)),
+ sellByDate = Some(LocalDate.of(2027, 11, 1)),
exposeClientSide = true,
highImpact = false,
)
Switch(
ABTests,
- "ab-the-trade-desk",
- "Test the impact of disabling the trade desk for some of our users",
- owners = Seq(Owner.withEmail("commercial.dev@theguardian.com")),
+ "ab-admiral-adblock-recovery",
+ "Testing the Admiral integration for adblock recovery on theguardian.com",
+ owners = Seq(Owner.withEmail("commercial.dev@guardian.co.uk")),
safeState = Off,
- sellByDate = Some(LocalDate.of(2025, 3, 28)),
+ sellByDate = Some(LocalDate.of(2026, 1, 21)),
exposeClientSide = true,
highImpact = false,
)
diff --git a/common/app/conf/switches/CommercialSwitches.scala b/common/app/conf/switches/CommercialSwitches.scala
index 818e56d3bb5e..5d202c208bdc 100644
--- a/common/app/conf/switches/CommercialSwitches.scala
+++ b/common/app/conf/switches/CommercialSwitches.scala
@@ -159,6 +159,28 @@ trait CommercialSwitches {
exposeClientSide = true,
highImpact = false,
)
+
+ val LineItemJobs: Switch = Switch(
+ group = Commercial,
+ name = "line-item-jobs",
+ description = "Enable Frontend to read from the S3 line items jobs generated by the Step Functions",
+ owners = group(Commercial),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = true,
+ highImpact = false,
+ )
+
+ val disableChildDirected: Switch = Switch(
+ group = Commercial,
+ name = "disable-child-directed",
+ description = "Disable child-directed treatment for ads",
+ owners = group(Commercial),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = true,
+ highImpact = false,
+ )
}
trait PrebidSwitches {
@@ -350,17 +372,6 @@ trait PrebidSwitches {
highImpact = false,
)
- val prebidAdYouLike: Switch = Switch(
- group = CommercialPrebid,
- name = "prebid-ad-you-like",
- description = "Include AdYouLike adapter in Prebid auctions",
- owners = group(Commercial),
- safeState = Off,
- sellByDate = never,
- exposeClientSide = true,
- highImpact = false,
- )
-
val prebidCriteo: Switch = Switch(
group = CommercialPrebid,
name = "prebid-criteo",
@@ -416,17 +427,6 @@ trait PrebidSwitches {
highImpact = false,
)
- val prebidBidCache: Switch = Switch(
- group = CommercialPrebid,
- name = "prebid-bid-cache",
- description = "Enable the Prebid bid cache",
- owners = group(Commercial),
- safeState = Off,
- sellByDate = never,
- exposeClientSide = true,
- highImpact = false,
- )
-
val sentinelLogger: Switch = Switch(
group = Commercial,
name = "sentinel-logger",
diff --git a/common/app/conf/switches/FeatureSwitches.scala b/common/app/conf/switches/FeatureSwitches.scala
index 74efc58aef57..762da402aa3f 100644
--- a/common/app/conf/switches/FeatureSwitches.scala
+++ b/common/app/conf/switches/FeatureSwitches.scala
@@ -73,17 +73,6 @@ trait FeatureSwitches {
highImpact = false,
)
- val ExtendedMostPopularFronts = Switch(
- SwitchGroup.Feature,
- "extended-most-popular-fronts",
- "Extended 'If switched on shows 'Most Popular' component with space for DPMUs on fronts",
- owners = group(Commercial),
- safeState = On,
- sellByDate = never,
- exposeClientSide = true,
- highImpact = false,
- )
-
val MostViewedFronts = Switch(
SwitchGroup.Feature,
"most-viewed-fronts",
@@ -397,18 +386,6 @@ trait FeatureSwitches {
highImpact = false,
)
- // Election interactive header switch
- val InteractiveHeaderSwitch = Switch(
- SwitchGroup.Feature,
- "interactive-full-header-switch",
- "If switched on, the header on all interactives will display in full.",
- owners = Seq(Owner.withName("unknown")),
- safeState = Off,
- sellByDate = never,
- exposeClientSide = true,
- highImpact = false,
- )
-
val slotBodyEnd = Switch(
SwitchGroup.Feature,
"slot-body-end",
@@ -562,4 +539,125 @@ trait FeatureSwitches {
exposeClientSide = false,
highImpact = false,
)
+
+ val DCRFootballPages = Switch(
+ SwitchGroup.Feature,
+ "dcr-football-pages",
+ "If this switch is on, live, fixtures and results football pages will be rendered with DCR",
+ owners = Seq(Owner.withGithub("dotcom.platform@theguardian.com")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val DCRFootballMatchSummary = Switch(
+ SwitchGroup.Feature,
+ "dcr-football-match-summary",
+ "If this switch is on, football match summary pages will be rendered with DCR",
+ owners = Seq(Owner.withGithub("dotcom.platform@theguardian.com")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val DCRCricketPages = Switch(
+ SwitchGroup.Feature,
+ "dcr-cricket-pages",
+ "If this switch is on, cricket scorecard pages will be rendered with DCR",
+ owners = Seq(Owner.withGithub("dotcom.platform@theguardian.com")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val DCRFootballTablesPages = Switch(
+ SwitchGroup.Feature,
+ "dcr-football-table-pages",
+ "If this switch is on, football table pages will be rendered with DCR",
+ owners = Seq(Owner.withGithub("dotcom.platform@theguardian.com")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val WomensEuro2025Atom = Switch(
+ SwitchGroup.Feature,
+ "womens-euro-2025-atom",
+ "If this switch is on, the atom will be rendered on several football data pages",
+ owners = Seq(Owner.withGithub("dotcom.platform@theguardian.com")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val DCARGalleyPages = Switch(
+ SwitchGroup.Feature,
+ "dcar-gallery-pages",
+ "If this switch is on, the gallery article will be rendered by DCAR",
+ owners = Seq(Owner.withGithub("dotcom.platform@theguardian.com")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val EnableNewServerSideABTestsHeader = Switch(
+ SwitchGroup.Feature,
+ "enable-new-server-side-tests-header",
+ "Enable new server-side AB tests header and add it to the vary header",
+ owners = Seq(Owner.withEmail("commercial.dev@guardian.co.uk")),
+ sellByDate = never,
+ safeState = Off,
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val ProductLeftColCards = Switch(
+ SwitchGroup.Feature,
+ "product-left-col-cards",
+ "Enables product element summary cards to be shown in the left column at wide breakpoints",
+ owners = Seq(Owner.withEmail("thefilter.dev@guardian.co.uk")),
+ sellByDate = never,
+ safeState = Off,
+ exposeClientSide = true,
+ highImpact = false,
+ )
+
+ val DCRHostedContent = Switch(
+ group = SwitchGroup.Feature,
+ name = "dcr-hosted-content",
+ description = "Render hosted content pages with DCR",
+ owners = Seq(Owner.withEmail("commercial.dev@guardian.co.uk")),
+ safeState = Off,
+ sellByDate = Some(LocalDate.of(2026, 4, 15)),
+ exposeClientSide = false,
+ highImpact = false,
+ )
+
+ val SignInGate = Switch(
+ group = SwitchGroup.Feature,
+ name = "sign-in-gate",
+ description = "Enable sign-in gate on articles",
+ owners = Seq(Owner.withEmail("value.dev@guardian.co.uk"), Owner.withEmail("growth.dev@guardian.co.uk")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = true,
+ highImpact = false,
+ )
+
+ val EnableHlsWeb = Switch(
+ group = SwitchGroup.Feature,
+ name = "enable-hls-web",
+ description = "Enable HLS web streaming on web",
+ owners = Seq(Owner.withEmail("fronts.and.curation@guardian.co.uk")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = true,
+ highImpact = false,
+ )
}
diff --git a/common/app/conf/switches/IdentitySwitches.scala b/common/app/conf/switches/IdentitySwitches.scala
index 795e6c6a10c0..1c0cb35200f0 100644
--- a/common/app/conf/switches/IdentitySwitches.scala
+++ b/common/app/conf/switches/IdentitySwitches.scala
@@ -15,11 +15,11 @@ trait IdentitySwitches {
highImpact = false,
)
- val Okta = Switch(
- group = SwitchGroup.Identity,
- name = "okta",
- description = "Use Okta for authentication",
- owners = Seq(Owner.withGithub("@guardian/dotcom-platform")),
+ val GoogleOneTapSwitch = Switch(
+ SwitchGroup.Identity,
+ "google-one-tap-switch",
+ "Signing into the Guardian with Google One Tap",
+ owners = Seq(Owner.withEmail("identity.dev@theguardian.com")),
safeState = Off,
sellByDate = never,
exposeClientSide = true,
diff --git a/common/app/conf/switches/JournalismSwitches.scala b/common/app/conf/switches/JournalismSwitches.scala
index 802a06aba9a0..17528ec9d919 100644
--- a/common/app/conf/switches/JournalismSwitches.scala
+++ b/common/app/conf/switches/JournalismSwitches.scala
@@ -70,15 +70,4 @@ trait JournalismSwitches {
exposeClientSide = true,
highImpact = false,
)
-
- val AbsoluteServerTimes = Switch(
- SwitchGroup.Journalism,
- name = "absolute-server-times",
- description = "Force times on the server to be absolute to improve caching",
- owners = Seq(Owner.withEmail("dotcom.platform@theguardian.com")),
- safeState = Off,
- sellByDate = never,
- exposeClientSide = true,
- highImpact = false,
- )
}
diff --git a/common/app/conf/switches/NewslettersSwitches.scala b/common/app/conf/switches/NewslettersSwitches.scala
index b68986b3e04d..3149f118d79e 100644
--- a/common/app/conf/switches/NewslettersSwitches.scala
+++ b/common/app/conf/switches/NewslettersSwitches.scala
@@ -16,6 +16,17 @@ trait NewslettersSwitches {
highImpact = false,
)
+ val ManyNewsletterVisibleRecaptcha = Switch(
+ SwitchGroup.Newsletters,
+ "many-newsletter-visible-recaptcha",
+ "Shows a visible rather than invisible reCAPTCHA when signing up on the All Newsletters page",
+ owners = Seq(Owner.withEmail("newsletters.dev@guardian.co.uk")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = true,
+ highImpact = false,
+ )
+
val NewslettersRemoveConfirmationStep = Switch(
SwitchGroup.Newsletters,
"newsletters-remove-confirmation-step",
diff --git a/common/app/conf/switches/PerformanceSwitches.scala b/common/app/conf/switches/PerformanceSwitches.scala
index 56d2cea8e275..d9fe61144a39 100644
--- a/common/app/conf/switches/PerformanceSwitches.scala
+++ b/common/app/conf/switches/PerformanceSwitches.scala
@@ -226,26 +226,4 @@ trait PerformanceSwitches {
exposeClientSide = true,
highImpact = false,
)
-
- val ShorterSurrogateCacheForRecentArticles = Switch(
- SwitchGroup.Performance,
- "shorter-surrogate-cache-for-recent-articles",
- "Shorten the surrogate cache time for recent articles for load testing",
- owners = Seq(Owner.withEmail("dotcom.platform@theguardian.com")),
- safeState = Off,
- sellByDate = LocalDate.of(2025, 12, 1),
- exposeClientSide = false,
- highImpact = false,
- )
-
- val ShorterSurrogateCacheForOlderArticles = Switch(
- SwitchGroup.Performance,
- "shorter-surrogate-cache-for-older-articles",
- "Shorten the surrogate cache time for older articles for load testing",
- owners = Seq(Owner.withEmail("dotcom.platform@theguardian.com")),
- safeState = Off,
- sellByDate = LocalDate.of(2025, 12, 1),
- exposeClientSide = false,
- highImpact = false,
- )
}
diff --git a/common/app/conf/switches/SwitchboardLifecycle.scala b/common/app/conf/switches/SwitchboardLifecycle.scala
index d24a26cc0954..8cad6a70727a 100644
--- a/common/app/conf/switches/SwitchboardLifecycle.scala
+++ b/common/app/conf/switches/SwitchboardLifecycle.scala
@@ -42,7 +42,7 @@ class SwitchboardLifecycle(appLifecycle: ApplicationLifecycle, jobs: JobSchedule
nextState.get(switch.name) match {
case Some("on") => switch.switchOn()
case Some("off") => switch.switchOff()
- case _ =>
+ case _ =>
log.warn(s"Badly configured switch ${switch.name}, setting to safe state.")
switch.switchToSafeState()
}
diff --git a/common/app/conf/switches/Switches.scala b/common/app/conf/switches/Switches.scala
index a001f1abadd3..739138c29968 100644
--- a/common/app/conf/switches/Switches.scala
+++ b/common/app/conf/switches/Switches.scala
@@ -8,6 +8,8 @@ import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future, Promise}
import java.text.SimpleDateFormat
import java.util.TimeZone
+import java.time.format.DateTimeFormatter
+import java.util.Locale
sealed trait SwitchState
case object On extends SwitchState
@@ -157,10 +159,13 @@ object Switch {
}
def expiryAsUserFriendlyString(switch: Switch): String = {
- val timeFormatter = new SimpleDateFormat("E dd MMM")
- timeFormatter.setTimeZone(TimeZone.getTimeZone("Europe/London"))
+ val zone = ZoneId.of("Europe/London")
+ val dateFormatter = DateTimeFormatter.ofPattern("E dd MMM", Locale.UK)
switch.sellByDate
- .map(d => s"expires ${timeFormatter.format(d)} at 23:59 (London time)")
+ .map { d =>
+ val datePortion = d.atStartOfDay(zone).format(dateFormatter)
+ s"expires $datePortion at 23:59 (London time)"
+ }
.getOrElse("expiry not specified")
}
}
diff --git a/common/app/controllers/EmailSignupController.scala b/common/app/controllers/EmailSignupController.scala
index c459ce5033a3..7f040536bf4f 100644
--- a/common/app/controllers/EmailSignupController.scala
+++ b/common/app/controllers/EmailSignupController.scala
@@ -6,6 +6,7 @@ import common.{GuLogging, ImplicitControllerExecutionContext, LinkTo}
import conf.Configuration
import conf.switches.Switches.{
EmailSignupRecaptcha,
+ ManyNewsletterVisibleRecaptcha,
NewslettersRemoveConfirmationStep,
ValidateEmailSignupRecaptchaTokens,
}
@@ -33,7 +34,7 @@ object emailLandingPage extends StandalonePage {
case class EmailForm(
email: String,
listName: Option[String],
- marketing: Option[String],
+ marketing: Option[Boolean],
referrer: Option[String],
ref: Option[String],
refViewId: Option[String],
@@ -45,7 +46,7 @@ case class EmailForm(
case class EmailFormManyNewsletters(
email: String,
listNames: Seq[String],
- marketing: Option[String],
+ marketing: Option[Boolean],
referrer: Option[String],
ref: Option[String],
refViewId: Option[String],
@@ -65,7 +66,8 @@ class EmailFormService(wsClient: WSClient, emailEmbedAgent: NewsletterSignupAgen
.obj(
"email" -> form.email,
"set-lists" -> List(form.listName),
- "set-consents" -> form.marketing.map(_ => List("similar_guardian_products")),
+ "set-consents" -> form.marketing.filter(_ == true).map(_ => List("similar_guardian_products")),
+ "unset-consents" -> form.marketing.filter(_ == false).map(_ => List("similar_guardian_products")),
)
.fields,
)
@@ -90,7 +92,8 @@ class EmailFormService(wsClient: WSClient, emailEmbedAgent: NewsletterSignupAgen
"set-lists" -> form.listNames,
"refViewId" -> form.refViewId,
"ref" -> form.ref,
- "set-consents" -> form.marketing.map(_ => List("similar_guardian_products")),
+ "set-consents" -> form.marketing.filter(_ == true).map(_ => List("similar_guardian_products")),
+ "unset-consents" -> form.marketing.filter(_ == false).map(_ => List("similar_guardian_products")),
)
.fields,
)
@@ -142,7 +145,7 @@ class EmailSignupController(
mapping(
"email" -> nonEmptyText.verifying(emailAddress),
"listName" -> optional[String](of[String]),
- "marketing" -> optional[String](of[String]),
+ "marketing" -> optional[Boolean](of[Boolean]),
"referrer" -> optional[String](of[String]),
"ref" -> optional[String](of[String]),
"refViewId" -> optional[String](of[String]),
@@ -156,7 +159,7 @@ class EmailSignupController(
mapping(
"email" -> nonEmptyText.verifying(emailAddress),
"listNames" -> seq(of[String]),
- "marketing" -> optional[String](of[String]),
+ "marketing" -> optional[Boolean](of[Boolean]),
"referrer" -> optional[String](of[String]),
"ref" -> optional[String](of[String]),
"refViewId" -> optional[String](of[String]),
@@ -275,7 +278,9 @@ class EmailSignupController(
}
def logNewsletterNotFoundError(newsletterName: String)(implicit request: RequestHeader): Unit = {
- logErrorWithRequestId(s"Newsletter not found: Couldn't find $newsletterName")
+ logInfoWithRequestId(
+ s"The newsletter $newsletterName used in an email sign-up form could not be found by the NewsletterSignupAgent. It may no longer exist or $newsletterName may be an outdated reference number.",
+ )
}
def renderFormFromNameWithParentComponent(
@@ -442,18 +447,22 @@ class EmailSignupController(
}
}
- private def validateCaptcha(googleRecaptchaResponse: Option[String], shouldValidateCaptcha: Boolean)(implicit
+ private def validateCaptcha(
+ googleRecaptchaResponse: Option[String],
+ shouldValidateCaptcha: Boolean,
+ shouldUseVisibleKey: Boolean = false,
+ )(implicit
request: Request[AnyContent],
) = {
if (shouldValidateCaptcha) {
for {
token <- googleRecaptchaResponse match {
case Some(token) => Future.successful(token)
- case None =>
+ case None =>
RecaptchaMissingTokenError.increment()
Future.failed(new IllegalAccessException("reCAPTCHA client token not provided"))
}
- wsResponse <- googleRecaptchaTokenValidationService.submit(token) recoverWith { case e =>
+ wsResponse <- googleRecaptchaTokenValidationService.submit(token, shouldUseVisibleKey) recoverWith { case e =>
RecaptchaAPIUnavailableError.increment()
Future.failed(e)
}
@@ -532,7 +541,11 @@ class EmailSignupController(
)
(for {
- _ <- validateCaptcha(form.googleRecaptchaResponse, ValidateEmailSignupRecaptchaTokens.isSwitchedOn)
+ _ <- validateCaptcha(
+ form.googleRecaptchaResponse,
+ ValidateEmailSignupRecaptchaTokens.isSwitchedOn,
+ shouldUseVisibleKey = ManyNewsletterVisibleRecaptcha.isSwitchedOn,
+ )
result <- buildSubmissionResult(emailFormService.submitWithMany(form), Option.empty[String])
} yield {
result
diff --git a/common/app/controllers/IndexControllerCommon.scala b/common/app/controllers/IndexControllerCommon.scala
index 829aeaec4ddf..e8a2117d69a7 100644
--- a/common/app/controllers/IndexControllerCommon.scala
+++ b/common/app/controllers/IndexControllerCommon.scala
@@ -75,7 +75,10 @@ trait IndexControllerCommon
path match {
// if this is a section tag e.g. football/football
case TagPattern(left, right) if left == right => successful(Cached(60)(redirect(left, request.isRss)))
- case _ =>
+ // This page does not exist on dotcom, and we don't want to make a CAPI request because that
+ // will trigger a CAPI sections query.
+ case "sections" => successful(Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound)))
+ case _ =>
logGoogleBot(request)
(index(Edition(request), path, inferPage(request), request.isRss) map {
// if no content is returned (as often happens with old/expired/migrated microsites) return 404 rather than an empty page
diff --git a/common/app/crosswords/CrosswordPage.scala b/common/app/crosswords/CrosswordPage.scala
index 191be74eeabe..7b3488d76d71 100644
--- a/common/app/crosswords/CrosswordPage.scala
+++ b/common/app/crosswords/CrosswordPage.scala
@@ -59,11 +59,13 @@ class CrosswordSearchPage extends StandalonePage {
"quick-cryptic",
"quiptic",
"genius",
+ "sunday-quick",
"speedy",
"everyman",
"azed",
"weekend",
"special",
+ "mini",
)
def queryParameter(crossType: String): String = {
diff --git a/common/app/dev/DevParametersHttpRequestHandler.scala b/common/app/dev/DevParametersHttpRequestHandler.scala
index 19f122307bc9..faa6979eb6cf 100644
--- a/common/app/dev/DevParametersHttpRequestHandler.scala
+++ b/common/app/dev/DevParametersHttpRequestHandler.scala
@@ -59,6 +59,8 @@ class DevParametersHttpRequestHandler(
"amzn_debug_mode", // set to `1` to enable A9 debugging
"force-braze-message", // JSON encoded representation of "extras" data from Braze
"dcr", // force page to render in DCR
+ "_sp_env", // allow testing of Sourcepoint stage campaign
+ "_sp_geo_override", // allow Sourcepoint geolocation override for testing purposes
)
val commercialParams = Seq(
@@ -76,7 +78,7 @@ class DevParametersHttpRequestHandler(
"dll", // Disable lazy loading of ads
"iasdebug", // IAS troubleshooting
"cmpdebug", // CMP troubleshooting
- "sfdebug", // enable spacefinder visualiser. '1' = inline ads (first pass), '2' = inline ads (second pass), 'carrot' = carrot ads
+ "sfdebug", // enable spacefinder visualiser. '1' = inline ads (first pass), '2' = inline ads (second pass)
"rikerdebug", // enable debug logging for Canadian ad setup managed by the Globe and Mail
"forceSendMetrics", // enable force sending of commercial metrics
"multiSticky", // enable multiple sticky ads in the right column, for the purpose of qualitative testing
diff --git a/common/app/experiments/Experiments.scala b/common/app/experiments/Experiments.scala
index 692a070eed44..b9b3784f34e5 100644
--- a/common/app/experiments/Experiments.scala
+++ b/common/app/experiments/Experiments.scala
@@ -11,40 +11,36 @@ import java.time.LocalDate
object ActiveExperiments extends ExperimentsDefinition {
override val allExperiments: Set[Experiment] =
Set(
- EuropeBetaFront,
+ StarRatingRedesign,
DarkModeWeb,
- DCRFootballMatches,
+ GoogleOneTap,
)
implicit val canCheckExperiment: CanCheckExperiment = new CanCheckExperiment(this)
}
-object EuropeBetaFront
+object StarRatingRedesign
extends Experiment(
- name = "europe-beta-front",
- description = "Allows viewing the beta version of the Europe network front",
- owners = Seq(
- Owner.withGithub("cemms1"),
- Owner.withEmail("project.fairground@theguardian.com"),
- Owner.withEmail("dotcom.platform@theguardian.com"),
- ),
- sellByDate = LocalDate.of(2025, 4, 2),
- participationGroup = Perc50,
+ name = "star-rating-redesign",
+ description = "Enable the refreshed star ratings design",
+ owners = Seq(Owner.withEmail("fronts.and.curation@theguardian.com")),
+ sellByDate = LocalDate.of(2026, 2, 2),
+ participationGroup = Perc0A,
)
-object DarkModeWeb
+object GoogleOneTap
extends Experiment(
- name = "dark-mode-web",
- description = "Enable dark mode on web",
- owners = Seq(Owner.withGithub("jakeii"), Owner.withEmail("dotcom.platform@theguardian.com")),
- sellByDate = LocalDate.of(2025, 4, 30),
- participationGroup = Perc0D,
+ name = "google-one-tap",
+ description = "Signing into the Guardian with Google One Tap",
+ owners = Seq(Owner.withEmail("identity.dev@theguardian.com")),
+ sellByDate = LocalDate.of(2026, 2, 2),
+ participationGroup = Perc0B,
)
-object DCRFootballMatches
+object DarkModeWeb
extends Experiment(
- name = "dcr-football-matches",
- description = "Render football matches lists in DCR",
+ name = "dark-mode-web",
+ description = "Enable dark mode on web",
owners = Seq(Owner.withEmail("dotcom.platform@theguardian.com")),
- sellByDate = LocalDate.of(2025, 4, 10),
- participationGroup = Perc10A,
+ sellByDate = LocalDate.of(2026, 1, 30),
+ participationGroup = Perc0D,
)
diff --git a/common/app/http/CustomPanDomainAuth.scala b/common/app/http/CustomPanDomainAuth.scala
new file mode 100644
index 000000000000..7a22a9c72d00
--- /dev/null
+++ b/common/app/http/CustomPanDomainAuth.scala
@@ -0,0 +1,12 @@
+package http
+
+import com.gu.pandomainauth.model.User
+import play.api.mvc.{RequestHeader, Result}
+
+import scala.concurrent.Future
+
+trait CustomPanDomainAuth {
+ def appliesTo(requestHeader: RequestHeader): Boolean
+
+ def authenticateRequest(request: RequestHeader)(produceResultGivenAuthedUser: User => Future[Result]): Future[Result]
+}
diff --git a/common/app/http/Filters.scala b/common/app/http/Filters.scala
index 5e6e88f306de..1e72e9c56fea 100644
--- a/common/app/http/Filters.scala
+++ b/common/app/http/Filters.scala
@@ -12,8 +12,12 @@ import play.filters.gzip.{GzipFilter, GzipFilterConfig}
import experiments.LookedAtExperiments
import model.Cached.PanicReuseExistingResult
import org.apache.commons.codec.digest.DigestUtils
+import ab.ABTests
+import conf.switches.Switches.{EnableNewServerSideABTestsHeader}
import scala.concurrent.{ExecutionContext, Future}
+import experiments.Experiment
+import experiments.{ActiveExperiments}
class GzipperConfig() extends GzipFilterConfig {
override val shouldGzip: (RequestHeader, Result) => Boolean = (request, result) => {
@@ -105,6 +109,32 @@ class ExperimentsFilter(implicit val mat: Materializer, executionContext: Execut
.map { case (k, v) => k -> v.map(_._2).mkString(",") }
}
+/** AB Testing filter that add the server side ab tests header to the Vary header and sets up AB tests from the request
+ * header.
+ */
+class ABTestingFilter(implicit val mat: Materializer, executionContext: ExecutionContext) extends Filter {
+ private val abTestHeader = "X-GU-Server-AB-Tests"
+
+ override def apply(nextFilter: (RequestHeader) => Future[Result])(request: RequestHeader): Future[Result] = {
+ if (EnableNewServerSideABTestsHeader.isSwitchedOff) {
+ nextFilter(request)
+ } else {
+ val r = ABTests.decorateRequest(request, abTestHeader)
+ nextFilter(r).map { result =>
+ val varyHeaderValues = result.header.headers.get("Vary").toSeq ++ Seq(abTestHeader)
+ val abTestHeaderValue = request.headers.get(abTestHeader).getOrElse("")
+ val responseHeaders =
+ Map(abTestHeader -> abTestHeaderValue, "Vary" -> varyHeaderValues.mkString(",")).filterNot { case (_, v) =>
+ v.isEmpty
+ }.toSeq
+
+ result.withHeaders(responseHeaders: _*)
+
+ }
+ }
+ }
+}
+
class PanicSheddingFilter(implicit val mat: Materializer, executionContext: ExecutionContext) extends Filter {
override def apply(nextFilter: (RequestHeader) => Future[Result])(request: RequestHeader): Future[Result] = {
if (Switches.PanicShedding.isSwitchedOn && request.headers.hasHeader("If-None-Match")) {
@@ -126,6 +156,7 @@ object Filters {
new RequestLoggingFilter,
new PanicSheddingFilter,
new JsonVaryHeadersFilter,
+ new ABTestingFilter,
new ExperimentsFilter,
new Gzipper,
new BackendHeaderFilter(frontendBuildInfo),
diff --git a/common/app/http/GuardianAuthWithExemptions.scala b/common/app/http/GuardianAuthWithExemptions.scala
index 16f849ad9797..9278903f1979 100644
--- a/common/app/http/GuardianAuthWithExemptions.scala
+++ b/common/app/http/GuardianAuthWithExemptions.scala
@@ -1,18 +1,18 @@
package http
-import com.amazonaws.regions.Regions
-import com.amazonaws.services.s3.AmazonS3
import com.gu.pandomainauth.action.AuthActions
-import com.gu.pandomainauth.model.AuthenticatedUser
+import com.gu.pandomainauth.model.{AuthenticatedUser, User}
import com.gu.pandomainauth.{PanDomain, PanDomainAuthSettingsRefresher, S3BucketLoader}
import com.gu.permissions.{PermissionDefinition, PermissionsConfig, PermissionsProvider}
import common.Environment.stage
-import conf.Configuration.aws.mandatoryCredentials
import model.ApplicationContext
import org.apache.pekko.stream.Materializer
import play.api.Mode
import play.api.libs.ws.WSClient
import play.api.mvc._
+import software.amazon.awssdk.regions.Region.EU_WEST_1
+import software.amazon.awssdk.services.s3.S3Client
+import utils.AWSv2
import java.net.URL
import scala.concurrent.Future
@@ -22,10 +22,11 @@ class GuardianAuthWithExemptions(
override val wsClient: WSClient,
toolsDomainPrefix: String,
oauthCallbackPath: String,
- s3Client: AmazonS3,
+ s3Client: S3Client,
system: String,
extraDoNotAuthenticatePathPrefixes: Seq[String],
requiredEditorialPermissionName: String,
+ customPandaAuth: Option[CustomPanDomainAuth] = None,
)(implicit
val mat: Materializer,
context: ApplicationContext,
@@ -37,8 +38,8 @@ class GuardianAuthWithExemptions(
private val permissions: PermissionsProvider = PermissionsProvider(
PermissionsConfig(
stage = if (stage == "PROD") "PROD" else "CODE",
- region = Regions.EU_WEST_1.getName,
- awsCredentials = mandatoryCredentials,
+ region = EU_WEST_1.toString,
+ awsCredentials = AWSv2.credentials,
),
)
@@ -57,7 +58,7 @@ class GuardianAuthWithExemptions(
override lazy val panDomainSettings = PanDomainAuthSettingsRefresher(
domain = toolsDomainSuffix,
system,
- S3BucketLoader.forAwsSdkV1(s3Client, "pan-domain-auth-settings"),
+ S3BucketLoader.forAwsSdkV2(s3Client, "pan-domain-auth-settings"),
)
override def authCallbackUrl = s"https://$toolsDomainPrefix.$toolsDomainSuffix$oauthCallbackPath"
@@ -91,22 +92,24 @@ class GuardianAuthWithExemptions(
) ++ extraDoNotAuthenticatePathPrefixes).exists(request.path.startsWith)
def apply(nextFilter: RequestHeader => Future[Result])(request: RequestHeader): Future[Result] = {
- if (doNotAuthenticate(request)) {
- nextFilter(request)
- } else {
- AuthAction.authenticateRequest(request) { user =>
- if (permissions.hasPermission(requiredPermission, user.email)) {
- nextFilter(request)
- } else {
- Future.successful(
- Results.Forbidden(
- s"You do not have permission to access $system. " +
- s"You should contact Central Production to request '$requiredEditorialPermissionName' permission.",
- ),
- )
- }
+ def authoriseUser(user: User): Future[Result] =
+ if (permissions.hasPermission(requiredPermission, user.email)) {
+ nextFilter(request)
+ } else {
+ Future.successful(
+ Results.Forbidden(
+ s"You do not have permission to access $system." +
+ s"You should contact Central Production to request '$requiredEditorialPermissionName' permission.",
+ ),
+ )
}
- }
+
+ if (doNotAuthenticate(request)) nextFilter(request)
+ else
+ customPandaAuth
+ .filter(_.appliesTo(request))
+ .map(_.authenticateRequest(request)(authoriseUser))
+ .getOrElse(AuthAction.authenticateRequest(request)(authoriseUser))
}
}
}
diff --git a/common/app/http/RequestLoggingFilter.scala b/common/app/http/RequestLoggingFilter.scala
index c66e9454d556..a8e4cca2398a 100644
--- a/common/app/http/RequestLoggingFilter.scala
+++ b/common/app/http/RequestLoggingFilter.scala
@@ -21,9 +21,9 @@ class RequestLoggingFilter(implicit val mat: Materializer, executionContext: Exe
val additionalInfo =
response.header.headers.get("X-Accel-Redirect") match {
case Some(internalRedirect) => s" - internal redirect to $internalRedirect"
- case None =>
+ case None =>
response.header.status match {
- case 304 => " - 304 Not Modified"
+ case 304 => " - 304 Not Modified"
case status if (status / 100) == 3 =>
s" - external redirect to ${response.header.headers.getOrElse("Location", "[location not found]")}"
case _ => ""
diff --git a/common/app/implicits/Requests.scala b/common/app/implicits/Requests.scala
index 5f10adeadf4c..94b3a83a92a4 100644
--- a/common/app/implicits/Requests.scala
+++ b/common/app/implicits/Requests.scala
@@ -1,14 +1,14 @@
package implicits
import com.gu.facia.client.models.{
+ AUNewSouthWalesTerritory,
+ AUQueenslandTerritory,
+ AUVictoriaTerritory,
EU27Territory,
NZTerritory,
TargetedTerritory,
USEastCoastTerritory,
USWestCoastTerritory,
- AUVictoriaTerritory,
- AUQueenslandTerritory,
- AUNewSouthWalesTerritory,
}
import conf.Configuration
import play.api.mvc.RequestHeader
@@ -62,6 +62,12 @@ trait Requests {
lazy val isEmailJson: Boolean = r.path.endsWith(EMAIL_JSON_SUFFIX)
+ lazy val isInteractiveRedirect: Boolean = r.path.startsWith("/interactive/")
+
+ private val desktopAuthPathPrefix = "/desktop-auth"
+
+ lazy val isDesktopAuthRequest: Boolean = r.path.startsWith(desktopAuthPathPrefix)
+
lazy val isEmailTxt: Boolean = r.path.endsWith(EMAIL_TXT_SUFFIX)
lazy val isLazyLoad: Boolean =
@@ -82,10 +88,12 @@ trait Requests {
lazy val isHeadlineText: Boolean =
r.getQueryString("format").contains("email-headline") || r.path.endsWith(HEADLINE_SUFFIX)
- lazy val isModified = isJson || isRss || isEmail || isHeadlineText
+ lazy val isModified = isJson || isRss || isEmail || isHeadlineText || isDesktopAuthRequest
lazy val pathWithoutModifiers: String =
if (isEmail) r.path.stripSuffix(EMAIL_SUFFIX)
+ else if (isInteractiveRedirect) r.path.stripPrefix("/interactive")
+ else if (isDesktopAuthRequest) r.path.stripPrefix(desktopAuthPathPrefix)
else r.path.stripSuffix("/all")
lazy val hasParameters: Boolean = r.queryString.nonEmpty
diff --git a/common/app/layout/ContainerCommercialOptions.scala b/common/app/layout/ContainerCommercialOptions.scala
index 792bba29eec1..533503632839 100644
--- a/common/app/layout/ContainerCommercialOptions.scala
+++ b/common/app/layout/ContainerCommercialOptions.scala
@@ -1,3 +1,3 @@
package layout
-case class ContainerCommercialOptions(omitMPU: Boolean, adFree: Boolean)
+case class ContainerCommercialOptions(adFree: Boolean)
diff --git a/common/app/layout/ContainerLayout.scala b/common/app/layout/ContainerLayout.scala
index 4cf61d9aa512..ced946fdfd9a 100644
--- a/common/app/layout/ContainerLayout.scala
+++ b/common/app/layout/ContainerLayout.scala
@@ -37,7 +37,7 @@ object ContainerLayout {
val (slices, showMore, finalContext) = sliceDefinitions.foldLeft(
(Seq.empty[SliceWithCards], indexedTrails, initialContext),
) {
- case ((slicesSoFar, Nil, context), _) => (slicesSoFar, Nil, context)
+ case ((slicesSoFar, Nil, context), _) => (slicesSoFar, Nil, context)
case ((slicesSoFar, trailsForUse, context), sliceDefinition) =>
val (slice, remainingTrails, newContext) = SliceWithCards.fromItems(
trailsForUse,
@@ -115,7 +115,7 @@ object ContainerLayout {
accumulation: Vector[SliceWithCards] = Vector.empty,
): Seq[SliceWithCards] = {
slices match {
- case Nil => accumulation
+ case Nil => accumulation
case (slice, numToConsume) :: remainingSlices =>
val (blobsConsumed, blobsUnconsumed) = blobs.splitAt(numToConsume)
slicesWithCards(
diff --git a/common/app/layout/DisplaySettings.scala b/common/app/layout/DisplaySettings.scala
index 88d8b8e32cf9..f68940e7e62b 100644
--- a/common/app/layout/DisplaySettings.scala
+++ b/common/app/layout/DisplaySettings.scala
@@ -6,6 +6,7 @@ import model.pressed._
case class DisplaySettings(
isBoosted: Boolean,
boostLevel: Option[BoostLevel],
+ isImmersive: Option[Boolean],
showBoostedHeadline: Boolean,
showQuotedHeadline: Boolean,
imageHide: Boolean,
@@ -17,6 +18,7 @@ object DisplaySettings {
DisplaySettings(
faciaContent.display.isBoosted,
faciaContent.display.boostLevel,
+ faciaContent.display.isImmersive,
faciaContent.display.showBoostedHeadline,
faciaContent.display.showQuotedHeadline,
faciaContent.display.imageHide,
diff --git a/common/app/layout/FaciaContainer.scala b/common/app/layout/FaciaContainer.scala
index 9454e4594979..78daa29b19f5 100644
--- a/common/app/layout/FaciaContainer.scala
+++ b/common/app/layout/FaciaContainer.scala
@@ -163,7 +163,6 @@ object FaciaContainer {
collectionEssentials: CollectionEssentials,
containerLayout: Option[ContainerLayout],
componentId: Option[String],
- omitMPU: Boolean = false,
adFree: Boolean = false,
targetedTerritory: Option[TargetedTerritory] = None,
): FaciaContainer =
@@ -180,9 +179,9 @@ object FaciaContainer {
config.config.showLatestUpdate,
// popular containers should never be sponsored
container match {
- case MostPopular => ContainerCommercialOptions(omitMPU = omitMPU, adFree = adFree)
- case _ if !adFree => ContainerCommercialOptions(omitMPU = false, adFree = false)
- case _ => ContainerCommercialOptions(omitMPU = false, adFree = adFree)
+ case MostPopular => ContainerCommercialOptions(adFree = adFree)
+ case _ if !adFree => ContainerCommercialOptions(adFree = false)
+ case _ => ContainerCommercialOptions(adFree = adFree)
},
config.config.description.map(DescriptionMetaHeader),
customClasses = config.config.metadata.flatMap(paletteClasses(container, _)),
diff --git a/common/app/layout/Front.scala b/common/app/layout/Front.scala
index b9d1af262064..9e5c34b01516 100644
--- a/common/app/layout/Front.scala
+++ b/common/app/layout/Front.scala
@@ -39,7 +39,7 @@ object Front {
accumulation: Vector[FaciaContainer] = Vector.empty,
): Seq[FaciaContainer] = {
allConfigs.toList match {
- case Nil => accumulation
+ case Nil => accumulation
case ((config, collection), container) :: remainingConfigs =>
val newItems = collection.items.distinctBy(_.header.url)
val layoutMaybe = ContainerLayout.fromContainer(container, context, config, newItems, hasMore = false)
@@ -77,10 +77,9 @@ object Front {
): Seq[FaciaContainer] = {
collections match {
- case Nil => accumulation
+ case Nil => accumulation
case pressedCollection :: remainingPressedCollections =>
- val omitMPU: Boolean = pressedPage.metadata.omitMPUsFromContainers(edition)
- val container: Container = Container.fromPressedCollection(pressedCollection, omitMPU, adFree)
+ val container: Container = Container.fromPressedCollection(pressedCollection, adFree)
val newItems = pressedCollection.distinct
val collectionEssentials = CollectionEssentials.fromPressedCollection(pressedCollection)
@@ -101,7 +100,6 @@ object Front {
collectionEssentials.copy(items = newItems),
containerLayoutMaybe.map(_._1),
None,
- omitMPU = if (containerLayoutMaybe.isDefined) false else omitMPU,
adFree = adFree,
targetedTerritory = pressedCollection.targetedTerritory,
)
diff --git a/common/app/layout/SliceWithCards.scala b/common/app/layout/SliceWithCards.scala
index f7dc9f0e12e0..d82be3e20c72 100644
--- a/common/app/layout/SliceWithCards.scala
+++ b/common/app/layout/SliceWithCards.scala
@@ -40,7 +40,7 @@ object SliceWithCards {
): Seq[ColumnAndCards] = {
columns match {
- case Nil => accumulation
+ case Nil => accumulation
case column :: remainingColumns =>
val (itemsForColumn, itemsNotConsumed) = items splitAt column.numItems
diff --git a/common/app/layout/slices/Container.scala b/common/app/layout/slices/Container.scala
index 15e211c92559..971eaafe4838 100644
--- a/common/app/layout/slices/Container.scala
+++ b/common/app/layout/slices/Container.scala
@@ -18,9 +18,6 @@ case class Email(get: EmailLayout) extends Container
case object NavList extends Container
case object NavMediaList extends Container
case object MostPopular extends Container
-case object Video extends Container
-case object VerticalVideo extends Container
-
object Container extends GuLogging {
/** This is THE top level resolver for containers */
@@ -29,9 +26,6 @@ object Container extends GuLogging {
("dynamic/fast", Dynamic(DynamicFast)),
("dynamic/slow", Dynamic(DynamicSlow)),
("dynamic/package", Dynamic(DynamicPackage)),
- ("dynamic/slow-mpu", Dynamic(DynamicSlowMPU(omitMPU = false, adFree = adFree))),
- ("fixed/video", Video),
- ("fixed/video/vertical", VerticalVideo),
("nav/list", NavList),
("nav/media-list", NavMediaList),
("news/most-popular", MostPopular),
@@ -57,7 +51,14 @@ object Container extends GuLogging {
.map(Front.itemsVisible)
case Fixed(fixedContainer) => Some(Front.itemsVisible(fixedContainer.slices))
case Email(_) => Some(EmailContentContainer.storiesCount(collectionConfig))
- case _ => None
+ // scrollable feature containers are capped at 3 stories
+ case _ if collectionConfig.collectionType == "scrollable/feature" => Some(3)
+ // scrollable small and medium containers are capped at 4 stories
+ case _ if collectionConfig.collectionType == "scrollable/small" => Some(4)
+ case _ if collectionConfig.collectionType == "scrollable/medium" => Some(4)
+ // scrollable highlights containers are capped at 6 stories
+ case _ if collectionConfig.collectionType == "scrollable/highlights" => Some(6)
+ case _ => None
}
}
@@ -78,13 +79,11 @@ object Container extends GuLogging {
}
}
- def fromPressedCollection(pressedCollection: PressedCollection, omitMPU: Boolean, adFree: Boolean): Container = {
+ def fromPressedCollection(pressedCollection: PressedCollection, adFree: Boolean): Container = {
val container = resolve(pressedCollection.collectionType, adFree)
container match {
- case Fixed(definition) if omitMPU || adFree =>
+ case Fixed(definition) if adFree =>
Fixed(definition.copy(slices = definition.slicesWithoutMPU))
- case Dynamic(DynamicSlowMPU(_, _)) if omitMPU || adFree =>
- Dynamic(DynamicSlowMPU(omitMPU, adFree))
case _ => container
}
}
diff --git a/common/app/layout/slices/DynamicContainers.scala b/common/app/layout/slices/DynamicContainers.scala
index ca1293f9bb72..32a3e923d5df 100644
--- a/common/app/layout/slices/DynamicContainers.scala
+++ b/common/app/layout/slices/DynamicContainers.scala
@@ -7,7 +7,6 @@ object DynamicContainers {
("dynamic/fast", DynamicFast),
("dynamic/slow", DynamicSlow),
("dynamic/package", DynamicPackage),
- ("dynamic/slow-mpu", DynamicSlowMPU(omitMPU = false, adFree = false)),
)
def apply(collectionType: Option[String], items: Seq[PressedContent]): Option[ContainerDefinition] = {
diff --git a/common/app/layout/slices/DynamicSlowMpu.scala b/common/app/layout/slices/DynamicSlowMpu.scala
deleted file mode 100644
index 89c05b7fcde9..000000000000
--- a/common/app/layout/slices/DynamicSlowMpu.scala
+++ /dev/null
@@ -1,36 +0,0 @@
-package layout.slices
-
-case class DynamicSlowMPU(omitMPU: Boolean, adFree: Boolean) extends DynamicContainer {
- override protected def optionalFirstSlice(stories: Seq[Story]): Option[(Slice, Seq[Story])] = {
- val BigsAndStandards(bigs, _) = bigsAndStandards(stories)
- val isFirstBoosted = stories.headOption.exists(_.isBoosted)
- val isSecondBoosted = stories.lift(1).exists(_.isBoosted)
-
- if (bigs.length == 3) {
- Some((HalfQQ, stories.drop(3)))
- } else if (bigs.length == 2) {
- Some(
- if (isFirstBoosted) ThreeQuarterQuarter else if (isSecondBoosted) QuarterThreeQuarter else HalfHalf,
- stories.drop(2),
- )
- } else if (bigs.length == 1) {
- Some(if (isFirstBoosted) ThreeQuarterQuarter else HalfHalf, stories.drop(2))
- } else if (bigs.isEmpty) {
- None
- } else {
- Some(QuarterQuarterQuarterQuarter, stories.drop(4))
- }
- }
-
- override protected def standardSlices(stories: Seq[Story], firstSlice: Option[Slice]): Seq[Slice] =
- firstSlice match {
- case Some(_) if omitMPU =>
- if (stories.size > 3) Seq(Hl3QuarterQuarter) else Seq(HalfQQ)
- case Some(_) if adFree =>
- if (stories.size > 3) Seq(Hl3QuarterQuarter) else Seq(TlTlTl)
- case Some(_) => Seq(Hl3Mpu)
- case None if omitMPU || adFree =>
- if (stories.size > 3) Seq(QuarterQuarterQuarterQuarter) else Seq(HalfHalf)
- case None => Seq(TTlMpu)
- }
-}
diff --git a/common/app/layout/slices/Slice.scala b/common/app/layout/slices/Slice.scala
index b08c010fbb25..7a0bc6d3fe12 100755
--- a/common/app/layout/slices/Slice.scala
+++ b/common/app/layout/slices/Slice.scala
@@ -1150,7 +1150,7 @@ case object Highlights extends Slice {
case object ScrollableSmall extends Slice {
val layout = SliceLayout(
cssClassName = "scrollable-small",
- columns = Seq.fill(8)(
+ columns = Seq.fill(4)(
SingleItem(
colSpan = 1,
ItemClasses(
@@ -1165,7 +1165,7 @@ case object ScrollableSmall extends Slice {
case object ScrollableMedium extends Slice {
val layout = SliceLayout(
cssClassName = "scrollable-medium",
- columns = Seq.fill(6)(
+ columns = Seq.fill(4)(
SingleItem(
colSpan = 1,
ItemClasses(
diff --git a/common/app/metrics/FrontendMetrics.scala b/common/app/metrics/FrontendMetrics.scala
index 70f24ef52ef5..4ab502ff9c61 100644
--- a/common/app/metrics/FrontendMetrics.scala
+++ b/common/app/metrics/FrontendMetrics.scala
@@ -1,9 +1,9 @@
package metrics
-import com.amazonaws.services.cloudwatch.model.StandardUnit
import common.{Box, StopWatch}
import model.diagnostics.CloudWatch
import org.joda.time.DateTime
+import software.amazon.awssdk.services.cloudwatch.model.StandardUnit
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.atomic.AtomicLong
@@ -37,37 +37,16 @@ case class SimpleDataPoint(value: Double, sampleTime: DateTime) extends DataPoin
}
final case class SimpleMetric(override val name: String, datapoint: SimpleDataPoint) extends FrontendMetric {
- override val metricUnit: StandardUnit = StandardUnit.Count
+ override val metricUnit: StandardUnit = StandardUnit.COUNT
override val getAndResetDataPoints: List[DataPoint] = List(datapoint)
override val isEmpty = false
}
-// MetricUploader is a class to allow basic putting of metrics. Why does it exist? Because if we provide
-// access to cloudwatch directly, then we start to measure everything, and never remove unused metrics.
-// Also, MetricUploader will upload in batches.
-final case class MetricUploader(namespace: String) {
-
- private val datapoints: Box[List[SimpleMetric]] = Box(List.empty)
-
- def put(metrics: Map[String, Double]): Unit = {
- val timedMetrics = metrics.map { case (key, value) =>
- SimpleMetric(name = key, SimpleDataPoint(value, DateTime.now))
- }
- datapoints.send(_ ++ timedMetrics)
- }
-
- def upload(): Unit = {
- val points = datapoints.get()
- datapoints.alter(_.diff(points))
- CloudWatch.putMetrics(namespace, points, List.empty)
- }
-}
-
case class TimingDataPoint(value: Double, time: Option[DateTime] = None) extends DataPoint
final case class TimingMetric(override val name: String, description: String) extends FrontendMetric {
- override val metricUnit: StandardUnit = StandardUnit.Milliseconds
+ override val metricUnit: StandardUnit = StandardUnit.MILLISECONDS
private val timeInMillis = new AtomicLong()
private val currentCount = new AtomicLong()
@@ -91,7 +70,7 @@ case class GaugeDataPoint(value: Double, time: Option[DateTime] = None) extends
final case class GaugeMetric(
override val name: String,
description: String,
- override val metricUnit: StandardUnit = StandardUnit.Megabytes,
+ override val metricUnit: StandardUnit = StandardUnit.MEGABYTES,
get: () => Double,
) extends FrontendMetric {
@@ -103,7 +82,7 @@ case class CountDataPoint(value: Double, time: Option[DateTime] = None) extends
final case class CountMetric(override val name: String, description: String) extends FrontendMetric {
private val count: AtomicLong = new AtomicLong(0L)
- override val metricUnit = StandardUnit.Count
+ override val metricUnit = StandardUnit.COUNT
override def getAndResetDataPoints: List[DataPoint] = List(CountDataPoint(count.getAndSet(0L).toDouble))
diff --git a/common/app/model/Cached.scala b/common/app/model/Cached.scala
index ab4c826a59e7..35b493fe3119 100644
--- a/common/app/model/Cached.scala
+++ b/common/app/model/Cached.scala
@@ -1,8 +1,6 @@
package model
import conf.switches.Switches.LongCacheSwitch
-import conf.switches.Switches.ShorterSurrogateCacheForOlderArticles
-import conf.switches.Switches.ShorterSurrogateCacheForRecentArticles
import org.joda.time.DateTime
import play.api.http.Writeable
import play.api.mvc._
@@ -19,7 +17,7 @@ object CacheTime {
object Default extends CacheTime(60)
object LiveBlogActive extends CacheTime(5, Some(60))
- def RecentlyUpdated = CacheTime(60, if (ShorterSurrogateCacheForRecentArticles.isSwitchedOn) Some(30) else None)
+ def RecentlyUpdated = CacheTime(60, None)
// There is lambda which invalidates the cache on press events, so the facia cache time can be high.
object Facia extends CacheTime(60, Some(900))
object Crosswords extends CacheTime(60, Some(900))
@@ -29,9 +27,11 @@ object CacheTime {
object DiscussionDefault extends CacheTime(60)
object DiscussionClosed extends CacheTime(60, Some(longCacheTime))
object Football extends CacheTime(10)
- private def oldArticleCacheTime = if (ShorterSurrogateCacheForOlderArticles.isSwitchedOn) 60 else longCacheTime
- def LastDayUpdated = CacheTime(60, Some(oldArticleCacheTime))
- def NotRecentlyUpdated = CacheTime(60, Some(oldArticleCacheTime))
+ object FootballMatch extends CacheTime(30)
+ object Cricket extends CacheTime(60)
+ object FootballTables extends CacheTime(60)
+ def LastDayUpdated = CacheTime(60, Some(longCacheTime))
+ def NotRecentlyUpdated = CacheTime(60, Some(longCacheTime))
}
object Cached extends implicits.Dates {
diff --git a/common/app/model/CardStylePicker.scala b/common/app/model/CardStylePicker.scala
index cb3922d7a68d..6592b63a8ccb 100644
--- a/common/app/model/CardStylePicker.scala
+++ b/common/app/model/CardStylePicker.scala
@@ -37,7 +37,7 @@ object CardStylePicker {
def getCampaignType(campaigns: Seq[Campaign]): CampaignType = {
campaigns match {
- case Nil => OtherCampaign
+ case Nil => OtherCampaign
case head :: tail =>
head.fields match {
case ReportFields(campaignId) =>
diff --git a/common/app/model/FaciaDisplayElement.scala b/common/app/model/FaciaDisplayElement.scala
index f343327ef3f3..4ede49cf7fab 100644
--- a/common/app/model/FaciaDisplayElement.scala
+++ b/common/app/model/FaciaDisplayElement.scala
@@ -13,7 +13,7 @@ object FaciaDisplayElement {
itemClasses: ItemClasses,
): Option[FaciaDisplayElement] = {
faciaContent.mainVideo match {
- case Some(videoElement) if faciaContent.properties.showMainVideo =>
+ case Some(videoElement) if faciaContent.properties.mediaSelect.exists(_.showMainVideo) =>
Some(
InlineVideo(
videoElement,
@@ -23,9 +23,12 @@ object FaciaDisplayElement {
)
case _ if faciaContent.properties.isCrossword && Switches.CrosswordSvgThumbnailsSwitch.isSwitchedOn =>
faciaContent.properties.maybeContentId map CrosswordSvg
- case _ if faciaContent.properties.imageSlideshowReplace && itemClasses.canShowSlideshow =>
+ case _ if faciaContent.properties.mediaSelect.exists(_.imageSlideshowReplace) && itemClasses.canShowSlideshow =>
InlineSlideshow.fromFaciaContent(faciaContent)
- case _ if faciaContent.properties.showMainVideo && faciaContent.mainYouTubeMediaAtom.isDefined =>
+ case _
+ if faciaContent.properties.mediaSelect.exists(
+ _.showMainVideo,
+ ) && faciaContent.mainYouTubeMediaAtom.isDefined =>
Some(InlineYouTubeMediaAtom(faciaContent.mainYouTubeMediaAtom.get, faciaContent.trailPicture))
case _ => InlineImage.fromFaciaContent(faciaContent)
}
diff --git a/common/app/model/Formats.scala b/common/app/model/Formats.scala
index aec269f60252..542319bb14b3 100644
--- a/common/app/model/Formats.scala
+++ b/common/app/model/Formats.scala
@@ -255,6 +255,7 @@ object PressedContentFormat {
implicit val imageMediaFormat: OFormat[ImageMedia] = Json.format[ImageMedia]
implicit val videoMediaFormat: OFormat[VideoMedia] = Json.format[VideoMedia]
implicit val videoElementFormat: OFormat[VideoElement] = Json.format[VideoElement]
+ implicit val assetDimensionsFormat: OFormat[AssetDimensions] = Json.format[AssetDimensions]
implicit val mediaAssetFormat: OFormat[MediaAsset] = Json.format[MediaAsset]
implicit val mediaAtomFormat: OFormat[MediaAtom] = Json.format[MediaAtom]
implicit val mediaTypeFormat: MediaTypeFormat.type = MediaTypeFormat
@@ -272,6 +273,7 @@ object PressedContentFormat {
implicit val pressedMetadata: OFormat[PressedMetadata] = Json.format[PressedMetadata]
implicit val pressedElements: OFormat[PressedElements] = Json.format[PressedElements]
implicit val pressedStory: OFormat[PressedStory] = Json.format[PressedStory]
+ implicit val mediaSelectFormat: OFormat[MediaSelect] = Json.format[MediaSelect]
implicit val pressedPropertiesFormat: OFormat[PressedProperties] = Json.format[PressedProperties]
implicit val enrichedContentFormat: OFormat[EnrichedContent] = Json.format[EnrichedContent]
@@ -311,11 +313,11 @@ object ItemKickerFormat {
def writes(itemKicker: ItemKicker): JsObject =
itemKicker match {
- case BreakingNewsKicker => JsObject(Seq("type" -> JsString("BreakingNewsKicker")))
- case LiveKicker => JsObject(Seq("type" -> JsString("LiveKicker")))
- case AnalysisKicker => JsObject(Seq("type" -> JsString("AnalysisKicker")))
- case ReviewKicker => JsObject(Seq("type" -> JsString("ReviewKicker")))
- case CartoonKicker => JsObject(Seq("type" -> JsString("CartoonKicker")))
+ case BreakingNewsKicker => JsObject(Seq("type" -> JsString("BreakingNewsKicker")))
+ case LiveKicker => JsObject(Seq("type" -> JsString("LiveKicker")))
+ case AnalysisKicker => JsObject(Seq("type" -> JsString("AnalysisKicker")))
+ case ReviewKicker => JsObject(Seq("type" -> JsString("ReviewKicker")))
+ case CartoonKicker => JsObject(Seq("type" -> JsString("CartoonKicker")))
case podcastKicker: PodcastKicker =>
JsObject(
Seq("type" -> JsString("PodcastKicker"), "series" -> Json.toJson(podcastKicker)(podcastKickerFormat)),
diff --git a/common/app/model/IpsosTags.scala b/common/app/model/IpsosTags.scala
index f7f0523c7a51..470c9e833763 100644
--- a/common/app/model/IpsosTags.scala
+++ b/common/app/model/IpsosTags.scala
@@ -30,7 +30,7 @@ object IpsosTags {
"au/environment" -> "environment",
"fashion" -> "fashion",
"au/lifeandstyle/fashion" -> "fashion",
- "fashion/beauty" -> "fashion",
+ "fashion/beauty" -> "beauty",
"uk/film" -> "film", /* There is no US film tag - should these map to film? */
"film" -> "film",
"au/film" -> "film",
@@ -52,9 +52,8 @@ object IpsosTags {
"lifeandstyle/love-and-sex" -> "lifeandstyle",
"lifeandstyle/women" -> "lifeandstyle",
"lifeandstyle/men" -> "lifeandstyle",
- "lifeandstyle/home-and-garden" -> "lifeandstyle",
+ "lifeandstyle/home-and-garden" -> "homeandgarden",
"us/lifeandstyle" -> "lifeandstyle",
- "lifeandstyle/home-and-garden" -> "lifeandstyle",
"au/media" -> "media",
"uk/media" -> "media", /* There is no US media tag - should these map to media? */
"membership" -> "membership",
@@ -97,7 +96,8 @@ object IpsosTags {
"teacher-network" -> "teachernetwork",
"uk/technology" -> "technology", /* There is no US technology tag - should these map to technology? */
"au/technology" -> "technology",
- "technology" -> "technology", /* Default for technology (including motoring) articles */
+ "technology" -> "technology", /* Default for technology articles */
+ "technology/motoring" -> "cars",
"thefilter" -> "thefilter",
"uk/thefilter" -> "thefilter",
"the-guardian-foundation" -> "foundation",
diff --git a/common/app/model/LiveBlogCurrentPage.scala b/common/app/model/LiveBlogCurrentPage.scala
index 11c9968840d9..88581187834a 100644
--- a/common/app/model/LiveBlogCurrentPage.scala
+++ b/common/app/model/LiveBlogCurrentPage.scala
@@ -20,7 +20,7 @@ object LiveBlogCurrentPage {
filterKeyEvents: Boolean,
): Option[LiveBlogCurrentPage] = {
range match {
- case CanonicalLiveBlog => firstPage(pageSize, blocks, filterKeyEvents)
+ case CanonicalLiveBlog => firstPage(pageSize, blocks, filterKeyEvents)
case PageWithBlock(isRequestedBlock) =>
findPageWithBlock(pageSize, blocks.body, isRequestedBlock, filterKeyEvents)
case SinceBlockId(blockId) => updates(blocks, SinceBlockId(blockId), filterKeyEvents)
diff --git a/common/app/model/PressedDisplaySettings.scala b/common/app/model/PressedDisplaySettings.scala
index 1644b2ceda50..46b893632354 100644
--- a/common/app/model/PressedDisplaySettings.scala
+++ b/common/app/model/PressedDisplaySettings.scala
@@ -6,6 +6,7 @@ import com.gu.facia.api.{models => fapi}
final case class PressedDisplaySettings(
isBoosted: Boolean,
boostLevel: Option[BoostLevel],
+ isImmersive: Option[Boolean],
showBoostedHeadline: Boolean,
showQuotedHeadline: Boolean,
imageHide: Boolean,
@@ -20,6 +21,7 @@ object PressedDisplaySettings {
imageHide = shouldSuppressImages || contentProperties.imageHide,
isBoosted = FaciaContentUtils.isBoosted(content),
boostLevel = Some(FaciaContentUtils.boostLevel(content)),
+ isImmersive = Some(FaciaContentUtils.isImmersive(content)),
showBoostedHeadline = FaciaContentUtils.showBoostedHeadline(content),
showQuotedHeadline = FaciaContentUtils.showQuotedHeadline(content),
showLivePlayable = FaciaContentUtils.showLivePlayable(content),
diff --git a/common/app/model/PressedPage.scala b/common/app/model/PressedPage.scala
index 81d66dfe4b16..1aef2ab3b5e6 100644
--- a/common/app/model/PressedPage.scala
+++ b/common/app/model/PressedPage.scala
@@ -1,7 +1,6 @@
package model
import com.gu.commercial.branding.Branding
-import com.gu.facia.api.models._
import common.Edition
import conf.Configuration
import contentapi.Paths
diff --git a/common/app/model/PressedProperties.scala b/common/app/model/PressedProperties.scala
index d5c331fced70..66766dc6c38d 100644
--- a/common/app/model/PressedProperties.scala
+++ b/common/app/model/PressedProperties.scala
@@ -2,15 +2,20 @@ package model.pressed
import com.gu.facia.api.utils.FaciaContentUtils
import com.gu.facia.api.{models => fapi, utils => fapiutils}
-import common.{Edition}
+import common.Edition
import common.commercial.EditionBranding
+case class MediaSelect(
+ showMainVideo: Boolean,
+ imageSlideshowReplace: Boolean,
+ videoReplace: Boolean,
+)
+
final case class PressedProperties(
isBreaking: Boolean,
- showMainVideo: Boolean,
+ mediaSelect: Option[MediaSelect],
showKickerTag: Boolean,
showByline: Boolean,
- imageSlideshowReplace: Boolean,
maybeContent: Option[PressedStory],
maybeContentId: Option[String],
isLiveBlog: Boolean,
@@ -40,10 +45,15 @@ object PressedProperties {
PressedProperties(
isBreaking = contentProperties.isBreaking,
- showMainVideo = contentProperties.showMainVideo,
+ mediaSelect = Some(
+ MediaSelect(
+ showMainVideo = contentProperties.showMainVideo,
+ imageSlideshowReplace = contentProperties.imageSlideshowReplace,
+ videoReplace = contentProperties.videoReplace,
+ ),
+ ),
showKickerTag = contentProperties.showKickerTag,
showByline = contentProperties.showByline,
- imageSlideshowReplace = contentProperties.imageSlideshowReplace,
maybeContent = capiContent.map(PressedStory(_)),
maybeContentId = FaciaContentUtils.maybeContentId(content),
isLiveBlog = FaciaContentUtils.isLiveBlog(content),
diff --git a/common/app/model/SupportedUrl.scala b/common/app/model/SupportedUrl.scala
index 4560b861cca3..f76db7506fcf 100644
--- a/common/app/model/SupportedUrl.scala
+++ b/common/app/model/SupportedUrl.scala
@@ -23,7 +23,7 @@ object SupportedUrl {
.map(_.replaceFirst("^https?://www.theguardian.com/", ""))
.orElse(supportingCuratedContent.properties.href)
.getOrElse(supportingCuratedContent.card.id)}"
- case linkSnap: LinkSnap => linkSnap.properties.href.getOrElse(linkSnap.card.id)
+ case linkSnap: LinkSnap => linkSnap.properties.href.getOrElse(linkSnap.card.id)
case latestSnap: LatestSnap =>
latestSnap.properties.maybeContent
.map(content => s"/${content.metadata.id}")
diff --git a/common/app/model/content.scala b/common/app/model/content.scala
index d73f3d011597..648fd015a7b3 100644
--- a/common/app/model/content.scala
+++ b/common/app/model/content.scala
@@ -24,8 +24,6 @@ import scala.jdk.CollectionConverters._
import scala.util.Try
import implicits.Booleans._
import org.joda.time.DateTime
-import conf.switches.Switches.InteractiveHeaderSwitch
-import _root_.contentapi.SectionTagLookUp.sectionId
sealed trait ContentType {
def content: Content
@@ -88,7 +86,9 @@ final case class Content(
lazy val isImmersive =
fields.displayHint.contains("immersive") || isGallery || tags.isTheMinuteArticle || isPhotoEssay
lazy val isPaidContent: Boolean = tags.tags.exists { tag => tag.id == "tone/advertisement-features" }
- lazy val isTheFilter: Boolean = tags.tags.exists { tag => tag.id == "thefilter/series/the-filter" }
+ lazy val isTheFilterUk: Boolean = tags.tags.exists { tag => tag.id == "thefilter/series/the-filter" }
+ lazy val isTheFilterUs: Boolean = tags.tags.exists { tag => tag.id == "thefilter-us/series/thefilter-us" }
+ lazy val isUSProductionOffice: Boolean = productionOffice.exists(_.toLowerCase == "us")
lazy val campaigns: List[Campaign] =
_root_.commercial.targeting.CampaignAgent.getCampaignsForTags(tags.tags.map(_.id))
@@ -96,12 +96,14 @@ final case class Content(
val shouldAmplifyContent = {
if (tags.isLiveBlog) {
AmpLiveBlogSwitch.isSwitchedOn
+ } else if (tags.isInteractive) {
+ AmpArticleSwitch.isSwitchedOn
} else if (tags.isArticle) {
val hasBodyBlocks: Boolean = fields.blocks.exists(b => b.body.nonEmpty)
// Some Labs pages have quiz atoms but are not tagged as quizzes
val hasQuizAtoms: Boolean = atoms.exists(a => a.quizzes.nonEmpty)
- AmpArticleSwitch.isSwitchedOn && hasBodyBlocks && !tags.isQuiz && !hasQuizAtoms && !isTheFilter
+ AmpArticleSwitch.isSwitchedOn && hasBodyBlocks && !tags.isQuiz && !hasQuizAtoms && !isTheFilterUk
} else {
false
}
@@ -162,7 +164,9 @@ final case class Content(
trail.webPublicationDate.isBefore(DateTime.now().minusYears(1))
() match {
- case paid if isPaidContent => Paid
+ case paid if isPaidContent => Paid
+ case filterUk if isTheFilterUk => FilterUk
+ case filterUs if isTheFilterUs => FilterUs
case oldcommentObserver if isOldOpinion && isFromTheObserver =>
CommentObserverOldContent(trail.webPublicationDate.getYear)
case oldComment if isOldOpinion => CommentGuardianOldContent(trail.webPublicationDate.getYear)
@@ -290,8 +294,9 @@ final case class Content(
("references", JsArray(javascriptReferences)),
(
"showRelatedContent",
- JsBoolean(if (tags.isTheMinuteArticle) { false }
- else showInRelated && !legallySensitive),
+ JsBoolean(if (tags.isTheMinuteArticle) {
+ false
+ } else showInRelated && !legallySensitive),
),
("productionOffice", JsString(productionOffice.getOrElse(""))),
("isImmersive", JsBoolean(isImmersive)),
@@ -332,7 +337,7 @@ final case class Content(
} else Nil
val seriesMeta = tags.series.filterNot { _.id == "commentisfree/commentisfree" } match {
- case Nil => Nil
+ case Nil => Nil
case allTags @ (mainSeries :: _) =>
List(
Some("series", JsString(mainSeries.name)),
@@ -1009,7 +1014,6 @@ object Interactive {
contentType = Some(contentType),
adUnitSuffix = section + "/" + contentType.name.toLowerCase,
twitterPropertiesOverrides = Map("twitter:title" -> fields.linkText),
- contentWithSlimHeader = InteractiveHeaderSwitch.isSwitchedOff,
opengraphPropertiesOverrides = opengraphProperties,
)
val contentOverrides = content.copy(
diff --git a/common/app/model/content/Atom.scala b/common/app/model/content/Atom.scala
index 6dd7f0d37c52..27e82db1ab3b 100644
--- a/common/app/model/content/Atom.scala
+++ b/common/app/model/content/Atom.scala
@@ -1,6 +1,11 @@
package model.content
-import com.gu.contentatom.thrift.atom.media.{Asset => AtomApiMediaAsset, MediaAtom => AtomApiMediaAtom}
+import com.gu.contentatom.thrift.atom.media.{
+ Asset => AtomApiMediaAsset,
+ MediaAtom => AtomApiMediaAtom,
+ VideoPlayerFormat => AtomApiVideoPlayerFormat,
+}
+import com.gu.contentatom.thrift.AtomDataAliases.{MediaAlias => MediaAtomData}
import com.gu.contentatom.thrift.atom.timeline.{TimelineItem => TimelineApiItem}
import com.gu.contentatom.thrift.{
AtomData,
@@ -15,7 +20,7 @@ import model.{ImageAsset, ImageMedia, ShareLinkMeta}
import org.apache.commons.lang3.time.DurationFormatUtils
import org.joda.time.format.DateTimeFormat
import org.joda.time.{DateTime, DateTimeZone, Duration}
-import play.api.libs.json.{JsError, JsSuccess, Json, OFormat}
+import play.api.libs.json.{JsError, JsSuccess, Json, OFormat, Writes}
import quiz._
import views.support.GoogleStructuredData
@@ -166,6 +171,8 @@ final case class MediaAtom(
expired: Option[Boolean],
activeVersion: Option[Long],
channelId: Option[String],
+ trailImage: Option[ImageMedia],
+ videoPlayerFormat: Option[VideoPlayerFormat],
) extends Atom {
def activeAssets: Seq[MediaAsset] =
@@ -188,13 +195,44 @@ final case class MediaAtom(
}
}
+object AssetDimensions {
+ implicit val assetDimensionsWrites: Writes[AssetDimensions] =
+ Json.writes[AssetDimensions]
+}
+final case class AssetDimensions(
+ width: Int,
+ height: Int,
+)
+
final case class MediaAsset(
id: String,
version: Long,
platform: MediaAssetPlatform,
mimeType: Option[String],
+ assetType: MediaAssetType,
+ dimensions: Option[AssetDimensions],
+ aspectRatio: Option[String],
)
+sealed trait VideoPlayerFormat extends EnumEntry
+
+object VideoPlayerFormat extends Enum[VideoPlayerFormat] with PlayJsonEnum[VideoPlayerFormat] {
+ val values = findValues
+
+ case object Default extends VideoPlayerFormat
+ case object Loop extends VideoPlayerFormat
+ case object Cinemagraph extends VideoPlayerFormat
+}
+
+sealed trait MediaAssetType extends EnumEntry
+
+object MediaAssetType extends Enum[MediaAssetType] with PlayJsonEnum[MediaAssetType] {
+ val values = findValues
+
+ case object Audio extends MediaAssetType
+ case object Video extends MediaAssetType
+ case object Subtitles extends MediaAssetType
+}
sealed trait MediaAssetPlatform extends EnumEntry
object MediaAtom extends common.GuLogging {
@@ -206,6 +244,33 @@ object MediaAtom extends common.GuLogging {
MediaAtom.mediaAtomMake(id, defaultHtml, mediaAtom)
}
+ def makeFromThrift(id: String, mediaAtom: MediaAtomData): MediaAtom = {
+ MediaAtom(
+ id,
+ // Default html is not being used by DCR - consider removing this field entirely.
+ defaultHtml = "",
+ assets = mediaAtom.assets.map(mediaAssetMake).toSeq,
+ title = mediaAtom.title,
+ duration = mediaAtom.duration,
+ source = mediaAtom.source,
+ posterImage = mediaAtom.posterImage.map(imageMediaMake(_, mediaAtom.title)),
+ // We filter out expired atoms in facia-scala-client so this is always false.
+ expired = Some(false),
+ activeVersion = mediaAtom.activeVersion,
+ channelId = mediaAtom.metadata.flatMap(_.channelId),
+ trailImage = mediaAtom.trailImage.map(imageMediaMake(_, mediaAtom.title)),
+ videoPlayerFormat = VideoPlayerFormat.withNameOption(
+ mediaAtom.metadata
+ .flatMap(_.selfHost)
+ .flatMap(_.videoPlayerFormat)
+ .getOrElse(
+ AtomApiVideoPlayerFormat.Default,
+ )
+ .name,
+ ),
+ )
+ }
+
def mediaAtomMake(id: String, defaultHtml: String, mediaAtom: AtomApiMediaAtom): MediaAtom = {
val expired: Option[Boolean] = for {
metadata <- mediaAtom.metadata
@@ -223,6 +288,16 @@ object MediaAtom extends common.GuLogging {
expired = expired,
activeVersion = mediaAtom.activeVersion,
channelId = mediaAtom.metadata.flatMap(_.channelId),
+ trailImage = mediaAtom.trailImage.map(imageMediaMake(_, mediaAtom.title)),
+ videoPlayerFormat = VideoPlayerFormat.withNameOption(
+ mediaAtom.metadata
+ .flatMap(_.selfHost)
+ .flatMap(_.videoPlayerFormat)
+ .getOrElse(
+ AtomApiVideoPlayerFormat.Default,
+ )
+ .name,
+ ),
)
}
@@ -236,6 +311,9 @@ object MediaAtom extends common.GuLogging {
version = mediaAsset.version,
platform = MediaAssetPlatform.withName(mediaAsset.platform.name),
mimeType = mediaAsset.mimeType,
+ assetType = MediaAssetType.withName(mediaAsset.assetType.name),
+ dimensions = mediaAsset.dimensions.map(dim => AssetDimensions(dim.width, dim.height)),
+ aspectRatio = mediaAsset.aspectRatio,
)
}
diff --git a/common/app/model/diagnostics/CloudWatch.scala b/common/app/model/diagnostics/CloudWatch.scala
index 0c71516c8e5a..d4a2bf095039 100644
--- a/common/app/model/diagnostics/CloudWatch.scala
+++ b/common/app/model/diagnostics/CloudWatch.scala
@@ -1,47 +1,33 @@
package model.diagnostics
-import com.amazonaws.handlers.AsyncHandler
-import com.amazonaws.services.cloudwatch.{AmazonCloudWatchAsync, AmazonCloudWatchAsyncClient}
-import com.amazonaws.services.cloudwatch.model._
import common.GuLogging
import conf.Configuration
import conf.Configuration._
import metrics.{FrontendMetric, FrontendStatisticSet}
+import software.amazon.awssdk.regions.Region
+import software.amazon.awssdk.services.cloudwatch.CloudWatchAsyncClient
+import software.amazon.awssdk.services.cloudwatch.model.{Dimension, MetricDatum, PutMetricDataRequest, StatisticSet}
+import utils.AWSv2
+import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters._
+import scala.jdk.FutureConverters._
+import scala.util.{Failure, Success}
trait CloudWatch extends GuLogging {
- lazy val stageDimension = new Dimension().withName("Stage").withValue(environment.stage)
+ lazy val stageDimension = Dimension.builder().name("Stage").value(environment.stage).build()
- lazy val cloudwatch: Option[AmazonCloudWatchAsync] = Configuration.aws.credentials.map { credentials =>
- AmazonCloudWatchAsyncClient
- .asyncBuilder()
- .withCredentials(credentials)
- .withRegion(conf.Configuration.aws.region)
+ lazy val cloudwatch: CloudWatchAsyncClient =
+ CloudWatchAsyncClient
+ .builder()
+ .credentialsProvider(AWSv2.credentials)
+ .region(Region.of(conf.Configuration.aws.region))
.build()
- }
- trait LoggingAsyncHandler extends AsyncHandler[PutMetricDataRequest, PutMetricDataResult] with GuLogging {
- def onError(exception: Exception): Unit = {
- log.info(s"CloudWatch PutMetricDataRequest error: ${exception.getMessage}}")
- }
- def onSuccess(request: PutMetricDataRequest, result: PutMetricDataResult): Unit = {
- log.info("CloudWatch PutMetricDataRequest - success")
- }
- }
-
- object LoggingAsyncHandler extends LoggingAsyncHandler
-
- case class AsyncHandlerForMetric(frontendStatisticSets: List[FrontendStatisticSet]) extends LoggingAsyncHandler {
- override def onError(exception: Exception): Unit = {
- log.warn(s"Failed to put ${frontendStatisticSets.size} metrics: $exception")
- log.warn(s"Failed to put ${frontendStatisticSets.map(_.name).mkString(",")}")
- super.onError(exception)
- }
- }
-
- def putMetrics(metricNamespace: String, metrics: List[FrontendMetric], dimensions: List[Dimension]): Unit = {
+ def putMetrics(metricNamespace: String, metrics: List[FrontendMetric], dimensions: List[Dimension])(implicit
+ executionContext: ExecutionContext,
+ ): Unit = {
if (Configuration.environment.isProd) {
putMetricsWithStage(metricNamespace, metrics, dimensions :+ stageDimension)
} else {
@@ -53,35 +39,52 @@ trait CloudWatch extends GuLogging {
metricNamespace: String,
metrics: List[FrontendMetric],
dimensions: List[Dimension],
- ): Unit = {
+ )(implicit executionContext: ExecutionContext): Unit = {
for {
metricGroup <- metrics.filterNot(_.isEmpty).grouped(20)
} {
val metricsAsStatistics: List[FrontendStatisticSet] =
metricGroup.map(metric => FrontendStatisticSet(metric.getAndResetDataPoints, metric.name, metric.metricUnit))
- val request = new PutMetricDataRequest()
- .withNamespace(metricNamespace)
- .withMetricData {
- val metricDatum = for (metricStatistic <- metricsAsStatistics) yield {
- new MetricDatum()
- .withStatisticValues(frontendMetricToStatisticSet(metricStatistic))
- .withUnit(metricStatistic.unit)
- .withMetricName(metricStatistic.name)
- .withDimensions(dimensions.asJava)
+ val request =
+ PutMetricDataRequest
+ .builder()
+ .namespace(metricNamespace)
+ .metricData {
+ val metricDatum = for (metricStatistic <- metricsAsStatistics) yield {
+ MetricDatum
+ .builder()
+ .statisticValues(frontendMetricToStatisticSet(metricStatistic))
+ .unit(metricStatistic.unit)
+ .metricName(metricStatistic.name)
+ .dimensions(dimensions.asJava)
+ .build()
+ }
+ metricDatum.asJava
}
- metricDatum.asJava
+ .build()
+
+ CloudWatch.cloudwatch
+ .putMetricData(request)
+ .asScala
+ .onComplete {
+ case Success(_) => log.info("CloudWatch PutMetricDataRequest - success")
+ case Failure(e) =>
+ log.warn(s"Failed to put ${metricsAsStatistics.size} metrics: $e")
+ log.warn(s"Failed to put ${metricsAsStatistics.map(_.name).mkString(",")}")
+ log.info(s"CloudWatch PutMetricDataRequest error: ${e.getMessage}}")
}
- CloudWatch.cloudwatch.foreach(_.putMetricDataAsync(request, AsyncHandlerForMetric(metricsAsStatistics)))
}
}
private def frontendMetricToStatisticSet(metricStatistics: FrontendStatisticSet): StatisticSet =
- new StatisticSet()
- .withMaximum(metricStatistics.maximum)
- .withMinimum(metricStatistics.minimum)
- .withSampleCount(metricStatistics.sampleCount)
- .withSum(metricStatistics.sum)
+ StatisticSet
+ .builder()
+ .maximum(metricStatistics.maximum)
+ .minimum(metricStatistics.minimum)
+ .sampleCount(metricStatistics.sampleCount)
+ .sum(metricStatistics.sum)
+ .build()
}
diff --git a/common/app/model/dotcomrendering/DotcomBlocksRenderingDataModel.scala b/common/app/model/dotcomrendering/DotcomBlocksRenderingDataModel.scala
index edba663ef04a..2d61c96e88ef 100644
--- a/common/app/model/dotcomrendering/DotcomBlocksRenderingDataModel.scala
+++ b/common/app/model/dotcomrendering/DotcomBlocksRenderingDataModel.scala
@@ -11,6 +11,7 @@ import play.api.libs.json._
import play.api.mvc.RequestHeader
import views.support.CamelCase
import experiments.ActiveExperiments
+import ab.ABTests
// -----------------------------------------------------------------
// DCR Blocks DataModel
@@ -30,6 +31,7 @@ case class DotcomBlocksRenderingDataModel(
adUnit: String,
switches: Map[String, Boolean],
abTests: Map[String, String],
+ serverSideABTests: Map[String, String],
)
object DotcomBlocksRenderingDataModel {
@@ -52,6 +54,7 @@ object DotcomBlocksRenderingDataModel {
"adUnit" -> model.adUnit,
"switches" -> model.switches,
"abTests" -> model.abTests,
+ "serverSideABTests" -> model.serverSideABTests,
)
ElementsEnhancer.enhanceBlocks(obj)
@@ -115,6 +118,7 @@ object DotcomBlocksRenderingDataModel {
adUnit = content.metadata.adUnitSuffix,
switches = switches,
abTests = ActiveExperiments.getJsMap(request),
+ serverSideABTests = ABTests.allTests(request),
)
}
}
diff --git a/common/app/model/dotcomrendering/DotcomFrontsRenderingDataModel.scala b/common/app/model/dotcomrendering/DotcomFrontsRenderingDataModel.scala
index d5fc57c7206d..e1f19775e067 100644
--- a/common/app/model/dotcomrendering/DotcomFrontsRenderingDataModel.scala
+++ b/common/app/model/dotcomrendering/DotcomFrontsRenderingDataModel.scala
@@ -12,6 +12,7 @@ import navigation.{FooterLinks, Nav}
import play.api.libs.json.{JsObject, JsValue, Json, OWrites}
import play.api.mvc.RequestHeader
import views.support.{CamelCase, JavaScriptPage}
+import ab.ABTests
case class DotcomFrontsRenderingDataModel(
pressedPage: PressedPage,
@@ -28,8 +29,6 @@ case class DotcomFrontsRenderingDataModel(
isAdFreeUser: Boolean,
isNetworkFront: Boolean,
mostViewed: Seq[Trail],
- mostCommented: Option[Trail],
- mostShared: Option[Trail],
deeplyRead: Option[Seq[Trail]],
contributionsServiceUrl: String,
canonicalUrl: String,
@@ -43,8 +42,6 @@ object DotcomFrontsRenderingDataModel {
request: RequestHeader,
pageType: PageType,
mostViewed: Seq[RelatedContentItem],
- mostCommented: Option[Content],
- mostShared: Option[Content],
deeplyRead: Option[Seq[Trail]],
): DotcomFrontsRenderingDataModel = {
val edition = Edition.edition(request)
@@ -59,6 +56,7 @@ object DotcomFrontsRenderingDataModel {
val config = Config(
switches = switches,
abTests = ActiveExperiments.getJsMap(request),
+ serverSideABTests = ABTests.allTests(request),
ampIframeUrl = DotcomRenderingUtils.assetURL("data/vendor/amp-iframe.html"),
googletagUrl = Configuration.googletag.jsLocation,
stage = common.Environment.stage,
@@ -75,8 +73,17 @@ object DotcomFrontsRenderingDataModel {
.map { _.perEdition.mapKeys(_.id) }
.getOrElse(Map.empty[String, EditionCommercialProperties])
+ val lighterPage = page.copy(collections =
+ page.collections.map(collection =>
+ collection.copy(
+ curated = collection.curated.map(content => content.withoutCommercial),
+ backfill = collection.backfill.map(content => content.withoutCommercial),
+ ),
+ ),
+ )
+
DotcomFrontsRenderingDataModel(
- pressedPage = page,
+ pressedPage = lighterPage,
nav = nav,
editionId = edition.id,
editionLongForm = edition.displayName,
@@ -90,8 +97,6 @@ object DotcomFrontsRenderingDataModel {
isAdFreeUser = views.support.Commercial.isAdFree(request),
isNetworkFront = page.isNetworkFront,
mostViewed = mostViewed.map(content => Trail.pressedContentToTrail(content.faciaContent)(request)),
- mostCommented = mostCommented.flatMap(ContentCard.fromApiContent).flatMap(Trail.contentCardToTrail),
- mostShared = mostShared.flatMap(ContentCard.fromApiContent).flatMap(Trail.contentCardToTrail),
deeplyRead = deeplyRead,
contributionsServiceUrl = Configuration.contributionsService.url,
canonicalUrl = CanonicalLink(request, page.metadata.webUrl),
diff --git a/common/app/model/dotcomrendering/DotcomNewslettersPageRenderingDataModel.scala b/common/app/model/dotcomrendering/DotcomNewslettersPageRenderingDataModel.scala
index 10a73c53834d..197cdac2d3c3 100644
--- a/common/app/model/dotcomrendering/DotcomNewslettersPageRenderingDataModel.scala
+++ b/common/app/model/dotcomrendering/DotcomNewslettersPageRenderingDataModel.scala
@@ -14,6 +14,7 @@ import play.api.mvc.RequestHeader
import views.support.{CamelCase, JavaScriptPage}
import services.newsletters.model.{NewsletterResponseV2, NewsletterLayout}
import services.NewsletterData
+import ab.ABTests
case class DotcomNewslettersPageRenderingDataModel(
newsletters: List[NewsletterData],
@@ -58,6 +59,7 @@ object DotcomNewslettersPageRenderingDataModel {
val config = Config(
switches = switches,
abTests = ActiveExperiments.getJsMap(request),
+ serverSideABTests = ABTests.allTests(request),
ampIframeUrl = DotcomRenderingUtils.assetURL("data/vendor/amp-iframe.html"),
googletagUrl = Configuration.googletag.jsLocation,
stage = common.Environment.stage,
diff --git a/common/app/model/dotcomrendering/DotcomRenderingDataModel.scala b/common/app/model/dotcomrendering/DotcomRenderingDataModel.scala
index f2ed6e4057f2..c550a8410d8e 100644
--- a/common/app/model/dotcomrendering/DotcomRenderingDataModel.scala
+++ b/common/app/model/dotcomrendering/DotcomRenderingDataModel.scala
@@ -1,5 +1,6 @@
package model.dotcomrendering
+import ab.ABTests
import com.gu.contentapi.client.model.v1.{Block => APIBlock, Blocks => APIBlocks}
import com.gu.contentapi.client.utils.AdvertisementFeature
import com.gu.contentapi.client.utils.format.{ImmersiveDisplay, InteractiveDesign}
@@ -10,19 +11,22 @@ import conf.Configuration
import crosswords.CrosswordPageWithContent
import experiments.ActiveExperiments
import model.dotcomrendering.DotcomRenderingUtils._
-import model.dotcomrendering.pageElements.{AudioBlockElement, ImageBlockElement, PageElement, Role, TextCleaner}
-import model.liveblog.BlockAttributes
+import model.dotcomrendering.pageElements._
+import model.meta.BlocksOn
import model.{
ArticleDateTimes,
Badges,
CanonicalLiveBlog,
ContentFormat,
ContentPage,
+ ContentType,
CrosswordData,
DotcomContentType,
GUDateTimeFormatNew,
+ Gallery,
GalleryPage,
ImageContentPage,
+ ImageMedia,
InteractivePage,
LiveBlogPage,
MediaPage,
@@ -33,6 +37,7 @@ import play.api.libs.json._
import play.api.mvc.RequestHeader
import services.NewsletterData
import views.support.{CamelCase, ContentLayout, JavaScriptPage}
+
// -----------------------------------------------------------------
// DCR DataModel
// -----------------------------------------------------------------
@@ -90,6 +95,7 @@ case class DotcomRenderingDataModel(
pageType: PageType,
starRating: Option[Int],
audioArticleImage: Option[PageElement],
+ trailPicture: Option[PageElement],
trailText: String,
nav: Nav,
showBottomSocialButtons: Boolean,
@@ -168,6 +174,7 @@ object DotcomRenderingDataModel {
"pageType" -> model.pageType,
"starRating" -> model.starRating,
"audioArticleImage" -> model.audioArticleImage,
+ "trailPicture" -> model.trailPicture,
"trailText" -> model.trailText,
"nav" -> model.nav,
"showBottomSocialButtons" -> model.showBottomSocialButtons,
@@ -197,13 +204,13 @@ object DotcomRenderingDataModel {
}
def forInteractive(
- page: InteractivePage,
- blocks: APIBlocks,
+ pageBlocks: BlocksOn[InteractivePage],
request: RequestHeader,
pageType: PageType,
): DotcomRenderingDataModel = {
val baseUrl = if (request.isAmp) Configuration.amp.baseUrl else Configuration.dotcom.baseUrl
-
+ val page = pageBlocks.page
+ val blocks = pageBlocks.blocks
apply(
page = page,
request = request,
@@ -225,8 +232,7 @@ object DotcomRenderingDataModel {
}
def forArticle(
- page: PageWithStoryPackage, // for now, any non-liveblog page type
- blocks: APIBlocks,
+ pageBlocks: BlocksOn[PageWithStoryPackage],
request: RequestHeader,
pageType: PageType,
newsletter: Option[NewsletterData],
@@ -234,21 +240,21 @@ object DotcomRenderingDataModel {
val baseUrl = if (request.isAmp) Configuration.amp.baseUrl else Configuration.dotcom.baseUrl
val linkedData =
LinkedData.forArticle(
- article = page.article,
+ article = pageBlocks.page.article,
baseURL = baseUrl,
fallbackLogo = Configuration.images.fallbackLogo,
)
apply(
- page = page,
+ page = pageBlocks.page,
request = request,
pagination = None,
linkedData = linkedData,
- mainBlock = blocks.main,
- bodyBlocks = blocks.body.getOrElse(Nil).toSeq,
+ mainBlock = pageBlocks.blocks.main,
+ bodyBlocks = pageBlocks.blocks.body.getOrElse(Nil).toSeq,
pageType = pageType,
- hasStoryPackage = page.related.hasStoryPackage,
- storyPackage = getStoryPackage(page.related.faciaItems, request),
+ hasStoryPackage = pageBlocks.page.related.hasStoryPackage,
+ storyPackage = getStoryPackage(pageBlocks.page.related.faciaItems, request),
pinnedPost = None,
keyEvents = Nil,
newsletter = newsletter,
@@ -365,14 +371,14 @@ object DotcomRenderingDataModel {
}
def forLiveblog(
- page: LiveBlogPage,
- blocks: APIBlocks,
+ pageBlocks: BlocksOn[LiveBlogPage],
request: RequestHeader,
pageType: PageType,
filterKeyEvents: Boolean,
forceLive: Boolean,
newsletter: Option[NewsletterData],
): DotcomRenderingDataModel = {
+ val page = pageBlocks.page
val pagination = page.currentPage.pagination.map(paginationInfo => {
Pagination(
currentPage = page.currentPage.currentPage.pageNumber,
@@ -384,8 +390,9 @@ object DotcomRenderingDataModel {
)
})
- val bodyBlocks = blocksForLiveblogPage(page, blocks, filterKeyEvents).map(ensureSummaryTitle)
+ val bodyBlocks = blocksForLiveblogPage(pageBlocks, filterKeyEvents).map(ensureSummaryTitle)
+ val blocks = pageBlocks.blocks
val allTimelineBlocks = blocks.body match {
case Some(allBlocks) if allBlocks.nonEmpty =>
allBlocks.filter(block => block.attributes.keyEvent.contains(true) || block.attributes.summary.contains(true))
@@ -462,13 +469,17 @@ object DotcomRenderingDataModel {
)
def hasAffiliateLinks(
+ content: ContentType,
blocks: Seq[APIBlock],
): Boolean = {
- blocks.exists(block => DotcomRenderingUtils.stringContainsAffiliateableLinks(block.bodyHtml))
+ content match {
+ case gallery: Gallery => gallery.lightbox.containsAffiliateableLinks
+ case _ => blocks.exists(block => DotcomRenderingUtils.stringContainsAffiliateableLinks(block.bodyHtml))
+ }
}
val shouldAddAffiliateLinks = DotcomRenderingUtils.shouldAddAffiliateLinks(content)
- val shouldAddDisclaimer = hasAffiliateLinks(bodyBlocks)
+ val shouldAddDisclaimer = hasAffiliateLinks(content, bodyBlocks)
val contentDateTimes: ArticleDateTimes = ArticleDateTimes(
webPublicationDate = content.trail.webPublicationDate,
@@ -486,6 +497,7 @@ object DotcomRenderingDataModel {
val config = Config(
switches = switches,
abTests = ActiveExperiments.getJsMap(request),
+ serverSideABTests = ABTests.allTests(request),
ampIframeUrl = assetURL("data/vendor/amp-iframe.html"),
googletagUrl = Configuration.googletag.jsLocation,
stage = common.Environment.stage,
@@ -504,28 +516,50 @@ object DotcomRenderingDataModel {
val dcrTags = content.tags.tags.map(Tag.apply)
+ def getImageBlockElement(imageMedia: ImageMedia, role: Role) = {
+ val imageData = imageMedia.allImages.headOption
+ .map { d =>
+ Map(
+ "copyright" -> "",
+ "alt" -> d.altText.getOrElse(""),
+ "caption" -> d.caption.getOrElse(""),
+ "credit" -> d.credit.getOrElse(""),
+ )
+ }
+ .getOrElse(Map.empty)
+ ImageBlockElement(
+ imageMedia,
+ imageData,
+ Some(true),
+ role,
+ Seq.empty,
+ )
+ }
+
val audioImageBlock: Option[ImageBlockElement] =
if (page.metadata.contentType.contains(DotcomContentType.Audio)) {
for {
thumbnail <- page.item.elements.thumbnail
} yield {
- val imageData = thumbnail.images.allImages.headOption
- .map { d =>
- Map(
- "copyright" -> "",
- "alt" -> d.altText.getOrElse(""),
- "caption" -> d.caption.getOrElse(""),
- "credit" -> d.credit.getOrElse(""),
- )
- }
- .getOrElse(Map.empty)
- ImageBlockElement(
- thumbnail.images,
- imageData,
- Some(true),
- Role(Some("inline")),
- Seq.empty,
- )
+ getImageBlockElement(thumbnail.images, Role(Some("inline")))
+ }
+ } else {
+ None
+ }
+
+ val trailPicture: Option[ImageBlockElement] =
+ if (page.metadata.contentType.contains(DotcomContentType.Gallery)) {
+ for {
+ imageMedia <- page.item.trail.trailPicture
+ } yield {
+ // DCAR only relies on 'height', 'width', and 'isMaster' fields,
+ // so we remove all other properties to reduce unnecessary data.
+ val filteredImageMedia = ImageMedia(imageMedia.allImages.map { image =>
+ image.copy(fields = image.fields.filter(f => {
+ f._1 == "height" || f._1 == "width" || f._1 == "isMaster"
+ }))
+ })
+ getImageBlockElement(filteredImageMedia, Role(Some("inline")))
}
} else {
None
@@ -609,6 +643,7 @@ object DotcomRenderingDataModel {
DotcomRenderingDataModel(
affiliateLinksDisclaimer = addAffiliateLinksDisclaimerDCR(shouldAddAffiliateLinks, shouldAddDisclaimer),
audioArticleImage = audioImageBlock,
+ trailPicture = trailPicture,
author = author,
badge = Badges.badgeFor(content).map(badge => DCRBadge(badge.seriesTag, badge.imageUrl)),
beaconURL = Configuration.debug.beaconUrl,
diff --git a/common/app/model/dotcomrendering/DotcomRenderingSupportTypes.scala b/common/app/model/dotcomrendering/DotcomRenderingSupportTypes.scala
index 1137f8b7831f..d1f8628edd68 100644
--- a/common/app/model/dotcomrendering/DotcomRenderingSupportTypes.scala
+++ b/common/app/model/dotcomrendering/DotcomRenderingSupportTypes.scala
@@ -124,7 +124,6 @@ object Block {
content,
shouldAddAffiliateLinks,
isMainBlock,
- content.metadata.format.exists(_.display == ImmersiveDisplay),
campaigns,
calloutsUrl,
),
@@ -170,6 +169,7 @@ object Commercial {
case class Config(
switches: Map[String, Boolean],
abTests: Map[String, String],
+ serverSideABTests: Map[String, String],
googletagUrl: String,
stage: String,
frontendAssetsFullURL: String,
diff --git a/common/app/model/dotcomrendering/DotcomRenderingUtils.scala b/common/app/model/dotcomrendering/DotcomRenderingUtils.scala
index 9f59477d8a95..d4fba9adc09b 100644
--- a/common/app/model/dotcomrendering/DotcomRenderingUtils.scala
+++ b/common/app/model/dotcomrendering/DotcomRenderingUtils.scala
@@ -1,15 +1,15 @@
package model.dotcomrendering
import com.github.nscala_time.time.Imports.DateTime
-import com.gu.contentapi.client.model.v1.ElementType.Text
import com.gu.contentapi.client.model.v1.{Block => APIBlock, BlockElement => ClientBlockElement, Blocks => APIBlocks}
-import com.gu.contentapi.client.utils.format.LiveBlogDesign
+import com.gu.contentapi.client.utils.format.{ImmersiveDisplay, LiveBlogDesign}
import com.gu.contentapi.client.utils.{AdvertisementFeature, DesignType}
import common.Edition
import conf.switches.Switches
import conf.{Configuration, Static}
import model.content.Atom
import model.dotcomrendering.pageElements.{PageElement, TextCleaner}
+import model.meta.BlocksOn
import model.pressed.{PressedContent, SpecialReport}
import model.{
ArticleDateTimes,
@@ -54,7 +54,7 @@ object DotcomRenderingUtils {
case (Some(date), Some(team)) =>
Some(
DotcomRenderingMatchData(
- s"${Configuration.ajax.url}/sport/cricket/match/$date/${team}.json?dcr=true",
+ s"${Configuration.ajax.url}/sport/cricket/match-scoreboard/$date/${team}.json",
CricketMatchType,
),
)
@@ -157,10 +157,10 @@ object DotcomRenderingUtils {
}
def blocksForLiveblogPage(
- liveblog: LiveBlogPage,
- blocks: APIBlocks,
+ pageBlocks: BlocksOn[LiveBlogPage],
filterKeyEvents: Boolean,
): Seq[APIBlock] = {
+ val blocks = pageBlocks.blocks
// When the key events filter is on, we'd need all of the key events rather than just the latest 60 blocks
val allBlocks =
getKeyEventsIfFiltered(filterKeyEvents, blocks)
@@ -172,7 +172,7 @@ object DotcomRenderingUtils {
// of the response so we use those
val relevantBlocks = if (allBlocks.isEmpty) blocks.body.getOrElse(Nil) else allBlocks
- val ids = liveblog.currentPage.currentPage.blocks.map(_.id).toSet
+ val ids = pageBlocks.page.currentPage.currentPage.blocks.map(_.id).toSet
relevantBlocks.filter(block => ids(block.id))
}.toSeq
def stringContainsAffiliateableLinks(textString: String): Boolean = {
@@ -185,7 +185,6 @@ object DotcomRenderingUtils {
article: ContentType,
affiliateLinks: Boolean,
isMainBlock: Boolean,
- isImmersive: Boolean,
campaigns: Option[JsValue],
calloutsUrl: Option[String],
): List[PageElement] = {
@@ -201,12 +200,14 @@ object DotcomRenderingUtils {
pageUrl = request.uri,
atoms = atoms,
isMainBlock,
- isImmersive,
+ article.content.metadata.format.exists(_.display == ImmersiveDisplay),
campaigns,
calloutsUrl,
article.elements.thumbnail,
edition,
article.trail.webPublicationDate,
+ article.content.isGallery,
+ article.content.isUSProductionOffice,
),
)
.filter(PageElement.isSupported)
@@ -242,27 +243,53 @@ object DotcomRenderingUtils {
}
}
+ def withoutDeepNull(json: JsValue): JsValue = {
+ json match {
+ case JsObject(fields) =>
+ JsObject(fields.collect {
+ case (key, value) if value != JsNull => key -> withoutDeepNull(value)
+ })
+ case JsArray(values) =>
+ JsArray(values.collect {
+ case value if value != JsNull => withoutDeepNull(value)
+ })
+ case other => other
+ }
+ }
+
def shouldAddAffiliateLinks(content: ContentType): Boolean = {
- val contentHtml = Jsoup.parse(content.fields.body)
- val bodyElements = contentHtml.select("body").first().children()
-
- /** On smaller devices, the disclaimer is inserted before paragraph 2 of the article body and floats left. This
- * logic ensures there are two clear paragraphs of text at the top of the article. We don't support inserting the
- * disclaimer next to other element types. It also ensures the second paragraph is long enough to accommodate the
- * disclaimer appearing alongside it.
- */
- if (bodyElements.size >= 2) {
- val firstEl = bodyElements.get(0)
- val secondEl = bodyElements.get(1)
- if (firstEl.tagName == "p" && secondEl.tagName == "p" && secondEl.text().length >= 150) {
- AffiliateLinksCleaner.shouldAddAffiliateLinks(
- switchedOn = Switches.AffiliateLinks.isSwitchedOn,
- showAffiliateLinks = content.content.fields.showAffiliateLinks,
- alwaysOffTags = Configuration.affiliateLinks.alwaysOffTags,
- tagPaths = content.content.tags.tags.map(_.id),
- )
+ if (content.content.isGallery) {
+ // For galleries, the disclaimer is only inserted in the header so we don't need
+ // a check for paragraphs as in other articles
+ AffiliateLinksCleaner.shouldAddAffiliateLinks(
+ switchedOn = Switches.AffiliateLinks.isSwitchedOn,
+ showAffiliateLinks = content.content.fields.showAffiliateLinks,
+ alwaysOffTags = Configuration.affiliateLinks.alwaysOffTags,
+ tagPaths = content.content.tags.tags.map(_.id),
+ )
+ } else {
+ val contentHtml = Jsoup.parse(content.fields.body)
+ val bodyElements = contentHtml.select("body").first().children()
+
+ /** On smaller devices, the disclaimer is inserted before paragraph 2 of the article body and floats left. This
+ * logic ensures there are two clear paragraphs of text at the top of the article. We don't support inserting the
+ * disclaimer next to other element types. It also ensures the second paragraph is long enough to accommodate the
+ * disclaimer appearing alongside it.
+ */
+ if (bodyElements.size >= 2) {
+ val firstEl = bodyElements.get(0)
+ val secondEl = bodyElements.get(1)
+ if (firstEl.tagName == "p" && secondEl.tagName == "p" && secondEl.text().length >= 150) {
+ AffiliateLinksCleaner.shouldAddAffiliateLinks(
+ switchedOn = Switches.AffiliateLinks.isSwitchedOn,
+ showAffiliateLinks = content.content.fields.showAffiliateLinks,
+ alwaysOffTags = Configuration.affiliateLinks.alwaysOffTags,
+ tagPaths = content.content.tags.tags.map(_.id),
+ )
+ } else false
} else false
- } else false
+ }
+
}
def contentDateTimes(content: ContentType): ArticleDateTimes = {
@@ -307,7 +334,7 @@ object DotcomRenderingUtils {
): Option[OnwardCollectionResponse] = {
faciaItems match {
case Nil => None
- case _ =>
+ case _ =>
Some(
OnwardCollectionResponse(
heading = "More on this story",
diff --git a/common/app/model/dotcomrendering/DotcomTagPagesRenderingDataModel.scala b/common/app/model/dotcomrendering/DotcomTagPagesRenderingDataModel.scala
index 2cf5dc72cf7d..09dc061e9dea 100644
--- a/common/app/model/dotcomrendering/DotcomTagPagesRenderingDataModel.scala
+++ b/common/app/model/dotcomrendering/DotcomTagPagesRenderingDataModel.scala
@@ -14,6 +14,7 @@ import play.api.mvc.RequestHeader
import services.IndexPage
import views.support.{CamelCase, JavaScriptPage, PreviousAndNext}
import model.PressedCollectionFormat.pressedContentFormat
+import ab.ABTests
case class DotcomTagPagesRenderingDataModel(
contents: Seq[PressedContent],
@@ -85,6 +86,7 @@ object DotcomTagPagesRenderingDataModel {
val config = Config(
switches = switches,
abTests = ActiveExperiments.getJsMap(request),
+ serverSideABTests = ABTests.allTests(request),
ampIframeUrl = DotcomRenderingUtils.assetURL("data/vendor/amp-iframe.html"),
googletagUrl = Configuration.googletag.jsLocation,
stage = common.Environment.stage,
diff --git a/common/app/model/dotcomrendering/ElementsEnhancer.scala b/common/app/model/dotcomrendering/ElementsEnhancer.scala
index a754b6b466a7..4fa1911ee2f8 100644
--- a/common/app/model/dotcomrendering/ElementsEnhancer.scala
+++ b/common/app/model/dotcomrendering/ElementsEnhancer.scala
@@ -19,6 +19,7 @@ object ElementsEnhancer {
elementType match {
case "model.dotcomrendering.pageElements.ListBlockElement" => enhanceListBlockElement(elementWithId)
case "model.dotcomrendering.pageElements.TimelineBlockElement" => enhanceTimelineBlockElement(elementWithId)
+ case "model.dotcomrendering.pageElements.ProductBlockElement" => enhanceProductBlockElement(elementWithId)
case _ => elementWithId;
}
case _ => element
@@ -33,6 +34,10 @@ object ElementsEnhancer {
elementWithId ++ Json.obj("items" -> listItemsWithIds)
}
+ def enhanceProductBlockElement(elementWithId: JsObject): JsObject = {
+ elementWithId ++ Json.obj("content" -> enhanceElements(elementWithId.value("content")))
+ }
+
def enhanceTimelineBlockElement(element: JsObject): JsObject = {
val sectionsList = element.value("sections").as[List[JsObject]]
val sectionsListWithIds = sectionsList.map { section =>
@@ -75,6 +80,7 @@ object ElementsEnhancer {
Json.obj("keyEvents" -> enhanceObjectsWithElements(obj.value("keyEvents"))) ++
Json.obj("pinnedPost" -> enhanceObjectWithElements(obj.value("pinnedPost"))) ++
Json.obj("promotedNewsletter" -> obj.value("promotedNewsletter")) ++
- Json.obj("audioArticleImage" -> enhanceElement(obj.value("audioArticleImage")))
+ Json.obj("audioArticleImage" -> enhanceElement(obj.value("audioArticleImage"))) ++
+ Json.obj("trailPicture" -> enhanceElement(obj.value("trailPicture")))
}
}
diff --git a/common/app/model/dotcomrendering/MostPopular.scala b/common/app/model/dotcomrendering/MostPopular.scala
index f1de836f26f2..ee0f64f974b7 100644
--- a/common/app/model/dotcomrendering/MostPopular.scala
+++ b/common/app/model/dotcomrendering/MostPopular.scala
@@ -12,8 +12,6 @@ object OnwardCollectionResponse {
case class OnwardCollectionResponseDCR(
tabs: Seq[OnwardCollectionResponse],
- mostCommented: Option[Trail],
- mostShared: Option[Trail],
)
object OnwardCollectionResponseDCR {
implicit val onwardCollectionResponseForDRCWrites: OWrites[OnwardCollectionResponseDCR] =
diff --git a/common/app/model/dotcomrendering/Trail.scala b/common/app/model/dotcomrendering/Trail.scala
index b8ae1e530fdb..0dd420ad300b 100644
--- a/common/app/model/dotcomrendering/Trail.scala
+++ b/common/app/model/dotcomrendering/Trail.scala
@@ -5,6 +5,7 @@ import com.gu.commercial.branding.{Branding, BrandingType, Dimensions, Logo => C
import common.{Edition, LinkTo}
import implicits.FaciaContentFrontendHelpers.FaciaContentFrontendHelper
import layout.{ContentCard, DiscussionSettings}
+import model.dotcomrendering.DotcomRenderingUtils.withoutNull
import model.{Article, ContentFormat, ImageMedia, InlineImage, Pillar}
import model.pressed.PressedContent
import play.api.libs.json.{Json, OWrites, Writes}
@@ -33,6 +34,8 @@ case class Trail(
avatarUrl: Option[String],
branding: Option[Branding],
discussion: DiscussionSettings,
+ trailText: Option[String],
+ galleryCount: Option[Int],
)
object Trail {
@@ -53,7 +56,35 @@ object Trail {
implicit val discussionWrites: OWrites[DiscussionSettings] = Json.writes[DiscussionSettings]
- implicit val OnwardItemWrites: OWrites[Trail] = Json.writes[Trail]
+ implicit val OnwardItemWrites: Writes[Trail] = Writes { trail =>
+ val jsObject = Json.obj(
+ "url" -> trail.url,
+ "linkText" -> trail.linkText,
+ "showByline" -> trail.showByline,
+ "byline" -> trail.byline,
+ "masterImage" -> trail.masterImage,
+ "image" -> trail.image,
+ "carouselImages" -> trail.carouselImages,
+ "ageWarning" -> trail.ageWarning,
+ "isLiveBlog" -> trail.isLiveBlog,
+ "pillar" -> trail.pillar,
+ "designType" -> trail.designType,
+ "format" -> trail.format,
+ "webPublicationDate" -> trail.webPublicationDate,
+ "headline" -> trail.headline,
+ "mediaType" -> trail.mediaType,
+ "shortUrl" -> trail.shortUrl,
+ "kickerText" -> trail.kickerText,
+ "starRating" -> trail.starRating,
+ "avatarUrl" -> trail.avatarUrl,
+ "branding" -> trail.branding,
+ "discussion" -> trail.discussion,
+ "trailText" -> trail.trailText,
+ "galleryCount" -> trail.galleryCount,
+ )
+
+ withoutNull(jsObject)
+ }
private def contentCardToAvatarUrl(contentCard: ContentCard): Option[String] = {
@@ -99,43 +130,6 @@ object Trail {
url <- masterImage.url
} yield url
- def contentCardToTrail(contentCard: ContentCard): Option[Trail] = {
- for {
- properties <- contentCard.properties
- maybeContent <- properties.maybeContent
- metadata = maybeContent.metadata
- pillar <- metadata.pillar
- url <- properties.webUrl
- headline = contentCard.header.headline
- isLiveBlog = properties.isLiveBlog
- showByline = properties.showByline
- webPublicationDate <- contentCard.webPublicationDate.map(x => x.toDateTime().toString())
- shortUrl <- contentCard.shortUrl
- } yield Trail(
- url = url,
- linkText = "",
- showByline = showByline,
- byline = contentCard.byline.map(x => x.get),
- masterImage = getMasterUrl(maybeContent.trail.trailPicture),
- image = maybeContent.trail.thumbnailPath,
- carouselImages = getImageSources(maybeContent.trail.trailPicture),
- ageWarning = None,
- isLiveBlog = isLiveBlog,
- pillar = TrailUtils.normalisePillar(Some(pillar)),
- designType = metadata.designType.toString,
- format = metadata.format.getOrElse(ContentFormat.defaultContentFormat),
- webPublicationDate = webPublicationDate,
- headline = headline,
- mediaType = contentCard.mediaType.map(x => x.toString),
- shortUrl = shortUrl,
- kickerText = contentCard.header.kicker.flatMap(_.properties.kickerText),
- starRating = contentCard.starRating,
- avatarUrl = contentCardToAvatarUrl(contentCard),
- branding = contentCard.branding,
- discussion = contentCard.discussionSettings,
- )
- }
-
def pressedContentToTrail(content: PressedContent)(implicit
request: RequestHeader,
): Trail = {
@@ -168,6 +162,8 @@ object Trail {
avatarUrl = None,
branding = content.branding(Edition(request)),
discussion = DiscussionSettings.fromTrail(content),
+ trailText = content.card.trailText,
+ galleryCount = content.card.galleryCount,
)
}
}
diff --git a/common/app/model/dotcomrendering/pageElements/EditionsCrosswordRenderingDataModel.scala b/common/app/model/dotcomrendering/pageElements/EditionsCrosswordRenderingDataModel.scala
index 189b9d0ee834..447966bc3910 100644
--- a/common/app/model/dotcomrendering/pageElements/EditionsCrosswordRenderingDataModel.scala
+++ b/common/app/model/dotcomrendering/pageElements/EditionsCrosswordRenderingDataModel.scala
@@ -1,33 +1,15 @@
package model.dotcomrendering.pageElements
-
-import com.gu.contentapi.client.model.v1.Crossword
-import com.gu.contentapi.json.CirceEncoders._
-import io.circe.syntax._
-import implicits.Dates.CapiRichDateTime
-import model.dotcomrendering.DotcomRenderingUtils
-import play.api.libs.json.{JsObject, Json, JsValue}
+import model.CrosswordData
+import play.api.libs.json.{JsValue, Json}
case class EditionsCrosswordRenderingDataModel(
- crosswords: Iterable[Crossword],
+ crosswords: Iterable[CrosswordData],
)
object EditionsCrosswordRenderingDataModel {
- def apply(crosswords: Iterable[Crossword]): EditionsCrosswordRenderingDataModel =
- new EditionsCrosswordRenderingDataModel(crosswords.map(crossword => {
- val shipSolutions =
- crossword.dateSolutionAvailable
- .map(_.toJoda.isBeforeNow)
- .getOrElse(crossword.solutionAvailable)
-
- if (shipSolutions) {
- crossword
- } else {
- crossword.copy(entries = crossword.entries.map(_.copy(solution = None)))
- }
- }))
-
- def toJson(model: EditionsCrosswordRenderingDataModel): JsValue =
+ def toJson(model: EditionsCrosswordRenderingDataModel): JsValue = {
Json.obj(
- "crosswords" -> Json.parse(model.crosswords.asJson.deepDropNullValues.toString()),
+ "newCrosswords" -> Json.toJson(model.crosswords),
)
+ }
}
diff --git a/common/app/model/dotcomrendering/pageElements/PageElement.scala b/common/app/model/dotcomrendering/pageElements/PageElement.scala
index 80b8416d2f44..9cc534d4c83f 100644
--- a/common/app/model/dotcomrendering/pageElements/PageElement.scala
+++ b/common/app/model/dotcomrendering/pageElements/PageElement.scala
@@ -5,6 +5,12 @@ import com.gu.contentapi.client.model.v1.ElementType.{List => GuList, Map => GuM
import com.gu.contentapi.client.model.v1.EmbedTracksType.DoesNotTrack
import com.gu.contentapi.client.model.v1.{
EmbedTracking,
+ LinkType,
+ ProductDisplayType,
+ ProductElementFields,
+ ProductCTA => ApiProductCta,
+ ProductCustomAttribute => ApiProductCustomAttribute,
+ ProductImage => ApiProductImage,
SponsorshipType,
TimelineElementFields,
WitnessElementFields,
@@ -22,7 +28,7 @@ import org.joda.time.DateTime
import org.jsoup.Jsoup
import play.api.libs.json._
import views.support.cleaner.SoundcloudHelper
-import views.support.{ImgSrc, SrcSet, Video700}
+import views.support.{AffiliateLinksCleaner, ImgSrc, SrcSet, Video700}
import java.net.URLEncoder
import scala.jdk.CollectionConverters._
@@ -428,12 +434,14 @@ object MapBlockElement {
case class MediaAtomBlockElementMediaAsset(
url: String,
mimeType: Option[String],
+ dimensions: Option[AssetDimensions],
+ aspectRatio: Option[String],
)
object MediaAtomBlockElementMediaAsset {
implicit val MediaAtomBlockElementMediaAssetWrites: Writes[MediaAtomBlockElementMediaAsset] =
Json.writes[MediaAtomBlockElementMediaAsset]
def fromMediaAsset(asset: MediaAsset): MediaAtomBlockElementMediaAsset = {
- MediaAtomBlockElementMediaAsset(asset.id, asset.mimeType)
+ MediaAtomBlockElementMediaAsset(asset.id, asset.mimeType, asset.dimensions, asset.aspectRatio)
}
}
case class MediaAtomBlockElement(
@@ -446,6 +454,7 @@ case class MediaAtomBlockElement(
expired: Option[Boolean],
activeVersion: Option[Long],
channelId: Option[String],
+ videoPlayerFormat: Option[VideoPlayerFormat],
) extends PageElement
object MediaAtomBlockElement {
implicit val MediaAtomBlockElementWrites: Writes[MediaAtomBlockElement] = Json.writes[MediaAtomBlockElement]
@@ -497,6 +506,60 @@ object PullquoteBlockElement {
implicit val PullquoteBlockElementWrites: Writes[PullquoteBlockElement] = Json.writes[PullquoteBlockElement]
}
+case class LinkBlockElement(
+ url: Option[String],
+ label: Option[String],
+ linkType: LinkType,
+) extends PageElement
+object LinkBlockElement {
+ implicit val LinkTypeWrites: Writes[LinkType] = Writes { linkType =>
+ JsString(linkType.name)
+ }
+ implicit val LinkBlockElementWrites: Writes[LinkBlockElement] = Json.writes[LinkBlockElement]
+}
+
+case class ProductImage(
+ url: String,
+ caption: String,
+ height: Int,
+ width: Int,
+ alt: String,
+ credit: String,
+ displayCredit: Boolean,
+)
+case class ProductCustomAttribute(
+ name: String,
+ value: String,
+)
+case class ProductCta(
+ text: String,
+ price: String,
+ retailer: String,
+ url: String,
+)
+case class ProductBlockElement(
+ productName: String,
+ brandName: String,
+ primaryHeadingHtml: String,
+ secondaryHeadingHtml: String,
+ starRating: String,
+ productCtas: List[ProductCta],
+ customAttributes: List[ProductCustomAttribute],
+ image: Option[ProductImage],
+ content: Seq[PageElement],
+ displayType: ProductDisplayType,
+) extends PageElement
+object ProductBlockElement {
+ implicit val ProductBlockElementImageWrites: Writes[ProductImage] = Json.writes[ProductImage]
+ implicit val ProductBlockElementCTAWrites: Writes[ProductCta] = Json.writes[ProductCta]
+ implicit val ProductBlockElementCustomAttributeWrites: Writes[ProductCustomAttribute] =
+ Json.writes[ProductCustomAttribute]
+ implicit val ProductBlockElementDisplayTypeWrites: Writes[ProductDisplayType] = Writes { displayType =>
+ JsString(displayType.name)
+ }
+ implicit val ProductBlockElementWrites: Writes[ProductBlockElement] = Json.writes[ProductBlockElement]
+}
+
case class QABlockElement(id: String, title: String, img: Option[String], html: String, credit: String)
extends PageElement
object QABlockElement {
@@ -883,6 +946,8 @@ object PageElement {
case _: VineBlockElement => true
case _: ListBlockElement => true
case _: TimelineBlockElement => true
+ case _: LinkBlockElement => true
+ case _: ProductBlockElement => true
// TODO we should quick fail here for these rather than pointlessly go to DCR
case table: TableBlockElement if table.isMandatory.exists(identity) => true
@@ -903,6 +968,8 @@ object PageElement {
overrideImage: Option[ImageElement],
edition: Edition,
webPublicationDate: DateTime,
+ isGallery: Boolean,
+ isUSProductionOffice: Boolean,
): List[PageElement] = {
def extractAtom: Option[Atom] =
@@ -920,7 +987,7 @@ object PageElement {
element.`type` match {
case Text =>
val textCleaners =
- TextCleaner.affiliateLinks(pageUrl, addAffiliateLinks) _ andThen
+ TextCleaner.affiliateLinks(pageUrl, addAffiliateLinks, isUSProductionOffice) _ andThen
TextCleaner.sanitiseLinks(edition)
for {
@@ -1012,7 +1079,7 @@ object PageElement {
List(
ImageBlockElement(
ImageMedia(imageAssets.toSeq),
- imageDataFor(element),
+ imageDataFor(element, isGallery, pageUrl, addAffiliateLinks, isUSProductionOffice),
element.imageTypeData.flatMap(_.displayCredit),
Role(element.imageTypeData.flatMap(_.role), defaultRole),
imageSources,
@@ -1243,12 +1310,13 @@ object PageElement {
mediaAtom.id,
mediaAtom.title,
mediaAtom.defaultHtml,
- mediaAtom.assets.map(MediaAtomBlockElementMediaAsset.fromMediaAsset),
+ mediaAtom.activeAssets.map(MediaAtomBlockElementMediaAsset.fromMediaAsset),
mediaAtom.duration,
mediaAtom.posterImage.map(NSImage1.imageMediaToSequence),
mediaAtom.expired,
mediaAtom.activeVersion,
mediaAtom.channelId,
+ mediaAtom.videoPlayerFormat,
),
)
}
@@ -1387,13 +1455,25 @@ object PageElement {
),
)
.toList
+
+ case Link =>
+ element.linkTypeData
+ .map(d =>
+ LinkBlockElement(
+ AffiliateLinksCleaner.replaceUrlInLink(d.url, pageUrl, addAffiliateLinks, isUSProductionOffice),
+ d.label,
+ d.linkType.getOrElse(LinkType.ProductButton),
+ ),
+ )
+ .toList
+
case Interactive =>
element.interactiveTypeData
.map(d =>
InteractiveBlockElement(d.iframeUrl, d.alt, d.scriptUrl.map(ensureHTTPS), d.role, d.isMandatory, d.caption),
)
.toList
- case Table => element.tableTypeData.map(d => TableBlockElement(d.html, Role(d.role), d.isMandatory)).toList
+ case Table => element.tableTypeData.map(d => TableBlockElement(d.html, Role(d.role), d.isMandatory)).toList
case Witness => {
(for {
wtd <- element.witnessTypeData
@@ -1479,6 +1559,8 @@ object PageElement {
edition,
webPublicationDate,
item,
+ isGallery,
+ isUSProductionOffice,
)
}.toSeq,
listElementType = listTypeData.`type`.map(_.name),
@@ -1498,10 +1580,29 @@ object PageElement {
edition,
webPublicationDate,
timelineTypeData,
+ isGallery,
+ isUSProductionOffice,
),
)
}.toList
+ case Product =>
+ element.productTypeData.map { productTypeData =>
+ makeProduct(
+ addAffiliateLinks,
+ pageUrl,
+ atoms,
+ isImmersive,
+ campaigns,
+ calloutsUrl,
+ edition,
+ webPublicationDate,
+ productTypeData,
+ isGallery,
+ isUSProductionOffice,
+ )
+ }.toList
+
case EnumUnknownElementType(f) => List(UnknownBlockElement(None))
case _ => Nil
}
@@ -1517,6 +1618,8 @@ object PageElement {
edition: Edition,
webPublicationDate: DateTime,
timelineTypeData: TimelineElementFields,
+ isGallery: Boolean,
+ isUSProductionOffice: Boolean,
) = {
timelineTypeData.sections.map { section =>
TimelineSection(
@@ -1540,6 +1643,8 @@ object PageElement {
overrideImage = None,
edition,
webPublicationDate,
+ isGallery,
+ isUSProductionOffice,
)
.headOption
},
@@ -1556,6 +1661,8 @@ object PageElement {
overrideImage = None,
edition,
webPublicationDate,
+ isGallery,
+ isUSProductionOffice,
)
}.toSeq,
)
@@ -1574,6 +1681,8 @@ object PageElement {
edition: Edition,
webPublicationDate: DateTime,
item: v1.ListItem,
+ isGallery: Boolean,
+ isUSProductionOffice: Boolean,
) = {
ListItem(
elements = item.elements.flatMap { element =>
@@ -1589,6 +1698,8 @@ object PageElement {
overrideImage = None,
edition,
webPublicationDate,
+ isGallery,
+ isUSProductionOffice,
)
}.toSeq,
title = item.title,
@@ -1601,6 +1712,112 @@ object PageElement {
)
}
+ private def makeProduct(
+ addAffiliateLinks: Boolean,
+ pageUrl: String,
+ atoms: Iterable[Atom],
+ isImmersive: Boolean,
+ campaigns: Option[JsValue],
+ calloutsUrl: Option[String],
+ edition: Edition,
+ webPublicationDate: DateTime,
+ product: ProductElementFields,
+ isGallery: Boolean,
+ isUSProductionOffice: Boolean,
+ ) = {
+
+ def createProductCta(
+ cta: ApiProductCta,
+ pageUrl: String,
+ addAffiliateLinks: Boolean,
+ isUSProductionOffice: Boolean,
+ ): Option[ProductCta] = {
+ for {
+ // URL must exist and be non-empty
+ url <- AffiliateLinksCleaner
+ .replaceUrlInLink(cta.url, pageUrl, addAffiliateLinks, isUSProductionOffice)
+ .filter(_.nonEmpty)
+
+ // Must have either non-empty text, or both non-empty price & retailer
+ if cta.text.exists(_.nonEmpty) ||
+ (cta.price.exists(_.nonEmpty) && cta.retailer.exists(_.nonEmpty))
+ } yield ProductCta(
+ text = cta.text.getOrElse(""),
+ price = cta.price.getOrElse(""),
+ retailer = cta.retailer.getOrElse(""),
+ url = url,
+ )
+ }
+
+ def createProductCustomAttribute(apiCustomAttribute: ApiProductCustomAttribute): Option[ProductCustomAttribute] = {
+ for {
+ name <- apiCustomAttribute.name if name.nonEmpty
+ value <- apiCustomAttribute.value if value.nonEmpty
+ } yield ProductCustomAttribute(
+ name = name,
+ value = value,
+ )
+ }
+
+ def createProductImage(apiImage: ApiProductImage): Option[ProductImage] = {
+ for {
+ url <- apiImage.file if url.nonEmpty
+ height <- apiImage.height
+ width <- apiImage.width
+ displayCredit <- apiImage.displayCredit
+ credit <- apiImage.credit
+ alt <- apiImage.alt
+ } yield ProductImage(
+ url = url,
+ caption = apiImage.caption.getOrElse(""),
+ credit = credit,
+ height = height,
+ width = width,
+ displayCredit = displayCredit,
+ alt = alt,
+ )
+ }
+
+ ProductBlockElement(
+ content = product.content
+ .getOrElse(List())
+ .flatMap { element =>
+ PageElement.make(
+ element,
+ addAffiliateLinks,
+ pageUrl,
+ atoms,
+ isMainBlock = false,
+ isImmersive,
+ campaigns,
+ calloutsUrl,
+ overrideImage = None,
+ edition,
+ webPublicationDate,
+ isGallery,
+ isUSProductionOffice,
+ )
+ }
+ .toSeq,
+ productName = product.productName.getOrElse(""),
+ brandName = product.brandName.getOrElse(""),
+ primaryHeadingHtml = product.primaryHeading.getOrElse(""),
+ secondaryHeadingHtml = product.secondaryHeading.getOrElse(""),
+ starRating = product.starRating.getOrElse("none-selected"),
+ productCtas = product.productCtas
+ .getOrElse(Seq.empty)
+ .flatMap(cta => createProductCta(cta, pageUrl, addAffiliateLinks, isUSProductionOffice))
+ .toList,
+ customAttributes = product.customAttributes
+ .getOrElse(Seq.empty)
+ .flatMap(apiAttr => createProductCustomAttribute(apiAttr))
+ .toList,
+ image = product.image.flatMap(apiImage => createProductImage(apiImage)),
+ displayType = product.displayType,
+ )
+
+ }
+
private[this] def ensureHTTPS(url: String): String = {
val http = "http://"
@@ -1912,12 +2129,24 @@ object PageElement {
pageElement.flatten
}
- private def imageDataFor(element: ApiBlockElement): Map[String, String] = {
+ private def imageDataFor(
+ element: ApiBlockElement,
+ isGallery: Boolean,
+ pageUrl: String,
+ addAffiliateLinks: Boolean,
+ isUSProductionOffice: Boolean,
+ ): Map[String, String] = {
element.imageTypeData.map { d =>
Map(
"copyright" -> d.copyright,
"alt" -> d.alt,
- "caption" -> d.caption,
+ "caption" -> {
+ if (isGallery) {
+ d.caption.map(TextCleaner.cleanGalleryCaption(_, pageUrl, addAffiliateLinks, isUSProductionOffice))
+ } else {
+ d.caption
+ }
+ },
"credit" -> d.credit,
) collect { case (k, Some(v)) => (k, v) }
} getOrElse Map()
diff --git a/common/app/model/dotcomrendering/pageElements/TextCleaner.scala b/common/app/model/dotcomrendering/pageElements/TextCleaner.scala
index 2c3ad1f8ab03..d8473cba6c4e 100644
--- a/common/app/model/dotcomrendering/pageElements/TextCleaner.scala
+++ b/common/app/model/dotcomrendering/pageElements/TextCleaner.scala
@@ -1,23 +1,30 @@
package model.dotcomrendering.pageElements
-import common.{Edition, LinkTo}
+import common.{Edition, GuLogging, LinkTo}
import conf.Configuration.{affiliateLinks => affiliateLinksConfig}
import model.{Tag, Tags}
import org.jsoup.Jsoup
-import views.support.AffiliateLinksCleaner
+import org.jsoup.nodes.Document
+import views.support.{AffiliateLinksCleaner, HtmlCleaner}
import scala.jdk.CollectionConverters._
import scala.util.matching.Regex
object TextCleaner {
- def affiliateLinks(pageUrl: String, addAffiliateLinks: Boolean)(html: String): String = {
+ def affiliateLinks(pageUrl: String, addAffiliateLinks: Boolean, isUSProductionOffice: Boolean)(
+ html: String,
+ ): String = {
if (addAffiliateLinks) {
val doc = Jsoup.parseBodyFragment(html)
val links = AffiliateLinksCleaner.getAffiliateableLinks(doc)
+ val skimlinksId =
+ if (isUSProductionOffice) affiliateLinksConfig.skimlinksUSId else affiliateLinksConfig.skimlinksDefaultId
links.foreach(el => {
- val id = affiliateLinksConfig.skimlinksId
- el.attr("href", AffiliateLinksCleaner.linkToSkimLink(el.attr("href"), pageUrl, id)).attr("rel", "sponsored")
+ el.attr(
+ "href",
+ AffiliateLinksCleaner.linkToSkimLink(el.attr("href"), pageUrl, skimlinksId),
+ ).attr("rel", "sponsored")
})
if (links.nonEmpty) {
@@ -30,6 +37,29 @@ object TextCleaner {
}
}
+ def cleanGalleryCaption(
+ caption: String,
+ pageUrl: String,
+ shouldAddAffiliateLinks: Boolean,
+ isUSProductionOffice: Boolean,
+ ): String = {
+
+ val cleaners = List(
+ GalleryCaptionCleaner,
+ GalleryAffiliateLinksCleaner(
+ pageUrl,
+ shouldAddAffiliateLinks,
+ isUSProductionOffice,
+ ),
+ )
+
+ val cleanedHtml = cleaners.foldLeft(Jsoup.parseBodyFragment(caption)) { case (html, cleaner) =>
+ cleaner.clean(html)
+ }
+ cleanedHtml.outputSettings().prettyPrint(false)
+ cleanedHtml.body.html
+ }
+
def sanitiseLinks(edition: Edition)(html: String): String = {
val doc = Jsoup.parseBodyFragment(html)
val links = doc.body().getElementsByTag("a")
@@ -108,3 +138,43 @@ object TagLinker {
}
}
}
+
+object GalleryCaptionCleaner extends HtmlCleaner {
+ override def clean(galleryCaption: Document): Document = {
+ // There is an inconsistent number of
tags in gallery captions.
+ // To create some consistency, re will remove them all.
+ galleryCaption.getElementsByTag("br").remove()
+
+ val firstStrong = Option(galleryCaption.getElementsByTag("strong").first())
+ val captionTitle = galleryCaption.createElement("h2")
+ val captionTitleText = firstStrong.map(_.html()).getOrElse("")
+
+ // is removed in place of having a element
+ firstStrong.foreach(_.remove())
+
+ if (captionTitleText.isEmpty) {
+ galleryCaption
+ } else {
+ captionTitle.html(captionTitleText)
+ galleryCaption.body.prependChild(captionTitle)
+ galleryCaption
+ }
+ }
+}
+
+case class GalleryAffiliateLinksCleaner(
+ pageUrl: String,
+ shouldAddAffiliateLinks: Boolean,
+ isUSProductionOffice: Boolean,
+) extends HtmlCleaner
+ with GuLogging {
+
+ override def clean(document: Document): Document = {
+ val skimlinksId =
+ if (isUSProductionOffice) affiliateLinksConfig.skimlinksUSId else affiliateLinksConfig.skimlinksDefaultId
+
+ if (shouldAddAffiliateLinks) {
+ AffiliateLinksCleaner.replaceLinksInHtml(document, pageUrl, skimlinksId)
+ } else document
+ }
+}
diff --git a/common/app/model/facia/PressedCollection.scala b/common/app/model/facia/PressedCollection.scala
index efb307095a2e..5eb672807ca4 100644
--- a/common/app/model/facia/PressedCollection.scala
+++ b/common/app/model/facia/PressedCollection.scala
@@ -1,9 +1,8 @@
package model.facia
import com.gu.commercial.branding.ContainerBranding
-import com.gu.facia.api.{models => fapi}
-import com.gu.facia.api.models.{GroupsConfig}
-import com.gu.facia.api.utils.ContainerBrandingFinder
+import com.gu.facia.api.FAPI
+import com.gu.facia.api.utils.{BoostLevel, ContainerBrandingFinder}
import com.gu.facia.client.models.{Branded, TargetedTerritory}
import common.Edition
import model.pressed._
@@ -20,7 +19,6 @@ case class PressedCollection(
href: Option[String],
description: Option[String],
collectionType: String,
- groupsConfig: Option[GroupsConfig],
uneditable: Boolean,
showTags: Boolean,
showSections: Boolean,
@@ -52,6 +50,21 @@ case class PressedCollection(
def totalSize: Int = curated.size + backfill.size
+ lazy val withDefaultBoostLevels = {
+ val (defaultBoostCurated, defaultBoostBackfill) = FAPI
+ .applyDefaultBoostLevelsAndGroups[PressedContent](
+ groupsConfig = config.groupsConfig,
+ collectionType = config.collectionType,
+ contents = curated ++ backfill,
+ getBoostLevel = _.display.boostLevel.getOrElse(BoostLevel.Default),
+ setBoostLevel = (content, level) => content.withBoostLevel(Some(level)),
+ setGroup = (content, group) => content.withCard(content.card.copy(group = group)),
+ )
+ .splitAt(curated.length)
+
+ copy(curated = defaultBoostCurated, backfill = defaultBoostBackfill)
+ }
+
def lite(visible: Int): PressedCollection = {
val liteCurated = curated.take(visible)
val liteBackfill = backfill.take(visible - liteCurated.length)
@@ -98,7 +111,6 @@ object PressedCollection {
collection.href,
collection.collectionConfig.description,
collection.collectionConfig.collectionType,
- collection.collectionConfig.groupsConfig,
collection.collectionConfig.uneditable,
collection.collectionConfig.showTags,
collection.collectionConfig.showSections,
diff --git a/common/app/model/liveblog/BlockElement.scala b/common/app/model/liveblog/BlockElement.scala
index ca23b4f907b4..2e36cbaafbc1 100644
--- a/common/app/model/liveblog/BlockElement.scala
+++ b/common/app/model/liveblog/BlockElement.scala
@@ -182,6 +182,8 @@ object BlockElement {
case Recipe => Some(UnsupportedBlockElement(None))
case ElementType.List => Some(UnsupportedBlockElement(None))
case Timeline => Some(UnsupportedBlockElement(None))
+ case Link => Some(UnsupportedBlockElement(None))
+ case Product => Some(UnsupportedBlockElement(None))
}
}
diff --git a/common/app/model/meta.scala b/common/app/model/meta.scala
index 831aab3c618c..b08869d260da 100644
--- a/common/app/model/meta.scala
+++ b/common/app/model/meta.scala
@@ -358,11 +358,6 @@ case class MetaData(
def hasSurveyAd(request: RequestHeader): Boolean =
DfpAgent.hasSurveyAd(fullAdUnitPath, this, request)
- def omitMPUsFromContainers(edition: Edition): Boolean =
- if (isPressedPage) {
- DfpAgent.omitMPUsFromContainers(id, edition)
- } else false
-
val isSecureContact: Boolean = Set(
"help/ng-interactive/2017/mar/17/contact-the-guardian-securely",
"help/2016/sep/19/how-to-contact-the-guardian-securely",
@@ -564,12 +559,25 @@ case class TagCombiner(
private val webTitle: String = webTitleOverrides.getOrElse(id, s"${leftTag.name} + ${rightTag.name}")
+ val javascriptConfigOverrides: Map[String, JsValue] = Map(
+ ("keywords", JsString(List(leftTag.properties.webTitle, rightTag.properties.webTitle).mkString(","))),
+ ("keywordIds", JsString(List(leftTag.id, rightTag.id).mkString(","))),
+ (
+ "references",
+ JsArray(
+ (leftTag.properties.references ++ rightTag.properties.references).map(ref => Reference.toJavaScript(ref.id)),
+ ),
+ ),
+ )
+
override val metadata: MetaData = MetaData.make(
id = id,
section = leftTag.metadata.section,
webTitle = webTitle,
pagination = pagination,
description = Some(DotcomContentType.TagIndex.name),
+ javascriptConfigOverrides = javascriptConfigOverrides,
+ isFront = true,
commercial = Some(
// We only use the left tag for CommercialProperties
CommercialProperties(
diff --git a/common/app/model/meta/BlocksOn.scala b/common/app/model/meta/BlocksOn.scala
new file mode 100644
index 000000000000..54e0fb6cebed
--- /dev/null
+++ b/common/app/model/meta/BlocksOn.scala
@@ -0,0 +1,13 @@
+package model.meta
+
+import com.gu.contentapi.client.model.v1.Blocks
+
+/** Blocks are often passed around with the Page they belong to. It makes sense to give them a type that holds the two
+ * together - this reduces parameter-count on many methods, improves consistency, and makes clear what things usefully
+ * belong together for the work we're performing.
+ *
+ * https://docondev.com/blog/2020/6/2/refactoring-introduce-parameter-object
+ */
+case class BlocksOn[+P](page: P, blocks: Blocks) {
+ def mapBoth[Q >: P](p: P => Q, b: Blocks => Blocks) = BlocksOn(p(page), b(blocks))
+}
diff --git a/common/app/model/pressedContent.scala b/common/app/model/pressedContent.scala
index ce73775c2b76..eec2b6f75f51 100644
--- a/common/app/model/pressedContent.scala
+++ b/common/app/model/pressedContent.scala
@@ -1,8 +1,10 @@
package model.pressed
import com.gu.commercial.branding.Branding
+import com.gu.facia.api.utils.BoostLevel
import com.gu.facia.api.{models => fapi}
import common.Edition
+import model.content.MediaAtom
import model.{ContentFormat, Pillar}
import views.support.ContentOldAgeDescriber
@@ -25,6 +27,28 @@ sealed trait PressedContent {
def withoutTrailText: PressedContent
+ def withoutCommercial: PressedContent
+
+ def withBoostLevel(level: Option[BoostLevel]): PressedContent
+
+ def withCard(card: PressedCard): PressedContent
+
+ protected def propertiesWithoutCommercial(properties: PressedProperties): PressedProperties =
+ properties.copy(
+ maybeContent = properties.maybeContent.map(storyWithoutCommercial),
+ )
+
+ private def storyWithoutCommercial(story: PressedStory): PressedStory =
+ story.copy(
+ tags = story.tags.copy(
+ tags = story.tags.tags.map(tag =>
+ tag.copy(
+ properties = tag.properties.copy(commercial = None),
+ ),
+ ),
+ ),
+ )
+
def isPaidFor: Boolean = properties.isPaidFor
def branding(edition: Edition): Option[Branding] =
@@ -72,9 +96,23 @@ final case class CuratedContent(
], // This is currently an option, as we introduce the new field. It can then become a value type.
supportingContent: List[PressedContent],
cardStyle: CardStyle,
+ mediaAtom: Option[MediaAtom],
) extends PressedContent {
override def withoutTrailText: PressedContent = copy(card = card.withoutTrailText)
+
+ override def withoutCommercial: PressedContent = copy(
+ properties = propertiesWithoutCommercial(properties),
+ supportingContent = supportingContent.map(_.withoutCommercial),
+ )
+
+ override def withBoostLevel(level: Option[BoostLevel]): PressedContent = copy(
+ display = display.copy(boostLevel = level),
+ )
+
+ override def withCard(card: PressedCard): PressedContent = copy(
+ card = card,
+ )
}
object CuratedContent {
@@ -89,6 +127,13 @@ object CuratedContent {
supportingContent = content.supportingContent.map((sc) => PressedContent.make(sc, false)),
cardStyle = CardStyle.make(content.cardStyle),
enriched = Some(EnrichedContent.empty),
+ mediaAtom = content.mediaAtom.flatMap { atom =>
+ atom.data match {
+ case mediaAtom: com.gu.contentatom.thrift.AtomData.Media =>
+ Some(MediaAtom.makeFromThrift(atom.id, mediaAtom.media))
+ case _ => None
+ }
+ },
)
}
}
@@ -103,6 +148,16 @@ final case class SupportingCuratedContent(
cardStyle: CardStyle,
) extends PressedContent {
override def withoutTrailText: PressedContent = copy(card = card.withoutTrailText)
+
+ override def withoutCommercial: PressedContent = copy(properties = propertiesWithoutCommercial(properties))
+
+ override def withBoostLevel(level: Option[BoostLevel]): PressedContent = copy(
+ display = display.copy(boostLevel = level),
+ )
+
+ override def withCard(card: PressedCard): PressedContent = copy(
+ card = card,
+ )
}
object SupportingCuratedContent {
@@ -129,8 +184,19 @@ final case class LinkSnap(
enriched: Option[
EnrichedContent,
], // This is currently an option, as we introduce the new field. It can then become a value type.
+ mediaAtom: Option[MediaAtom],
) extends PressedContent {
override def withoutTrailText: PressedContent = copy(card = card.withoutTrailText)
+
+ override def withoutCommercial: PressedContent = copy(properties = propertiesWithoutCommercial(properties))
+
+ override def withBoostLevel(level: Option[BoostLevel]): PressedContent = copy(
+ display = display.copy(boostLevel = level),
+ )
+
+ override def withCard(card: PressedCard): PressedContent = copy(
+ card = card,
+ )
}
object LinkSnap {
@@ -143,6 +209,7 @@ object LinkSnap {
display = PressedDisplaySettings.make(content, None),
enriched = Some(EnrichedContent.empty),
format = ContentFormat.defaultContentFormat,
+ mediaAtom = None,
)
}
}
@@ -157,6 +224,16 @@ final case class LatestSnap(
) extends PressedContent {
override def withoutTrailText: PressedContent = copy(card = card.withoutTrailText)
+
+ override def withoutCommercial: PressedContent = copy(properties = propertiesWithoutCommercial(properties))
+
+ override def withBoostLevel(level: Option[BoostLevel]): PressedContent = copy(
+ display = display.copy(boostLevel = level),
+ )
+
+ override def withCard(card: PressedCard): PressedContent = copy(
+ card = card,
+ )
}
object LatestSnap {
diff --git a/common/app/model/trails.scala b/common/app/model/trails.scala
index e6ecc09893e7..7c1479d398b0 100644
--- a/common/app/model/trails.scala
+++ b/common/app/model/trails.scala
@@ -24,23 +24,23 @@ object Trail {
.orElse(elements.videos.headOption.map(_.images))
.orElse(elements.thumbnail.map(_.images))
- // Try to take the master 5:3 image. At render-time, the image resizing service will size the image according to card width.
+ // Try to take the master image (5:4 or 5:3). At render-time, the image resizing service will size the image according to card width.
// Filtering the list images here means that facia-press does not need to slim down the Trail object.
trailImageMedia.flatMap { imageMedia =>
val filteredTrailImages = imageMedia.allImages.filter { image =>
- IsRatio(5, 3, image.width, image.height)
+ IsRatio(5, 4, image.width, image.height) || IsRatio(5, 3, image.width, image.height)
}
val masterTrailImage = filteredTrailImages.find(_.isMaster).map { master =>
ImageMedia.make(List(master))
}
- // If there isn't a 5:3 image, no ImageMedia object will be created.
+ // If there isn't a 5:4 or 5:3 image, no ImageMedia object will be created.
lazy val largestTrailImage = filteredTrailImages.sortBy(-_.width).headOption.map { bestImage =>
ImageMedia.make(List(bestImage))
}
- // Choose the master 5:3 image, or the largest 5:3 image.
+ // Choose the master image (5:4 or 5:3), or the largest image (5:4 or 5:3).
masterTrailImage.orElse(largestTrailImage)
}
}
diff --git a/common/app/navigation/FooterLinks.scala b/common/app/navigation/FooterLinks.scala
index 7eb67e473953..31b181c8b98f 100644
--- a/common/app/navigation/FooterLinks.scala
+++ b/common/app/navigation/FooterLinks.scala
@@ -1,6 +1,11 @@
package navigation
import common.{Edition, editions}
+import common.editions.Uk.{networkFrontId => UK}
+import common.editions.Us.{networkFrontId => US}
+import common.editions.Au.{networkFrontId => AU}
+import common.editions.International.{networkFrontId => INT}
+import common.editions.Europe.{networkFrontId => EUR}
case class FooterLink(
text: String,
@@ -11,14 +16,18 @@ case class FooterLink(
object FooterLinks {
- // Footer column one
-
+ // Helpers
val complaintsAndCorrections =
FooterLink("Complaints & corrections", "/info/complaints-and-corrections", "complaints")
val secureDrop = FooterLink("SecureDrop", "https://www.theguardian.com/securedrop", "securedrop")
val privacyPolicy = FooterLink("Privacy policy", "/info/privacy", "privacy")
val cookiePolicy = FooterLink("Cookie policy", "/info/cookies", "cookie")
val termsAndConditions = FooterLink("Terms & conditions", "/help/terms-of-service", "terms")
+ val accessibilitySettings = FooterLink(
+ "Accessibility settings",
+ "/help/accessibility-help",
+ "accessibility settings",
+ )
def help(edition: String): FooterLink =
FooterLink(
@@ -29,186 +38,230 @@ object FooterLinks {
)
def workForUs(edition: String): FooterLink =
FooterLink("Work for us", "https://workforus.theguardian.com", s"${edition} : footer : work for us")
+ def allTopics(edition: String): FooterLink =
+ FooterLink("All topics", "/index/subjects/a", s"${edition} : footer : all topics")
+ def allWriters(edition: String): FooterLink =
+ FooterLink("All writers", "/index/contributors", s"${edition} : footer : all contributors")
+ val digitalNewspaperArchive: FooterLink =
+ FooterLink("Digital newspaper archive", "https://theguardian.newspapers.com", "digital newspaper archive")
+ def taxStrategy(edition: String): FooterLink =
+ FooterLink(
+ "Tax strategy",
+ "https://uploads.guim.co.uk/2025/09/05/Tax_strategy_for_the_year_ended_31_March_2025.pdf",
+ s"${edition} : footer : tax strategy",
+ )
+ def newsletters(edition: String): FooterLink = {
+ FooterLink(
+ text = "Newsletters",
+ url = s"/email-newsletters?INTCMP=DOTCOM_FOOTER_NEWSLETTER_${edition.toUpperCase}",
+ dataLinkName = s"$edition : footer : newsletters",
+ )
+ }
+ def modernSlaveryActStatement(edition: String): FooterLink = {
+ FooterLink(
+ "Modern Slavery Act",
+ "https://uploads.guim.co.uk/2025/09/05/Modern_Slavery_Statement_2025.pdf",
+ s"$edition : footer : modern slavery act statement",
+ )
+ }
+ def tipUsOff(edition: String): FooterLink = {
+ FooterLink("Tip us off", "https://www.theguardian.com/tips", s"$edition : footer : tips")
+ }
+ def searchJobs(edition: String): FooterLink = {
+ FooterLink("Search jobs", "https://jobs.theguardian.com", s"$edition : footer : jobs")
+ }
+
+ def socialLinks(edition: String): Iterable[FooterLink] = {
+ /*
+ * The `socials` list preserves the order of the links in the footer.
+ * Change the order here, if required.
+ */
+ val socials = List(
+ "bluesky" -> "Bluesky",
+ "facebook" -> "Facebook",
+ "instagram" -> "Instagram",
+ "linkedin" -> "LinkedIn",
+ "threads" -> "Threads",
+ "tiktok" -> "TikTok",
+ "youtube" -> "YouTube",
+ )
+
+ val defaultLinks: Map[String, String] = Map(
+ "bluesky" -> "https://bsky.app/profile/theguardian.com",
+ "facebook" -> "https://www.facebook.com/theguardian",
+ "instagram" -> "https://www.instagram.com/guardian",
+ "linkedin" -> "https://www.linkedin.com/company/theguardian",
+ "threads" -> "https://www.threads.com/@guardian",
+ "tiktok" -> "https://www.tiktok.com/@guardian",
+ "youtube" -> "https://www.youtube.com/user/TheGuardian",
+ )
+
+ /* Some editions have regional accounts. We can override the defaults here */
+ val editionOverrides: Map[String, Map[String, String]] = Map(
+ "au" -> Map(
+ "bluesky" -> "https://bsky.app/profile/australia.theguardian.com",
+ "facebook" -> "https://www.facebook.com/theguardianaustralia",
+ "instagram" -> "https://www.instagram.com/guardianaustralia",
+ "linkedin" -> "https://www.linkedin.com/company/guardianaustralia",
+ "threads" -> "https://www.threads.com/@guardianaustralia",
+ "tiktok" -> "https://www.tiktok.com/@guardianaustralia",
+ "youtube" -> "https://www.youtube.com/@GuardianAustralia",
+ ),
+ "us" -> Map(
+ "bluesky" -> "https://bsky.app/profile/us.theguardian.com",
+ "threads" -> "https://www.threads.com/@guardian_us",
+ ),
+ )
+ val urls: Map[String, String] = defaultLinks ++ editionOverrides.getOrElse(edition, Map.empty)
+
+ socials.map { case (key, displayName) =>
+ FooterLink(displayName, urls(key), s"$edition : footer : $displayName")
+ }
+ }
+
+ /* Column one */
val ukListOne = List(
- FooterLink("About us", "/about", "uk : footer : about us"),
- help("uk"),
+ FooterLink("About us", "/about", s"$UK : footer : about us"),
+ help(UK),
complaintsAndCorrections,
+ FooterLink("Contact us", "/help/contact-us", s"$UK : footer : contact us"),
+ tipUsOff(UK),
secureDrop,
- workForUs("uk"),
privacyPolicy,
cookiePolicy,
+ modernSlaveryActStatement(UK),
+ taxStrategy(UK),
termsAndConditions,
- FooterLink("Contact us", "/help/contact-us", "uk : footer : contact us"),
)
val usListOne = List(
- FooterLink("About us", "/info/about-guardian-us", "us : footer : about us"),
- help("us"),
+ FooterLink("About us", "/info/about-guardian-us", s"$US : footer : about us"),
+ help(US),
complaintsAndCorrections,
+ FooterLink("Contact us", "/info/about-guardian-us/contact", s"$US : footer : contact us"),
+ tipUsOff(US),
secureDrop,
- workForUs("us"),
privacyPolicy,
cookiePolicy,
+ taxStrategy(US),
termsAndConditions,
- FooterLink("Contact us", "/info/about-guardian-us/contact", "us : footer : contact us"),
)
val auListOne = List(
- FooterLink("About us", "/info/about-guardian-australia", "au : footer : about us"),
- FooterLink("Information", "/info", "au : footer : information"),
+ FooterLink("About us", "/info/about-guardian-australia", s"$AU : footer : about us"),
+ FooterLink("Information", "/info", s"$AU : footer : information"),
+ help(AU),
complaintsAndCorrections,
- help("au"),
+ FooterLink("Contact us", "/info/2013/may/26/contact-guardian-australia", s"$AU : footer : contact us"),
+ tipUsOff(AU),
secureDrop,
- workForUs("australia"),
- privacyPolicy,
- termsAndConditions,
- FooterLink("Contact us", "/info/2013/may/26/contact-guardian-australia", "au : footer : contact us"),
- )
-
- val intListOne = List(
- help("international"),
- complaintsAndCorrections,
- secureDrop,
- workForUs("international"),
privacyPolicy,
cookiePolicy,
+ taxStrategy(AU),
termsAndConditions,
- FooterLink("Contact us", "/help/contact-us", "international : footer : contact us"),
)
- // Footer column two
-
- def allTopics(edition: String): FooterLink =
- FooterLink("All topics", "/index/subjects/a", s"${edition} : footer : all topics")
- def allWriters(edition: String): FooterLink =
- FooterLink("All writers", "/index/contributors", s"${edition} : footer : all contributors")
- val digitalNewspaperArchive: FooterLink =
- FooterLink("Digital newspaper archive", "https://theguardian.newspapers.com", "digital newspaper archive")
- def taxStrategy(edition: String): FooterLink =
- FooterLink(
- "Tax strategy",
- "https://uploads.guim.co.uk/2024/08/27/TAX_STRATEGY_FOR_THE_YEAR_ENDED_31_MARCH_2025.pdf",
- s"${edition} : footer : tax strategy",
- )
- def facebook(edition: String): FooterLink =
- FooterLink("Facebook", "https://www.facebook.com/theguardian", s"${edition} : footer : facebook")
- def youtube(edition: String): FooterLink =
- FooterLink("YouTube", "https://www.youtube.com/user/TheGuardian", s"${edition} : footer : youtube")
- def linkedin(edition: String): FooterLink =
- FooterLink("LinkedIn", "https://www.linkedin.com/company/theguardian", s"${edition} : footer : linkedin")
- def instagram(edition: String): FooterLink =
- FooterLink("Instagram", "https://www.instagram.com/guardian", s"${edition} : footer : instagram")
- def newsletters(edition: String): FooterLink =
- FooterLink(
- text = "Newsletters",
- url = s"/email-newsletters?INTCMP=DOTCOM_FOOTER_NEWSLETTER_${edition.toUpperCase}",
- dataLinkName = s"$edition : footer : newsletters",
+ def genericListOne(edition: String): List[FooterLink] = {
+ List(
+ FooterLink("About us", "/about", s"$edition : footer : about us"),
+ help(edition),
+ complaintsAndCorrections,
+ FooterLink("Contact us", "/help/contact-us", s"$edition : footer : contact us"),
+ tipUsOff(edition),
+ secureDrop,
+ privacyPolicy,
+ cookiePolicy,
+ taxStrategy(edition),
+ termsAndConditions,
)
+ }
+ /* Column two */
val ukListTwo = List(
- allTopics("uk"),
- allWriters("uk"),
- FooterLink(
- "Modern Slavery Act",
- "https://uploads.guim.co.uk/2024/09/04/Modern_Slavery_Statement_2024_.pdf",
- "uk : footer : modern slavery act statement",
- ),
- taxStrategy("uk"),
+ allTopics(UK),
+ allWriters(UK),
+ newsletters(UK),
digitalNewspaperArchive,
- facebook("uk"),
- youtube("uk"),
- instagram("uk"),
- linkedin("uk"),
- newsletters("uk"),
- )
+ ) ++ socialLinks(UK)
val usListTwo = List(
- allTopics("us"),
- allWriters("us"),
+ allTopics(US),
+ allWriters(US),
+ newsletters(US),
digitalNewspaperArchive,
- taxStrategy("us"),
- facebook("us"),
- youtube("us"),
- instagram("us"),
- linkedin("us"),
- newsletters("us"),
- )
+ ) ++ socialLinks(US)
val auListTwo = List(
- allTopics("au"),
- allWriters("au"),
- FooterLink("Events", "/guardian-masterclasses/guardian-masterclasses-australia", "au : footer : masterclasses"),
+ allTopics(AU),
+ allWriters(AU),
+ newsletters(AU),
digitalNewspaperArchive,
- taxStrategy("au"),
- facebook("au"),
- youtube("au"),
- instagram("au"),
- linkedin("au"),
- newsletters("au"),
- )
+ ) ++ socialLinks(AU)
- val intListTwo = List(
- allTopics("international"),
- allWriters("international"),
- digitalNewspaperArchive,
- taxStrategy("international"),
- facebook("international"),
- youtube("international"),
- instagram("international"),
- linkedin("international"),
- newsletters("international"),
- )
+ def genericListTwo(edition: String): List[FooterLink] = {
+ List(
+ allTopics(edition),
+ allWriters(edition),
+ newsletters(edition),
+ digitalNewspaperArchive,
+ ) ++ socialLinks(edition)
+ }
- // Footer column three
+ /* Column three */
val ukListThree = List(
- FooterLink("Advertise with us", "https://advertising.theguardian.com", "uk : footer : advertise with us"),
- FooterLink("Guardian Labs", "/guardian-labs", "uk : footer : guardian labs"),
- FooterLink("Search jobs", "https://jobs.theguardian.com", "uk : footer : jobs"),
- FooterLink("Patrons", "https://patrons.theguardian.com?INTCMP=footer_patrons", "uk : footer : patrons"),
+ FooterLink("Advertise with us", "https://advertising.theguardian.com", s"$UK : footer : advertise with us"),
+ FooterLink("Guardian Labs", "/guardian-labs", s"$UK : footer : guardian labs"),
+ searchJobs(UK),
+ FooterLink("Patrons", "https://patrons.theguardian.com?INTCMP=footer_patrons", s"$UK : footer : patrons"),
+ workForUs(UK),
+ accessibilitySettings,
)
val usListThree = List(
FooterLink(
"Advertise with us",
"https://usadvertising.theguardian.com",
- "us : footer : advertise with us",
+ s"$US : footer : advertise with us",
),
- FooterLink("Guardian Labs", "/guardian-labs-us", "us : footer : guardian labs"),
- FooterLink("Search jobs", "https://jobs.theguardian.com", "us : footer : jobs"),
+ FooterLink("Guardian Labs", "/guardian-labs-us", s"$US : footer : guardian labs"),
+ searchJobs(US),
+ workForUs(US),
+ accessibilitySettings,
)
val auListThree = List(
- FooterLink("Guardian Labs", "/guardian-labs-australia", "au : footer : guardian labs"),
FooterLink(
"Advertise with us",
"https://ausadvertising.theguardian.com/",
- "au : footer : advertise with us",
+ s"$AU : footer : advertise with us",
),
- cookiePolicy,
+ FooterLink("Guardian Labs", "/guardian-labs-australia", s"$AU : footer : guardian labs"),
+ workForUs(AU),
+ accessibilitySettings,
)
- val intListThree = List(
- FooterLink(
- "Advertise with us",
- "https://advertising.theguardian.com",
- "international : footer : advertise with us",
- ),
- FooterLink(
- "Search UK jobs",
- "https://jobs.theguardian.com",
- "international : footer : uk-jobs",
- ),
- )
+ def genericListThree(edition: String): List[FooterLink] = {
+ List(
+ FooterLink(
+ "Advertise with us",
+ "https://advertising.theguardian.com",
+ s"$edition : footer : advertise with us",
+ ),
+ FooterLink("Search UK jobs", "https://jobs.theguardian.com", s"$edition : footer : jobs"),
+ FooterLink("Tips", "https://www.theguardian.com/tips", s"$edition : footer : tips"),
+ accessibilitySettings,
+ workForUs(edition),
+ )
+ }
def getFooterByEdition(edition: Edition): Seq[Seq[FooterLink]] =
edition match {
case editions.Uk => Seq(ukListOne, ukListTwo, ukListThree)
case editions.Us => Seq(usListOne, usListTwo, usListThree)
case editions.Au => Seq(auListOne, auListTwo, auListThree)
- case editions.International => Seq(intListOne, intListTwo, intListThree)
- case _ => Seq(intListOne, intListTwo, intListThree)
+ case editions.International => Seq(genericListOne(INT), genericListTwo(INT), genericListThree(INT))
+ case editions.Europe => Seq(genericListOne(EUR), genericListTwo(EUR), genericListThree(EUR))
}
-
}
diff --git a/common/app/navigation/NavLinks.scala b/common/app/navigation/NavLinks.scala
index 88308651749b..5532591ddced 100644
--- a/common/app/navigation/NavLinks.scala
+++ b/common/app/navigation/NavLinks.scala
@@ -17,9 +17,9 @@ object NavLinks {
val auPolitics = NavLink("AU politics", "/australia-news/australian-politics", longTitle = Some("Politics"))
val auImmigration = NavLink("Immigration", "/australia-news/australian-immigration-and-asylum")
val indigenousAustralia = NavLink("Indigenous Australia", "/australia-news/indigenous-australians")
- val indigenousAustraliaOpinion = NavLink("Indigenous", "/commentisfree/series/indigenousx")
- val usNews = NavLink("US", "/us-news", longTitle = Some("US news"))
+ val usNews = NavLink("US news", "/us-news", longTitle = Some("US news"))
val usPolitics = NavLink("US politics", "/us-news/us-politics")
+ val usImmigration = NavLink("US immigration", "/us-news/usimmigration")
val education = {
val teachers = NavLink("Teachers", "/teacher-network")
@@ -176,6 +176,7 @@ object NavLinks {
val fashion = NavLink("Fashion", "/fashion")
val fashionAu = NavLink("Fashion", "/au/lifeandstyle/fashion")
val theFilterUk = NavLink("The Filter", "/uk/thefilter")
+ val theFilterUs = NavLink("The Filter", "/thefilter-us")
val food = NavLink("Food", "/food")
val foodAu = NavLink("Food", "/au/food")
val relationshipsAu = NavLink("Relationships", "/au/lifeandstyle/relationships")
@@ -211,16 +212,6 @@ object NavLinks {
),
)
val insideTheGuardian = NavLink("Inside the Guardian", "https://www.theguardian.com/insidetheguardian")
- val observer = NavLink(
- "The Observer",
- "/observer",
- children = List(
- NavLink("Comment", "/theobserver/news/comment"),
- NavLink("The New Review", "/theobserver/new-review"),
- NavLink("Observer Magazine", "/theobserver/magazine"),
- NavLink("Observer Food Monthly", "/theobserver/foodmonthly"),
- ),
- )
val weekly = NavLink("Guardian Weekly", "https://www.theguardian.com/weekly")
val digitalNewspaperArchive = NavLink("Digital Archive", "https://theguardian.newspapers.com")
val crosswords = NavLink(
@@ -229,13 +220,12 @@ object NavLinks {
children = List(
NavLink("Blog", "/crosswords/crossword-blog"),
NavLink("Quick", "/crosswords/series/quick"),
- NavLink("Speedy", "/crosswords/series/speedy"),
+ NavLink("Sunday quick", "/crosswords/series/sunday-quick"),
+ NavLink("Mini", "/crosswords/series/mini-crossword"),
NavLink("Quick cryptic", "/crosswords/series/quick-cryptic"),
- NavLink("Everyman", "/crosswords/series/everyman"),
NavLink("Quiptic", "/crosswords/series/quiptic"),
NavLink("Cryptic", "/crosswords/series/cryptic"),
NavLink("Prize", "/crosswords/series/prize"),
- NavLink("Azed", "/crosswords/series/azed"),
NavLink("Genius", "/crosswords/series/genius"),
NavLink("Weekend", "/crosswords/series/weekend-crossword"),
NavLink("Special", "/crosswords/series/special"),
@@ -254,22 +244,26 @@ object NavLinks {
val jobs = NavLink("Search jobs", "https://jobs.theguardian.com")
val apps =
NavLink("The Guardian app", "https://app.adjust.com/16xt6hai")
- val auWeekend = NavLink(
- "Australia Weekend",
- "/info/ng-interactive/2021/mar/17/make-sense-of-the-week-with-australia-weekend?INTCMP=header_au_weekend",
- )
val printShop = NavLink("Guardian Print Shop", "/artanddesign/series/gnm-print-sales")
- val auEvents = NavLink("Events", "/guardian-live-australia")
val holidays = NavLink("Holidays", "https://holidays.theguardian.com")
val ukPatrons = NavLink("Patrons", "https://patrons.theguardian.com/?INTCMP=header_patrons")
- val guardianLive =
+ val guardianLiveUK =
NavLink("Live events", "https://www.theguardian.com/guardian-live-events?INTCMP=live_uk_header_dropdown")
+ val guardianLiveAU =
+ NavLink("Live events", "https://www.theguardian.com/guardian-live-events?INTCMP=live_au_header_dropdown")
+ val guardianLiveUS =
+ NavLink("Live events", "https://www.theguardian.com/guardian-live-events?INTCMP=live_us_header_dropdown")
+ val guardianLiveEUR =
+ NavLink("Live events", "https://www.theguardian.com/guardian-live-events?INTCMP=live_eur_header_dropdown")
+ val guardianLiveINT =
+ NavLink("Live events", "https://www.theguardian.com/guardian-live-events?INTCMP=live_int_header_dropdown")
val guardianLicensing = NavLink("Guardian Licensing", s"https://licensing.theguardian.com/")
val jobsRecruiter = NavLink(
"Hire with Guardian Jobs",
"https://recruiters.theguardian.com/?utm_source=gdnwb&utm_medium=navbar&utm_campaign=Guardian_Navbar_Recruiters&CMP_TU=trdmkt&CMP_BUNIT=jobs",
)
val aboutUs = NavLink("About Us", "/about")
+ val tips = NavLink("Tips", "https://www.theguardian.com/tips")
// News Pillar
val ukNewsPillar = NavLink(
@@ -321,6 +315,7 @@ object NavLinks {
climateCrisis,
middleEast,
ukraine,
+ usImmigration,
usSoccer,
usBusiness,
usEnvironment,
@@ -381,7 +376,6 @@ object NavLinks {
children = List(
auColumnists,
cartoons,
- indigenousAustraliaOpinion,
theGuardianView.copy(title = "Editorials"),
letters,
),
@@ -588,6 +582,7 @@ object NavLinks {
)
val usLifestylePillar = ukLifestylePillar.copy(
children = List(
+ theFilterUs,
usWellness,
fashion,
food,
@@ -639,11 +634,11 @@ object NavLinks {
newsletters,
todaysPaper,
insideTheGuardian,
- observer,
weekly.copy(url = s"${weekly.url}?INTCMP=gdnwb_mawns_editorial_gweekly_GW_TopNav_UK"),
crosswords,
wordiply,
corrections,
+ tips,
)
val auOtherLinks = List(
apps,
@@ -656,6 +651,7 @@ object NavLinks {
crosswords,
wordiply,
corrections,
+ tips,
)
val usOtherLinks = List(
apps,
@@ -667,6 +663,7 @@ object NavLinks {
crosswords,
wordiply,
corrections,
+ tips,
)
val intOtherLinks = List(
apps,
@@ -676,11 +673,11 @@ object NavLinks {
newsletters,
todaysPaper,
insideTheGuardian,
- observer,
weekly.copy(url = s"${weekly.url}?INTCMP=gdnwb_mawns_editorial_gweekly_GW_TopNav_Int"),
crosswords,
wordiply,
corrections,
+ tips,
)
val eurOtherLinks = List(
apps,
@@ -690,18 +687,18 @@ object NavLinks {
newsletters,
todaysPaper,
insideTheGuardian,
- observer,
weekly.copy(url = s"${weekly.url}?INTCMP=gdnwb_mawns_editorial_gweekly_GW_TopNav_Int"),
crosswords,
wordiply,
corrections,
+ tips,
)
val ukBrandExtensions = List(
jobs,
jobsRecruiter,
holidays.copy(url = holidays.url + "?INTCMP=holidays_uk_web_newheader"),
- guardianLive,
+ guardianLiveUK,
aboutUs,
digitalNewspaperArchive,
printShop,
@@ -709,16 +706,16 @@ object NavLinks {
guardianLicensing,
)
val auBrandExtensions = List(
- auEvents,
digitalNewspaperArchive,
- auWeekend,
guardianLicensing,
+ guardianLiveAU,
aboutUs,
)
val usBrandExtensions = List(
jobs,
digitalNewspaperArchive,
guardianLicensing,
+ guardianLiveUS,
aboutUs,
)
val intBrandExtensions = List(
@@ -726,6 +723,7 @@ object NavLinks {
holidays.copy(url = holidays.url + "?INTCMP=holidays_int_web_newheader"),
digitalNewspaperArchive,
guardianLicensing,
+ guardianLiveINT,
aboutUs,
)
val eurBrandExtensions = List(
@@ -733,6 +731,7 @@ object NavLinks {
holidays.copy(url = holidays.url + "?INTCMP=holidays_int_web_newheader"),
digitalNewspaperArchive,
guardianLicensing,
+ guardianLiveEUR,
aboutUs,
)
@@ -829,10 +828,8 @@ object NavLinks {
"crosswords/series/weekend-crossword",
"crosswords/series/quiptic",
"crosswords/series/genius",
- "crosswords/series/speedy",
- "crosswords/series/everyman",
+ "crosswords/series/sunday-quick",
"crosswords/series/special",
- "crosswords/series/azed",
"fashion/beauty",
"technology/motoring",
// these last two are here to ensure that content in education and CiF always appear as such in the navigation
diff --git a/common/app/renderers/DotcomRenderingService.scala b/common/app/renderers/DotcomRenderingService.scala
index c88ec4a497ae..882aa8a59325 100644
--- a/common/app/renderers/DotcomRenderingService.scala
+++ b/common/app/renderers/DotcomRenderingService.scala
@@ -11,6 +11,7 @@ import http.{HttpPreconnections, ResultWithPreconnectPreload}
import model.Cached.{RevalidatableResult, WithoutRevalidationResult}
import model.dotcomrendering._
import model.dotcomrendering.pageElements.EditionsCrosswordRenderingDataModel
+import model.meta.BlocksOn
import model.{
CacheTime,
Cached,
@@ -30,7 +31,7 @@ import play.api.libs.ws.{WSClient, WSResponse}
import play.api.mvc.Results.{InternalServerError, NotFound}
import play.api.mvc.{RequestHeader, Result}
import play.twirl.api.Html
-import services.newsletters.model.{NewsletterResponseV2, NewsletterLayout}
+import services.newsletters.model.{NewsletterLayout, NewsletterResponseV2}
import services.{IndexPage, NewsletterData}
import java.lang.System.currentTimeMillis
@@ -55,6 +56,14 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
resetTimeout = Configuration.rendering.timeout * 4,
)
+ private[this] val circuitBreakerLongTimeout = CircuitBreakerRegistry.withConfig(
+ name = "dotcom-rendering-client-long-timeout",
+ system = PekkoActorSystem("dotcom-rendering-client-circuit-breaker-long-timeout"),
+ maxFailures = Configuration.rendering.circuitBreakerMaxFailures,
+ callTimeout = (Configuration.rendering.timeout * 2).plus(200.millis),
+ resetTimeout = Configuration.rendering.timeout * 4,
+ )
+
private[this] def postWithoutHandler(
ws: WSClient,
payload: JsValue,
@@ -139,7 +148,8 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
}
if (CircuitBreakerDcrSwitch.isSwitchedOn) {
- circuitBreaker
+ val breaker = if (timeout > Configuration.rendering.timeout) circuitBreakerLongTimeout else circuitBreaker
+ breaker
.withCircuitBreaker(postWithoutHandler(ws, payload, endpoint, requestId, timeout))
.map(handler)
} else {
@@ -149,18 +159,34 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
def getAMPArticle(
ws: WSClient,
- page: PageWithStoryPackage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[PageWithStoryPackage],
pageType: PageType,
newsletter: Option[NewsletterData],
filterKeyEvents: Boolean = false,
)(implicit request: RequestHeader): Future[Result] =
- baseArticleRequest("/AMPArticle", ws, page, blocks, pageType, filterKeyEvents, false, newsletter)
+ baseArticleRequest("/AMPArticle", ws, pageBlocks, pageType, filterKeyEvents, false, newsletter)
+
+ def getDCARAssets(ws: WSClient, path: String)(implicit request: RequestHeader): Future[Result] = {
+ ws
+ .url(Configuration.rendering.articleBaseURL + path)
+ .withRequestTimeout(Configuration.rendering.timeout)
+ .get()
+ .map { response =>
+ response.status match {
+ case 200 =>
+ Cached(CacheTime.Default)(RevalidatableResult.Ok(Html(response.body)))
+ case _ =>
+ log.error(
+ s"Request to DCR assets failed: status ${response.status}, path: ${request.path}",
+ )
+ NoCache(InternalServerError("Remote renderer error (500)"))
+ }
+ }
+ }
def getAppsArticle(
ws: WSClient,
- page: PageWithStoryPackage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[PageWithStoryPackage],
pageType: PageType,
newsletter: Option[NewsletterData],
filterKeyEvents: Boolean = false,
@@ -169,8 +195,7 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
baseArticleRequest(
"/AppsArticle",
ws,
- page,
- blocks,
+ pageBlocks,
pageType,
filterKeyEvents,
forceLive,
@@ -179,8 +204,7 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
def getArticle(
ws: WSClient,
- page: PageWithStoryPackage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[PageWithStoryPackage],
pageType: PageType,
newsletter: Option[NewsletterData],
filterKeyEvents: Boolean = false,
@@ -189,8 +213,7 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
baseArticleRequest(
"/Article",
ws,
- page,
- blocks,
+ pageBlocks,
pageType,
filterKeyEvents,
forceLive,
@@ -200,29 +223,27 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
private def baseArticleRequest(
path: String,
ws: WSClient,
- page: PageWithStoryPackage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[PageWithStoryPackage],
pageType: PageType,
filterKeyEvents: Boolean,
forceLive: Boolean = false,
newsletter: Option[NewsletterData],
)(implicit request: RequestHeader): Future[Result] = {
- val dataModel = page match {
+ val dataModel = pageBlocks.page match {
case liveblog: LiveBlogPage =>
DotcomRenderingDataModel.forLiveblog(
- liveblog,
- blocks,
+ pageBlocks.copy(page = liveblog),
request,
pageType,
filterKeyEvents,
forceLive,
newsletter,
)
- case _ => DotcomRenderingDataModel.forArticle(page, blocks, request, pageType, newsletter)
+ case _ => DotcomRenderingDataModel.forArticle(pageBlocks, request, pageType, newsletter)
}
val json = DotcomRenderingDataModel.toJson(dataModel)
- post(ws, json, Configuration.rendering.articleBaseURL + path, page.metadata.cacheTime)
+ post(ws, json, Configuration.rendering.articleBaseURL + path, pageBlocks.page.metadata.cacheTime)
}
def getBlocks(
@@ -269,20 +290,11 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
})
}
- private def getTimeout: Duration = {
- if (Configuration.environment.stage == "DEV")
- Configuration.rendering.timeout * 5
- else
- Configuration.rendering.timeout
- }
-
def getFront(
ws: WSClient,
page: PressedPage,
pageType: PageType,
mostViewed: Seq[RelatedContentItem],
- mostCommented: Option[Content],
- mostShared: Option[Content],
deeplyRead: Option[Seq[Trail]],
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomFrontsRenderingDataModel(
@@ -290,13 +302,15 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
request,
pageType,
mostViewed,
- mostCommented,
- mostShared,
deeplyRead,
)
val json = DotcomFrontsRenderingDataModel.toJson(dataModel)
- val timeout = getTimeout
+ val timeout =
+ if (Configuration.environment.stage == "DEV")
+ Configuration.rendering.timeout * 5
+ else
+ Configuration.rendering.timeout * 2
post(ws, json, Configuration.rendering.faciaBaseURL + "/Front", CacheTime.Facia, timeout)
}
@@ -317,46 +331,53 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
def getInteractive(
ws: WSClient,
- page: InteractivePage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[InteractivePage],
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
- val dataModel = DotcomRenderingDataModel.forInteractive(page, blocks, request, pageType)
+ val dataModel = DotcomRenderingDataModel.forInteractive(pageBlocks, request, pageType)
val json = DotcomRenderingDataModel.toJson(dataModel)
// Nb. interactives have a longer timeout because some of them are very
- // large unfortunately. E.g.
+ // large, unfortunately. E.g.
// https://www.theguardian.com/education/ng-interactive/2018/may/29/university-guide-2019-league-table-for-computer-science-information.
- post(ws, json, Configuration.rendering.interactiveBaseURL + "/Interactive", page.metadata.cacheTime, 4.seconds)
+ post(
+ ws,
+ json,
+ Configuration.rendering.interactiveBaseURL + "/Interactive",
+ pageBlocks.page.metadata.cacheTime,
+ 4.seconds,
+ )
}
def getAMPInteractive(
ws: WSClient,
- page: InteractivePage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[InteractivePage],
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
-
- val dataModel = DotcomRenderingDataModel.forInteractive(page, blocks, request, pageType)
+ val dataModel = DotcomRenderingDataModel.forInteractive(pageBlocks, request, pageType)
val json = DotcomRenderingDataModel.toJson(dataModel)
- post(ws, json, Configuration.rendering.interactiveBaseURL + "/AMPInteractive", page.metadata.cacheTime)
+ post(ws, json, Configuration.rendering.interactiveBaseURL + "/AMPInteractive", pageBlocks.page.metadata.cacheTime)
}
def getAppsInteractive(
ws: WSClient,
- page: InteractivePage,
- blocks: Blocks,
+ pageBlocks: BlocksOn[InteractivePage],
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
-
- val dataModel = DotcomRenderingDataModel.forInteractive(page, blocks, request, pageType)
+ val dataModel = DotcomRenderingDataModel.forInteractive(pageBlocks, request, pageType)
val json = DotcomRenderingDataModel.toJson(dataModel)
// Nb. interactives have a longer timeout because some of them are very
// large unfortunately. E.g.
// https://www.theguardian.com/education/ng-interactive/2018/may/29/university-guide-2019-league-table-for-computer-science-information.
- post(ws, json, Configuration.rendering.interactiveBaseURL + "/AppsInteractive", page.metadata.cacheTime, 4.seconds)
+ post(
+ ws,
+ json,
+ Configuration.rendering.interactiveBaseURL + "/AppsInteractive",
+ pageBlocks.page.metadata.cacheTime,
+ 4.seconds,
+ )
}
def getEmailNewsletters(
@@ -379,7 +400,7 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forImageContent(imageContent, request, pageType, mainBlock)
val json = DotcomRenderingDataModel.toJson(dataModel)
- post(ws, json, Configuration.rendering.articleBaseURL + "/Article", CacheTime.Facia)
+ post(ws, json, Configuration.rendering.articleBaseURL + "/Article", imageContent.metadata.cacheTime)
}
def getAppsImageContent(
@@ -390,7 +411,7 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forImageContent(imageContent, request, pageType, mainBlock)
val json = DotcomRenderingDataModel.toJson(dataModel)
- post(ws, json, Configuration.rendering.articleBaseURL + "/AppsArticle", CacheTime.Facia)
+ post(ws, json, Configuration.rendering.articleBaseURL + "/AppsArticle", imageContent.metadata.cacheTime)
}
def getMedia(
@@ -402,7 +423,7 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
val dataModel = DotcomRenderingDataModel.forMedia(mediaPage, request, pageType, blocks)
val json = DotcomRenderingDataModel.toJson(dataModel)
- post(ws, json, Configuration.rendering.articleBaseURL + "/Article", CacheTime.Facia)
+ post(ws, json, Configuration.rendering.articleBaseURL + "/Article", mediaPage.metadata.cacheTime)
}
def getAppsMedia(
@@ -414,7 +435,7 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
val dataModel = DotcomRenderingDataModel.forMedia(mediaPage, request, pageType, blocks)
val json = DotcomRenderingDataModel.toJson(dataModel)
- post(ws, json, Configuration.rendering.articleBaseURL + "/AppsArticle", CacheTime.Facia)
+ post(ws, json, Configuration.rendering.articleBaseURL + "/AppsArticle", mediaPage.metadata.cacheTime)
}
def getGallery(
@@ -426,7 +447,19 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
val dataModel = DotcomRenderingDataModel.forGallery(gallery, request, pageType, blocks)
val json = DotcomRenderingDataModel.toJson(dataModel)
- post(ws, json, Configuration.rendering.articleBaseURL + "/Article", CacheTime.Facia)
+ post(ws, json, Configuration.rendering.articleBaseURL + "/Article", gallery.metadata.cacheTime)
+ }
+
+ def getAppsGallery(
+ ws: WSClient,
+ gallery: GalleryPage,
+ pageType: PageType,
+ blocks: Blocks,
+ )(implicit request: RequestHeader): Future[Result] = {
+ val dataModel = DotcomRenderingDataModel.forGallery(gallery, request, pageType, blocks)
+
+ val json = DotcomRenderingDataModel.toJson(dataModel)
+ post(ws, json, Configuration.rendering.articleBaseURL + "/AppsArticle", gallery.metadata.cacheTime)
}
def getCrossword(
@@ -451,7 +484,28 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
ws: WSClient,
json: JsValue,
)(implicit request: RequestHeader): Future[Result] = {
- post(ws, json, Configuration.rendering.articleBaseURL + "/FootballDataPage", CacheTime.Football)
+ post(ws, json, Configuration.rendering.articleBaseURL + "/FootballMatchListPage", CacheTime.Football)
+ }
+
+ def getFootballMatchSummaryPage(
+ ws: WSClient,
+ json: JsValue,
+ )(implicit request: RequestHeader): Future[Result] = {
+ post(ws, json, Configuration.rendering.articleBaseURL + "/FootballMatchSummaryPage", CacheTime.FootballMatch)
+ }
+
+ def getCricketPage(
+ ws: WSClient,
+ json: JsValue,
+ )(implicit request: RequestHeader): Future[Result] = {
+ post(ws, json, Configuration.rendering.articleBaseURL + "/CricketMatchPage", CacheTime.Cricket)
+ }
+
+ def getFootballTablesPage(
+ ws: WSClient,
+ json: JsValue,
+ )(implicit request: RequestHeader): Future[Result] = {
+ post(ws, json, Configuration.rendering.articleBaseURL + "/FootballTablesPage", CacheTime.FootballTables)
}
}
diff --git a/common/app/services/FaciaContentConvert.scala b/common/app/services/FaciaContentConvert.scala
index 3f97b493c7dd..88b9676e7c83 100644
--- a/common/app/services/FaciaContentConvert.scala
+++ b/common/app/services/FaciaContentConvert.scala
@@ -39,6 +39,8 @@ object FaciaContentConvert {
editionBranding.edition.id -> editionBranding.branding
}
.toMap,
+ atomId = None,
+ mediaAtom = None,
)
PressedContent.make(curated, false)
diff --git a/common/app/services/IndexPage.scala b/common/app/services/IndexPage.scala
index 0325eb8c37d5..d0c2eccc7c38 100644
--- a/common/app/services/IndexPage.scala
+++ b/common/app/services/IndexPage.scala
@@ -68,7 +68,7 @@ object IndexPage {
accumulation: Vector[((ContainerDisplayConfig, CollectionEssentials), Container)] = Vector.empty,
): Seq[((ContainerDisplayConfig, CollectionEssentials), Container)] = {
groupings.toList match {
- case Nil => accumulation
+ case Nil => accumulation
case grouping :: remainingGroupings =>
val collection = CollectionEssentials.fromFaciaContent(
grouping.items.flatMap { item =>
diff --git a/common/app/services/NewsletterService.scala b/common/app/services/NewsletterService.scala
index 56b605048224..f9853cffaa54 100644
--- a/common/app/services/NewsletterService.scala
+++ b/common/app/services/NewsletterService.scala
@@ -54,7 +54,7 @@ class NewsletterService(newsletterSignupAgent: NewsletterSignupAgent) {
private def getNewsletterResponseFromSignUpPage(articleId: String): Option[NewsletterResponseV2] = {
newsletterSignupAgent.getV2Newsletters() match {
- case Left(_) => None
+ case Left(_) => None
case Right(list) =>
list
.filter(shouldInclude)
diff --git a/common/app/services/Notification.scala b/common/app/services/Notification.scala
index f2c632fc04b8..c69a12f279ee 100644
--- a/common/app/services/Notification.scala
+++ b/common/app/services/Notification.scala
@@ -1,63 +1,54 @@
package services
-import com.amazonaws.services.sns.{AmazonSNSAsync, AmazonSNSAsyncClient}
-import com.amazonaws.services.sns.model.PublishRequest
-import common.{PekkoAsync, GuLogging}
+import common.{GuLogging, PekkoAsync}
import conf.Configuration
-import awswrappers.sns._
+import software.amazon.awssdk.regions.Region
+import software.amazon.awssdk.services.sns.SnsAsyncClient
+import software.amazon.awssdk.services.sns.model.PublishRequest
+import utils.AWSv2
import scala.concurrent.ExecutionContext
import scala.util.{Failure, Success}
+import scala.jdk.FutureConverters._
trait Notification extends GuLogging {
val topic: String
- lazy val sns: Option[AmazonSNSAsync] = Configuration.aws.credentials.map { credentials =>
- AmazonSNSAsyncClient
- .asyncBuilder()
- .withCredentials(credentials)
- .withRegion(conf.Configuration.aws.region)
+ lazy val sns: SnsAsyncClient =
+ SnsAsyncClient
+ .builder()
+ .credentialsProvider(AWSv2.credentials)
+ .region(Region.of(conf.Configuration.aws.region))
.build()
- }
-
- def send(
- pekkoAsync: PekkoAsync,
- )(subject: String, message: String)(implicit executionContext: ExecutionContext): Unit = {
- val request = new PublishRequest()
- .withTopicArn(topic)
- .withSubject(subject)
- .withMessage(message)
-
- sendAsync(pekkoAsync)(request)
- }
def sendWithoutSubject(pekkoAsync: PekkoAsync)(message: String)(implicit executionContext: ExecutionContext): Unit = {
- val request = new PublishRequest()
- .withTopicArn(topic)
- .withMessage(message)
+ val request = PublishRequest
+ .builder()
+ .topicArn(topic)
+ .message(message)
+ .build()
- sendAsync(pekkoAsync)(request)
+ publishTopic(pekkoAsync)(request)
}
- private def sendAsync(
+ private def publishTopic(
pekkoAsync: PekkoAsync,
- )(request: PublishRequest)(implicit executionContext: ExecutionContext): Unit =
+ )(request: PublishRequest)(implicit executionContext: ExecutionContext): Unit = {
pekkoAsync.after1s {
- sns match {
- case Some(client) =>
- log.info(s"Issuing SNS notification: ${request.getSubject}:${request.getMessage}")
- client.publishFuture(request) onComplete {
- case Success(_) =>
- log.info(s"Successfully published SNS notification: ${request.getSubject}:${request.getMessage}")
+ log.info(s"Issuing SNS notification: ${request.subject()}:${request.message()}")
- case Failure(error) =>
- log.error(s"Failed to publish SNS notification: ${request.getSubject}:${request.getMessage}", error)
- }
- case None =>
- log.error(s"There is NO SNS client available to publish ${request.getSubject}:${request.getMessage}")
- }
+ sns
+ .publish(request)
+ .asScala
+ .onComplete {
+ case Success(_) =>
+ log.info(s"Successfully published SNS notification: ${request.subject()}:${request.message()}")
+ case Failure(error) =>
+ log.error(s"Failed to publish SNS notification: ${request.subject}:${request.message}", error)
+ }
}
+ }
}
object FrontPressNotification extends Notification {
diff --git a/common/app/services/OphanApi.scala b/common/app/services/OphanApi.scala
index 5e860d21182b..89459e8af6e9 100644
--- a/common/app/services/OphanApi.scala
+++ b/common/app/services/OphanApi.scala
@@ -76,12 +76,6 @@ class OphanApi(wsClient: WSClient)(implicit executionContext: ExecutionContext)
def getBreakdown(path: String): Future[JsValue] = getBreakdown(Map("path" -> s"/$path"))
- def getMostReadFacebook(hours: Int): Future[Seq[OphanMostReadItem]] =
- getMostRead("Facebook", hours)
-
- def getMostReadTwitter(hours: Int): Future[Seq[OphanMostReadItem]] =
- getMostRead("Twitter", hours)
-
def getMostRead(referrer: String, hours: Int): Future[Seq[OphanMostReadItem]] =
getMostRead(Map("referrer" -> referrer, "hours" -> hours.toString))
@@ -94,9 +88,6 @@ class OphanApi(wsClient: WSClient)(implicit executionContext: ExecutionContext)
def getMostReadInSection(section: String, days: Int, count: Int): Future[Seq[OphanMostReadItem]] =
getMostRead(Map("days" -> days.toString, "count" -> count.toString, "section" -> section))
- def getMostReferredFromSocialMedia(days: Int): Future[Seq[OphanMostReadItem]] =
- getMostRead(Map("days" -> days.toString, "referrer" -> "social media"))
-
def getMostViewedGalleries(hours: Int, count: Int): Future[Seq[OphanMostReadItem]] =
getMostRead(Map("content-type" -> "gallery", "hours" -> hours.toString, "count" -> count.toString))
@@ -106,17 +97,6 @@ class OphanApi(wsClient: WSClient)(implicit executionContext: ExecutionContext)
def getDeeplyRead(edition: Edition): Future[Seq[OphanDeeplyReadItem]] =
getBody("deeplyread")(Map("country" -> countryFromEdition(edition))).map(_.as[Seq[OphanDeeplyReadItem]])
- def getAdsRenderTime(params: Map[String, Seq[String]]): Future[JsValue] = {
- val validatedParams = for {
- (key, values) <- params
- if Seq("platform", "hours", "ad-slot").contains(key)
- value <- values
- } yield {
- key -> value
- }
- getBody("ads/render-time")(validatedParams)
- }
-
def getSurgingContent(): Future[JsValue] = getBody("surging")()
def getMostViewedVideos(hours: Int, count: Int): Future[JsValue] = {
diff --git a/common/app/services/ParameterStore.scala b/common/app/services/ParameterStore.scala
index e163e5fb7429..ec80d3ac2fc0 100644
--- a/common/app/services/ParameterStore.scala
+++ b/common/app/services/ParameterStore.scala
@@ -1,54 +1,43 @@
package services
-import com.amazonaws.services.simplesystemsmanagement.model.{GetParameterRequest, GetParametersByPathRequest}
-import com.amazonaws.services.simplesystemsmanagement.{
- AWSSimpleSystemsManagement,
- AWSSimpleSystemsManagementClientBuilder,
-}
+import software.amazon.awssdk.services.ssm.SsmClient
+import software.amazon.awssdk.services.ssm.model.{GetParameterRequest, GetParametersByPathRequest}
+import software.amazon.awssdk.regions.Region
import common.GuardianConfiguration
import conf.Configuration
+import utils.AWSv2
import scala.annotation.tailrec
import scala.jdk.CollectionConverters._
class ParameterStore(region: String) {
- private lazy val client: AWSSimpleSystemsManagement = Configuration.aws.credentials
- .map { credentials =>
- AWSSimpleSystemsManagementClientBuilder
- .standard()
- .withCredentials(credentials)
- .withRegion(region)
- .build()
- }
- .getOrElse(throw new RuntimeException("Failed to initialize AWSSimpleSystemsManagement"))
+ private lazy val client: SsmClient =
+ SsmClient.builder().credentialsProvider(AWSv2.credentials).region(Region.of(region)).build()
def get(key: String): String = {
- val parameterRequest = new GetParameterRequest().withWithDecryption(true).withName(key)
- client.getParameter(parameterRequest).getParameter.getValue
+ val parameterRequest = GetParameterRequest.builder().withDecryption(true).name(key).build()
+ client.getParameter(parameterRequest).parameter().value()
}
def getPath(path: String, isRecursiveSearch: Boolean = false): Map[String, String] = {
@tailrec
def pagination(accum: Map[String, String], nextToken: Option[String]): Map[String, String] = {
-
- val parameterRequest = new GetParametersByPathRequest()
- .withWithDecryption(true)
- .withPath(path)
- .withRecursive(isRecursiveSearch)
-
- val parameterRequestWithNextToken = nextToken.map(parameterRequest.withNextToken).getOrElse(parameterRequest)
-
- val result = client.getParametersByPath(parameterRequestWithNextToken)
-
- val resultMap = result.getParameters.asScala.map { param =>
- param.getName -> GuardianConfiguration.unwrapQuotedString(param.getValue)
- }.toMap
-
- Option(result.getNextToken) match {
- case Some(next) => pagination(accum ++ resultMap, Some(next))
- case None => accum ++ resultMap
+ val baseBuilder =
+ GetParametersByPathRequest.builder().path(path).withDecryption(true).recursive(isRecursiveSearch)
+ val parameterRequest = nextToken.fold(baseBuilder)(baseBuilder.nextToken).build()
+ val response = client.getParametersByPath(parameterRequest)
+ val resultMap = response
+ .parameters()
+ .asScala
+ .map { param =>
+ param.name() -> GuardianConfiguration.unwrapQuotedString(param.value())
+ }
+ .toMap
+ Option(response.nextToken()) match {
+ case Some(next) if next.nonEmpty => pagination(accum ++ resultMap, Some(next))
+ case _ => accum ++ resultMap
}
}
diff --git a/common/app/services/S3.scala b/common/app/services/S3.scala
index c2d618c89fd7..23e3c6985930 100644
--- a/common/app/services/S3.scala
+++ b/common/app/services/S3.scala
@@ -1,17 +1,22 @@
package services
-import com.amazonaws.services.s3.model.CannedAccessControlList.{Private, PublicRead}
-import com.amazonaws.services.s3.model._
-import com.amazonaws.services.s3.{AmazonS3, AmazonS3Client}
-import com.amazonaws.util.StringInputStream
import com.gu.etagcaching.aws.s3.ObjectId
import common.GuLogging
import conf.Configuration
import model.PressedPageType
import org.joda.time.DateTime
import services.S3.logS3ExceptionWithDevHint
+import software.amazon.awssdk.core.sync.RequestBody
+import software.amazon.awssdk.services.s3.model.ObjectCannedACL.{PRIVATE, PUBLIC_READ}
+import software.amazon.awssdk.services.s3.model._
+import software.amazon.awssdk.services.s3.presigner.S3Presigner
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest
+import utils.AWSv2
import java.io._
+import java.nio.charset.StandardCharsets.UTF_8
+import java.time.Duration.ofMinutes
+import java.time.Instant
import java.util.zip.GZIPOutputStream
import scala.io.{Codec, Source}
@@ -19,125 +24,100 @@ trait S3 extends GuLogging {
lazy val bucket = Configuration.aws.frontendStoreBucket
- lazy val client: Option[AmazonS3] = Configuration.aws.credentials.map { credentials =>
- AmazonS3Client.builder
- .withCredentials(credentials)
- .withRegion(conf.Configuration.aws.region)
- .build()
- }
+ lazy private val client = AWSv2.S3Sync
- private def withS3Result[T](key: String)(action: S3Object => T): Option[T] =
- client.flatMap { client =>
- val objectId = ObjectId(bucket, key)
- try {
- val request = new GetObjectRequest(bucket, key)
- val result = client.getObject(request)
- log.info(s"S3 got ${result.getObjectMetadata.getContentLength} bytes from ${result.getKey}")
-
- // http://stackoverflow.com/questions/17782937/connectionpooltimeoutexception-when-iterating-objects-in-s3
- try {
- Some(action(result))
- } catch {
- case e: Exception =>
- throw e
- } finally {
- result.close()
- }
- } catch {
- case e: AmazonS3Exception if e.getStatusCode == 404 =>
- log.warn(s"not found at ${objectId.s3Uri}")
- None
- case e: AmazonS3Exception =>
- logS3ExceptionWithDevHint(objectId, e)
- None
- case e: Exception =>
- throw e
- }
+ def handleS3Errors[T](key: String)(thunk: => T): Option[T] = {
+ val objectId = ObjectId(bucket, key)
+ try {
+ Some(thunk)
+ } catch {
+ case e: NoSuchKeyException if e.statusCode == 404 =>
+ log.warn(s"not found at ${objectId.s3Uri}")
+ None
+ case e: software.amazon.awssdk.services.s3.model.S3Exception =>
+ logS3ExceptionWithDevHint(objectId, e)
+ None
+ case e: Exception =>
+ throw e
}
+ }
- def get(key: String)(implicit codec: Codec): Option[String] =
- withS3Result(key) { result =>
- Source.fromInputStream(result.getObjectContent).mkString
- }
+ def get(key: String)(implicit codec: Codec): Option[String] = handleS3Errors(key)(getResponseAndContent(key)._2)
- def getWithLastModified(key: String): Option[(String, DateTime)] =
- withS3Result(key) { result =>
- val content = Source.fromInputStream(result.getObjectContent).mkString
- val lastModified = new DateTime(result.getObjectMetadata.getLastModified)
- (content, lastModified)
- }
+ def getPresignedUrl(key: String): Option[String] = handleS3Errors(key) {
+ val presignRequest = GetObjectPresignRequest.builder
+ .signatureDuration(ofMinutes(10))
+ .getObjectRequest(GetObjectRequest.builder.bucket(bucket).key(key).build)
+ .build()
+
+ AWSv2.S3PresignerSync.presignGetObject(presignRequest).url.toExternalForm
+ }
- def getLastModified(key: String): Option[DateTime] =
- withS3Result(key) { result =>
- new DateTime(result.getObjectMetadata.getLastModified)
+ private def toDateTime(instant: Instant): DateTime = new DateTime(instant.toEpochMilli)
+
+ def getWithLastModified(key: String): Option[(String, DateTime)] = handleS3Errors(key) {
+ val (resp, content) = getResponseAndContent(key)
+ val lastModified = toDateTime(resp.lastModified())
+ (content, lastModified)
+ }
+
+ private def getResponseAndContent(key: String)(implicit codec: Codec): (GetObjectResponse, String) = {
+ val request = GetObjectRequest.builder().bucket(bucket).key(key).build()
+ val resp = client.getObject(request)
+ val objectResponse = resp.response()
+ log.info(s"S3 got ${objectResponse.contentLength} bytes from $key")
+ try {
+ val content = Source.fromInputStream(resp).mkString
+ (objectResponse, content)
+ } finally {
+ resp.close()
}
+ }
- def putPublic(key: String, value: String, contentType: String): Unit = {
- put(key: String, value: String, contentType: String, PublicRead)
+ def getLastModified(key: String): Option[DateTime] = handleS3Errors(key) {
+ val request = HeadObjectRequest.builder().bucket(bucket).key(key).build()
+ toDateTime(client.headObject(request).lastModified())
}
- def putPublic(key: String, file: File, contentType: String): Unit = {
- val request = new PutObjectRequest(bucket, key, file).withCannedAcl(PublicRead)
- client.foreach(_.putObject(request))
+ def putPublic(key: String, value: String, contentType: String): Unit = {
+ put(key: String, value: String, contentType: String, PUBLIC_READ)
}
def putPrivate(key: String, value: String, contentType: String): Unit = {
- put(key: String, value: String, contentType: String, Private)
+ put(key: String, value: String, contentType: String, PRIVATE)
}
def putPrivateGzipped(key: String, value: String, contentType: String): Unit = {
- putGzipped(key, value, contentType, Private)
- }
-
- private def putGzipped(
- key: String,
- value: String,
- contentType: String,
- accessControlList: CannedAccessControlList,
- ): Unit = {
- lazy val request = {
- val metadata = new ObjectMetadata()
-
- metadata.setCacheControl("no-cache,no-store")
- metadata.setContentType(contentType)
- metadata.setContentEncoding("gzip")
-
- val valueAsBytes = value.getBytes("UTF-8")
- val os = new ByteArrayOutputStream()
- val gzippedStream = new GZIPOutputStream(os)
- gzippedStream.write(valueAsBytes)
- gzippedStream.flush()
- gzippedStream.close()
-
- metadata.setContentLength(os.size())
-
- new PutObjectRequest(bucket, key, new ByteArrayInputStream(os.toByteArray), metadata)
- .withCannedAcl(accessControlList)
- }
+ val os = new ByteArrayOutputStream()
+ val gzippedStream = new GZIPOutputStream(os)
+ gzippedStream.write(value.getBytes(UTF_8))
+ gzippedStream.flush()
+ gzippedStream.close()
+
+ val request = PutObjectRequest
+ .builder()
+ .bucket(bucket)
+ .key(key)
+ .acl(PRIVATE)
+ .cacheControl("no-cache,no-store")
+ .contentType(contentType)
+ .contentEncoding("gzip")
+ .build()
- try {
- client.foreach(_.putObject(request))
- } catch {
- case e: Exception =>
- throw e
- }
+ client.putObject(request, RequestBody.fromBytes(os.toByteArray))
}
- private def put(key: String, value: String, contentType: String, accessControlList: CannedAccessControlList): Unit = {
- val metadata = new ObjectMetadata()
- metadata.setCacheControl("no-cache,no-store")
- metadata.setContentType(contentType)
- metadata.setContentLength(value.getBytes("UTF-8").length)
-
- val request =
- new PutObjectRequest(bucket, key, new StringInputStream(value), metadata).withCannedAcl(accessControlList)
+ private def put(key: String, value: String, contentType: String, accessControlList: ObjectCannedACL): Unit = {
+ val request = PutObjectRequest
+ .builder()
+ .bucket(bucket)
+ .key(key)
+ .acl(accessControlList)
+ .cacheControl("no-cache,no-store")
+ .contentType(contentType)
+ .build()
- try {
- client.foreach(_.putObject(request))
- } catch {
- case e: Exception =>
- throw e
- }
+ client.putObject(request, RequestBody.fromString(value, UTF_8))
}
}
@@ -169,7 +149,6 @@ object S3FrontsApi extends S3 {
object S3Archive extends S3 {
override lazy val bucket: String =
if (Configuration.environment.isNonProd) "aws-frontend-archive-code" else "aws-frontend-archive"
- def getHtml(path: String): Option[String] = get(path)
}
object S3ArchiveOriginals extends S3 {
diff --git a/common/app/services/TagIndexesS3.scala b/common/app/services/TagIndexesS3.scala
index f15c6b51ac14..06d31c6f289a 100644
--- a/common/app/services/TagIndexesS3.scala
+++ b/common/app/services/TagIndexesS3.scala
@@ -22,7 +22,7 @@ object TagIndexesS3 extends S3 {
s"${indexRoot(indexType)}/$pageName.json"
private def putJson[A: Writes](key: String, a: A) =
- putPublic(
+ putPrivate(
key,
Json.stringify(Json.toJson(a)),
"application/json",
diff --git a/common/app/services/newsletters/GoogleRecaptchaValidationApi.scala b/common/app/services/newsletters/GoogleRecaptchaValidationApi.scala
index c0258ed63909..fac936204eac 100644
--- a/common/app/services/newsletters/GoogleRecaptchaValidationApi.scala
+++ b/common/app/services/newsletters/GoogleRecaptchaValidationApi.scala
@@ -9,9 +9,14 @@ import utils.RemoteAddress
import scala.concurrent.Future
class GoogleRecaptchaValidationService(wsClient: WSClient) extends LazyLogging with RemoteAddress {
- def submit(token: String): Future[WSResponse] = {
+ def submit(token: String, shouldUseVisibleKey: Boolean = false): Future[WSResponse] = {
val url = "https://www.google.com/recaptcha/api/siteverify"
- val payload = Map("response" -> Seq(token), "secret" -> Seq(Configuration.google.googleRecaptchaSecret))
+ val secret = if (shouldUseVisibleKey) {
+ Configuration.google.googleRecaptchaSecretVisible
+ } else {
+ Configuration.google.googleRecaptchaSecret
+ }
+ val payload = Map("response" -> Seq(token), "secret" -> Seq(secret))
wsClient
.url(url)
.post(payload)
diff --git a/common/app/services/newsletters/NewsletterSignupAgent.scala b/common/app/services/newsletters/NewsletterSignupAgent.scala
index 4ca3240f9909..af36af653191 100644
--- a/common/app/services/newsletters/NewsletterSignupAgent.scala
+++ b/common/app/services/newsletters/NewsletterSignupAgent.scala
@@ -26,7 +26,7 @@ class NewsletterSignupAgent(newsletterApi: NewsletterApi) extends GuLogging {
def getNewsletterById(listId: Int): Either[String, Option[NewsletterResponse]] = {
newslettersAgent.get() match {
- case Left(err) => Left(err)
+ case Left(err) => Left(err)
case Right(newsletters) =>
Right(
newsletters.find(newsletter => newsletter.listId == listId || newsletter.listIdV1 == listId),
@@ -43,7 +43,7 @@ class NewsletterSignupAgent(newsletterApi: NewsletterApi) extends GuLogging {
def getV2NewsletterById(listId: Int): Either[String, Option[NewsletterResponseV2]] = {
newslettersV2Agent.get() match {
- case Left(err) => Left(err)
+ case Left(err) => Left(err)
case Right(newsletters) =>
Right(
newsletters.find(newsletter => newsletter.listId == listId),
diff --git a/common/app/services/repositories.scala b/common/app/services/repositories.scala
index 6a73603e413b..c9727bf31a6b 100644
--- a/common/app/services/repositories.scala
+++ b/common/app/services/repositories.scala
@@ -74,13 +74,21 @@ trait Index extends ConciergeRepository {
.map { response =>
val trails = response.results.map(IndexPageItem(_)).toList
trails match {
- case Nil => Left(NotFound)
+ case Nil => Left(NotFound)
case head :: _ =>
val tag1 = findTag(head.item, firstTag)
val tag2 = findTag(head.item, secondTag)
if (tag1.isDefined && tag2.isDefined) {
val page = TagCombiner(s"$leftSide+$rightSide", tag1.get, tag2.get, pagination(response))
- Right(IndexPage(page, contents = trails, tags = Tags(Nil), date = DateTime.now, tzOverride = None))
+ Right(
+ IndexPage(
+ page,
+ contents = trails,
+ tags = Tags(List(tag1.get, tag2.get)),
+ date = DateTime.now,
+ tzOverride = None,
+ ),
+ )
} else {
Left(NotFound)
}
diff --git a/common/app/templates/javaScriptConfig.scala.js b/common/app/templates/javaScriptConfig.scala.js
index 76a7859ffc3d..9f908cd3c9cc 100644
--- a/common/app/templates/javaScriptConfig.scala.js
+++ b/common/app/templates/javaScriptConfig.scala.js
@@ -16,6 +16,7 @@
s""""${CamelCase.fromHyphenated(switch.name)}":${switch.isSwitchedOn}"""}.mkString(","))}
},
"tests": { @JavaScript(experiments.ActiveExperiments.getJavascriptConfig) },
+ "serverSideABTests": { @JavaScript(ab.ABTests.getJavascriptConfig) },
"modules": {
"tracking": {
"ready": null
diff --git a/common/app/utils/AWSv2.scala b/common/app/utils/AWSv2.scala
index eb82f689c81d..fbeb520edf39 100644
--- a/common/app/utils/AWSv2.scala
+++ b/common/app/utils/AWSv2.scala
@@ -5,7 +5,8 @@ import software.amazon.awssdk.awscore.client.builder.AwsClientBuilder
import software.amazon.awssdk.http.nio.netty.NettyNioAsyncHttpClient
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.regions.Region.EU_WEST_1
-import software.amazon.awssdk.services.s3.{S3AsyncClient, S3AsyncClientBuilder}
+import software.amazon.awssdk.services.s3.presigner.S3Presigner
+import software.amazon.awssdk.services.s3.{S3AsyncClient, S3AsyncClientBuilder, S3Client, S3ClientBuilder}
import software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider
import software.amazon.awssdk.services.sts.model.AssumeRoleRequest
import software.amazon.awssdk.services.sts.{StsClient, StsClientBuilder}
@@ -29,14 +30,25 @@ object AWSv2 {
val S3Async: S3AsyncClient = buildS3AsyncClient(credentials)
+ val S3Sync: S3Client = build[S3Client, S3ClientBuilder](S3Client.builder())
+
+ val S3PresignerSync: S3Presigner = S3Presigner.builder().credentialsProvider(credentials).region(region).build()
+
val STS: StsClient = build[StsClient, StsClientBuilder](StsClient.builder())
- def stsCredentials(devProfile: String, roleArn: String): AwsCredentialsProvider = credentialsForDevAndProd(
+ /** Assume a role (roleArn) with STS while allowing local dev to fall back to a profile. sessionName defaults to
+ * "frontend" but can be overridden per caller.
+ */
+ def stsCredentials(
+ devProfile: String,
+ roleArn: String,
+ sessionName: String = "frontend",
+ ): AwsCredentialsProvider = credentialsForDevAndProd(
devProfile,
StsAssumeRoleCredentialsProvider
.builder()
.stsClient(STS)
- .refreshRequest(AssumeRoleRequest.builder.roleSessionName("frontend").roleArn(roleArn).build)
+ .refreshRequest(AssumeRoleRequest.builder.roleSessionName(sessionName).roleArn(roleArn).build)
.build(),
)
diff --git a/common/app/views/fragments/collections/popularExtended.scala.html b/common/app/views/fragments/collections/popularExtended.scala.html
deleted file mode 100644
index 042b098aa5ea..000000000000
--- a/common/app/views/fragments/collections/popularExtended.scala.html
+++ /dev/null
@@ -1,88 +0,0 @@
-@(popular: Seq[model.MostPopular], mostCards: Map[String,Option[layout.ContentCard]] = Map.empty, containerDefinition: Option[layout.FaciaContainer] = None, isFront: Boolean = false)(implicit request: RequestHeader)
-
-@import common.Localisation
-@import layout.FaciaCardHeader
-@import views.html.fragments.items.elements.facia_cards.title
-@import views.html.fragments.items.facia_cards.simpleContentCard
-@import views.support._
-@import views.support.TrailCssClasses.toneClass
-@import views.support.MostPopular.{isAdFree, showMPU, tabsPaneCssClass}
-@import views.support.{GetClasses, RemoveOuterParaHtml}
-@import model.Pillar.RichPillar
-
-@defining(popular.size > 1){ isTabbed =>
-
- @if(isTabbed) {
-
-
- @popular.zipWithRowInfo.map{ case (section, info) =>
- -
-
- Most viewed @Html(Localisation(section.heading.stripPrefix("popular ").stripPrefix("Most viewed ")))
-
-
- }
-
-
- }
-
- @popular.zipWithRowInfo.map{ case (section, info) =>
-
-
-
- @section.trails.zipWithRowInfo.map{ case (trail, info) =>
- @defining(FaciaCardHeader.fromTrail(trail, None)) { header =>
- -
-
-
- @fragments.inlineSvg(s"number-${info.rowNum}", "numbers")
-
-
- @title(header, 2, 2, "headline-list__body", isAction = trail.isActionCard)
- @trail.properties.maybeContent.map { content =>
- @if(content.tags.tags.exists(_.id == "tone/news") || content.tags.tags.exists(_.id == "tone/comment")) {
- @fragments.contentAgeNotice(ContentOldAgeDescriber(content))
- }
- }
-
-
- @RemoveOuterParaHtml(header.headline)
-
- }
- }
-
-
- }
- @if(showMPU(containerDefinition) && isFront) {
-
-
- }
- @if(isTabbed) {
-
-
- }
-
-
- @mostCards.getOrElse("most_commented", None).map { mostCommented =>
-
- @simpleContentCard(mostCommented, 0, 0, "", false, false)
-
- }
-
- @mostCards.getOrElse("most_shared", None).map { mostShared =>
-
- @simpleContentCard(mostShared, 0, 0, "", false, false)
-
- }
-
-
-
-
-}
diff --git a/common/app/views/fragments/containers/facia_cards/container.scala.html b/common/app/views/fragments/containers/facia_cards/container.scala.html
index bcc828a10de5..8286229646c5 100644
--- a/common/app/views/fragments/containers/facia_cards/container.scala.html
+++ b/common/app/views/fragments/containers/facia_cards/container.scala.html
@@ -1,6 +1,6 @@
@import common.commercial.ContainerModel
@import layout.MetaDataHeader
-@import layout.slices.{Dynamic, Fixed, MostPopular, NavList, NavMediaList, Video, VerticalVideo}
+@import layout.slices.{Dynamic, Fixed, MostPopular, NavList, NavMediaList}
@import views.html.fragments.commercial.containers.paidContainer
@import views.html.fragments.audio.containers.flagshipContainer
@import views.html.fragments.containers.facia_cards._
@@ -83,12 +83,6 @@
}
- case Video => {
-
- @videoContainer(containerDefinition, frontProperties)
-
- }
-
case NavMediaList => {
@navMediaListContainer(containerDefinition, frontProperties)
diff --git a/common/app/views/fragments/containers/facia_cards/mostPopularContainer.scala.html b/common/app/views/fragments/containers/facia_cards/mostPopularContainer.scala.html
index f1b4c6c6d28e..d22324dd9970 100644
--- a/common/app/views/fragments/containers/facia_cards/mostPopularContainer.scala.html
+++ b/common/app/views/fragments/containers/facia_cards/mostPopularContainer.scala.html
@@ -1,10 +1,7 @@
@(containerDefinition: layout.FaciaContainer, frontProperties: model.FrontProperties)(implicit requestHeader: RequestHeader)
-@import conf.switches.Switches
@import common.LinkTo
@import model.MostPopular
-@import fragments.commercial.adSlot
-@import layout.ContentCard
@defining("Most viewed") { containerTitle =>
}
-
- @if(Switches.ExtendedMostPopularFronts.isSwitchedOn) {
-
-
- @defining(Seq(
- MostPopular(
- containerDefinition.displayName.getOrElse(containerTitle),
- containerTitle,
- containerDefinition.items.take(10)
- ),
- MostPopular(
- "Across the guardian",
- containerTitle,
- Nil
- )
- )) { popular =>
- @fragments.collections.popularExtended(popular)
- }
-
-
-
- @adSlot(
- "mostpop",
- Seq("container-inline"),
- Map(),
- optId = None,
- optClassNames = None
- ){ }
-
-
-
- } else {
-
- @defining(Seq(
- MostPopular(
- containerDefinition.displayName.getOrElse(containerTitle),
- containerTitle,
- containerDefinition.items.take(10)
- ),
- MostPopular(
- "Across the guardian",
- containerTitle,
- Nil
- )
- )) { popular =>
- @fragments.collections.popular(popular, Some(containerDefinition), isFront = true)
- }
-
+
+ @defining(Seq(
+ MostPopular(
+ containerDefinition.displayName.getOrElse(containerTitle),
+ containerTitle,
+ containerDefinition.items.take(10)
+ ),
+ MostPopular(
+ "Across the guardian",
+ containerTitle,
+ Nil
+ )
+ )) { popular =>
+ @fragments.collections.popular(popular, Some(containerDefinition), isFront = true)
}
+
}
diff --git a/common/app/views/fragments/containers/facia_cards/verticalVideoContainer.scala.html b/common/app/views/fragments/containers/facia_cards/verticalVideoContainer.scala.html
deleted file mode 100644
index a058c262ed5e..000000000000
--- a/common/app/views/fragments/containers/facia_cards/verticalVideoContainer.scala.html
+++ /dev/null
@@ -1,118 +0,0 @@
-@import model.{InlineImage, VideoPlayer}
-@import views.html.fragments.media.video
-@import views.html.fragments.nav.treats
-@import views.html.fragments.atoms.youtube
-@import views.support.{RenderClasses, Video640, Video700}
-@import model.content.MediaAssetPlatform
-@import model.content.MediaWrapper.VideoContainer
-@import model.VideoFaciaProperties
-@import layout.FaciaCardHeader
-@import model.Pillar.RichPillar
-
-@(containerDefinition: layout.FaciaContainer, frontProperties: model.FrontProperties)(implicit requestHeader: RequestHeader)
-
-
-
-
-
-
-
-
-
-
-
- @containerDefinition.collectionEssentials.items.filter(i => i.header.isVideo).zipWithIndex.map { case (item, index) =>
- -
- @item.properties.maybeContent.map { content =>
- @defining(content.elements.mediaAtoms.find(_.assets.exists(_.platform == MediaAssetPlatform.Youtube))) { youTubeAtom =>
- @youTubeAtom.map { youTubeAtom =>
- @youtube(media = youTubeAtom,
- displayCaption = false,
- mediaWrapper = Some(VideoContainer),
- displayDuration = false,
- faciaHeaderProperties = Some(VideoFaciaProperties(header = FaciaCardHeader.fromTrail(item, None),
- showByline = item.properties.showByline, item.properties.byline)),
- isPaidFor = item.isPaidFor,
- pressedContent = Some(item),
- verticalVideo = true
- )
- }
- }
-
-
- @content.elements.mainVideo.map { mainVideo =>
- @defining(VideoPlayer(
- mainVideo,
- Video640,
- item,
- autoPlay = false,
- showControlsAtStart = false,
- path = Some(content.metadata.id)
- )) { player =>
-
-
- }
- }
-
- }
- }
-
-
-
-
-
-
diff --git a/common/app/views/fragments/containers/facia_cards/videoContainer.scala.html b/common/app/views/fragments/containers/facia_cards/videoContainer.scala.html
deleted file mode 100644
index 81c1c6f25899..000000000000
--- a/common/app/views/fragments/containers/facia_cards/videoContainer.scala.html
+++ /dev/null
@@ -1,102 +0,0 @@
-@import model.{InlineImage, VideoPlayer}
-@import views.html.fragments.media.video
-@import views.html.fragments.nav.treats
-@import views.html.fragments.atoms.youtube
-@import views.support.{RenderClasses, Video640, Video700}
-@import model.content.MediaAssetPlatform
-@import model.content.MediaWrapper.VideoContainer
-@import model.VideoFaciaProperties
-@import layout.FaciaCardHeader
-@import views.support.GetClasses
-@import model.Pillar.RichPillar
-
-@(containerDefinition: layout.FaciaContainer, frontProperties: model.FrontProperties)(implicit requestHeader: RequestHeader)
-
-
-
-
-
-
-
- @fragments.inlineSvg("chevron-left", "icon", Seq("video-playlist__icon"))
-
-
-
-
-
- @containerDefinition.collectionEssentials.items.filter(i => i.header.isVideo).zipWithIndex.map { case (item, index) =>
- -
- @item.properties.maybeContent.map { content =>
- @defining(content.elements.mediaAtoms.find(_.assets.exists(_.platform == MediaAssetPlatform.Youtube))) { youTubeAtom =>
- @youTubeAtom.map { youTubeAtom =>
- @youtube(media = youTubeAtom,
- displayCaption = false,
- mediaWrapper = Some(VideoContainer),
- displayDuration = false,
- faciaHeaderProperties = Some(VideoFaciaProperties(header = FaciaCardHeader.fromTrail(item, None),
- showByline = item.properties.showByline, item.properties.byline)),
- isPaidFor = item.isPaidFor,
- pressedContent = Some(item))
- }
- }
-
-
- @content.elements.mainVideo.map { mainVideo =>
- @defining(VideoPlayer(
- mainVideo,
- Video640,
- item,
- autoPlay = false,
- showControlsAtStart = false,
- path = Some(content.metadata.id)
- )) { player =>
-
-
- }
- }
-
- }
- }
-
-
-
- @fragments.inlineSvg("chevron-right", "icon", Seq("video-playlist__icon"))
-
-
diff --git a/common/app/views/fragments/immersiveGalleryMainMedia.scala.html b/common/app/views/fragments/immersiveGalleryMainMedia.scala.html
index 265c56efa102..92f9bc54b661 100644
--- a/common/app/views/fragments/immersiveGalleryMainMedia.scala.html
+++ b/common/app/views/fragments/immersiveGalleryMainMedia.scala.html
@@ -39,10 +39,13 @@
@defining(page.item.elements.mainPicture.flatMap(_.images.masterImage)) {
case Some(masterImage) => {
- @fragments.inlineSvg("triangle", "icon")
- @masterImage.caption.map(Html(_))
- @if(masterImage.displayCredit && !masterImage.creditEndsWithCaption) {
- @masterImage.credit.map(Html(_))
+ @if(masterImage.caption.isDefined || (masterImage.displayCredit && !masterImage.creditEndsWithCaption)) {
+ @fragments.inlineSvg("triangle", "icon")
+ @masterImage.caption.map(Html(_))
+
+ @if(masterImage.displayCredit && !masterImage.creditEndsWithCaption) {
+ @masterImage.credit.map(Html(_))
+ }
}
}
diff --git a/common/app/views/support/Commercial.scala b/common/app/views/support/Commercial.scala
index 8bfbea4fda95..758719b85af9 100644
--- a/common/app/views/support/Commercial.scala
+++ b/common/app/views/support/Commercial.scala
@@ -68,67 +68,6 @@ object Commercial {
def isFoundationFundedContent(page: Page): Boolean = page.metadata.commercial.exists(_.isFoundationFunded)
- def isBrandedContent(page: Page)(implicit request: RequestHeader): Boolean = {
- isPaidContent(page) || isSponsoredContent(page) || isFoundationFundedContent(page)
- }
-
- def listSponsorLogosOnPage(page: Page)(implicit request: RequestHeader): Option[Seq[String]] = {
-
- val edition = Edition(request)
- def sponsor(branding: Branding) = branding.sponsorName.toLowerCase
-
- val pageSponsor = page.metadata.commercial.flatMap(_.branding(edition)).map(sponsor)
-
- val allSponsors = page match {
- case front: PressedPage =>
- val containerSponsors = front.collections.flatMap { container =>
- container.branding(edition) flatMap {
- case b: Branding => Some(b.sponsorName.toLowerCase)
- case _ => None
- }
- }
-
- val cardSponsors = front.collections.flatMap { container =>
- container.branding(edition) match {
- case Some(PaidMultiSponsorBranding) =>
- container.curatedPlusBackfillDeduplicated.flatMap(_.branding(edition).map(sponsor))
- case _ => Nil
- }
- }
-
- val allSponsorsOnPage = pageSponsor.toList ++ containerSponsors ++ cardSponsors
- if (allSponsorsOnPage.isEmpty) None else Some(allSponsorsOnPage.distinct)
-
- case _ => pageSponsor map (Seq(_))
- }
-
- allSponsors map (_ map escapeJavaScript)
- }
-
- def getSponsorForGA(page: Page, key: String)(implicit request: RequestHeader): Html =
- Html {
- if (isBrandedContent(page)) {
- listSponsorLogosOnPage(page) match {
- case Some(logos) => s"&$key=${logos.mkString("|")}"
- case _ => ""
- }
- } else { "" }
- }
-
- def getBrandingTypeForGA(page: Page, key: String)(implicit request: RequestHeader): Html =
- Html {
- brandingType(page) match {
- case Some(branding) => s"&$key=${branding.name}"
- case _ => ""
- }
- }
-
- def brandingType(page: Page)(implicit request: RequestHeader): Option[BrandingType] =
- for {
- commercial <- page.metadata.commercial
- branding <- commercial.branding(Edition(request))
- } yield branding.brandingType
-
object topAboveNavSlot {
// The sizesOverride parameter is for testing only.
def cssClasses(metadata: model.MetaData): String = {
diff --git a/common/app/views/support/ContentLayout.scala b/common/app/views/support/ContentLayout.scala
index c6a21285b12d..0a32320bfd6c 100644
--- a/common/app/views/support/ContentLayout.scala
+++ b/common/app/views/support/ContentLayout.scala
@@ -7,7 +7,7 @@ object ContentLayout {
def showBottomSocialButtons(content: model.ContentType): Boolean = {
content match {
case l: Article if l.isLiveBlog => true
- case a: Article =>
+ case a: Article =>
val bodyLength = Jsoup.parseBodyFragment(a.content.fields.body).select("> *").text().length
val mainMediaOffset = if (a.elements.hasMainPicture || a.elements.hasMainVideo || a.hasVideoAtTop) 700 else 0
bodyLength + mainMediaOffset > 1200
diff --git a/common/app/views/support/CutOut.scala b/common/app/views/support/CutOut.scala
index 09d0d420acef..c11f36e57bbb 100644
--- a/common/app/views/support/CutOut.scala
+++ b/common/app/views/support/CutOut.scala
@@ -24,7 +24,7 @@ object CutOut extends GuLogging {
case Some(Cutout(src, Some(width), Some(height))) =>
Try((width.toInt, height.toInt)) match {
case Success((w, h)) => Option(CutOut(src, Orientation.fromDimensions(w, h)))
- case Failure(t) =>
+ case Failure(t) =>
log.warn(s"Could not convert width and height to INT: $t")
None
}
diff --git a/common/app/views/support/EmailHelpers.scala b/common/app/views/support/EmailHelpers.scala
index 4a9e1e8d32af..a7c4b8e18f34 100644
--- a/common/app/views/support/EmailHelpers.scala
+++ b/common/app/views/support/EmailHelpers.scala
@@ -11,7 +11,7 @@ object EmailHelpers {
def imageUrlFromCard(contentCard: ContentCard, width: Int): Option[String] = {
def imageUrl(displayElement: Option[FaciaDisplayElement]): Option[String] =
displayElement.flatMap {
- case InlineImage(imageMedia) => FrontEmailImage(width).bestSrcFor(imageMedia)
+ case InlineImage(imageMedia) => FrontEmailImage(width).bestSrcFor(imageMedia)
case InlineVideo(video, _, maybeFallbackImage) =>
EmailVideoImage.bestSrcFor(video.images).orElse(imageUrl(maybeFallbackImage))
case InlineYouTubeMediaAtom(atom, posterOverride) =>
diff --git a/common/app/views/support/GetClasses.scala b/common/app/views/support/GetClasses.scala
index 7d84196aa516..c5a8ee9d2be8 100644
--- a/common/app/views/support/GetClasses.scala
+++ b/common/app/views/support/GetClasses.scala
@@ -113,8 +113,6 @@ object GetClasses {
slices.Container.customClasses(containerDefinition.container),
disableHide = containerDefinition.hideToggle,
lazyLoad = containerDefinition.shouldLazyLoad,
- dynamicSlowMpu =
- containerDefinition.container == Dynamic(DynamicSlowMPU(omitMPU = false, adFree = isAdFree(request))),
)
/** TODO get rid of this when we consolidate 'all' logic with index logic */
@@ -125,13 +123,12 @@ object GetClasses {
hasTitle,
isHeadlines = false,
isVideo = false,
- commercialOptions = ContainerCommercialOptions(omitMPU = false, adFree = adFree),
+ commercialOptions = ContainerCommercialOptions(adFree = adFree),
hasDesktopShowMore = false,
container = None,
extraClasses = Nil,
disableHide = true,
lazyLoad = false,
- dynamicSlowMpu = false,
)
def forContainer(
@@ -146,7 +143,6 @@ object GetClasses {
extraClasses: Seq[String] = Nil,
disableHide: Boolean = false,
lazyLoad: Boolean,
- dynamicSlowMpu: Boolean,
): String = {
// no toggle for Headlines container as it will be hosting the weather widget instead
val showToggle =
@@ -161,7 +157,6 @@ object GetClasses {
("fc-container--video", isVideo),
("fc-container--lazy-load", lazyLoad),
("js-container--lazy-load", lazyLoad),
- ("fc-container--dynamic-slow-mpu", dynamicSlowMpu),
("fc-container--will-have-toggle", showToggle),
("js-container--toggle", showToggle),
) collect { case (kls, true) =>
diff --git a/common/app/views/support/HtmlCleaner.scala b/common/app/views/support/HtmlCleaner.scala
index f50fc1452368..d74106cccf5b 100644
--- a/common/app/views/support/HtmlCleaner.scala
+++ b/common/app/views/support/HtmlCleaner.scala
@@ -867,6 +867,7 @@ case class AffiliateLinksCleaner(
showAffiliateLinks: Option[Boolean],
appendDisclaimer: Option[Boolean] = None,
tags: List[String],
+ isUSProductionOffice: Boolean,
) extends HtmlCleaner
with GuLogging {
@@ -879,6 +880,7 @@ case class AffiliateLinksCleaner(
tags,
)
) {
+ val skimlinksId = if (isUSProductionOffice) skimlinksUSId else skimlinksDefaultId
AffiliateLinksCleaner.replaceLinksInHtml(document, pageUrl, skimlinksId)
} else document
}
@@ -901,25 +903,28 @@ object AffiliateLinksCleaner {
html
}
- def replaceLinksInElement(html: String, pageUrl: String): TextBlockElement = {
- val doc = Jsoup.parseBodyFragment(html)
- val linksToReplace: mutable.Seq[Element] = getAffiliateableLinks(doc)
- linksToReplace.foreach { el =>
- el.attr("href", linkToSkimLink(el.attr("href"), pageUrl, skimlinksId)).attr("rel", "sponsored")
- }
-
- if (linksToReplace.nonEmpty) {
- TextBlockElement(doc.body().html())
- } else {
- TextBlockElement(html)
+ def replaceUrlInLink(
+ url: Option[String],
+ pageUrl: String,
+ addAffiliateLinks: Boolean,
+ isUSProductionOffice: Boolean,
+ ): Option[String] = {
+ val skimlinksId = if (isUSProductionOffice) skimlinksUSId else skimlinksDefaultId
+ val httpsUrl = url.map(ensureHttps)
+ httpsUrl match {
+ case Some(link) if addAffiliateLinks && SkimLinksCache.isSkimLink(link) =>
+ Some(linkToSkimLink(link, pageUrl, skimlinksId))
+ case _ => httpsUrl
}
}
+ def ensureHttps(url: String): String = url.replace("http:", "https:")
+
def isAffiliatable(element: Element): Boolean =
element.tagName == "a" && SkimLinksCache.isSkimLink(element.attr("href"))
def linkToSkimLink(link: String, pageUrl: String, skimlinksId: String): String = {
- val urlEncodedLink = URLEncode(link)
+ val urlEncodedLink = URLEncode(ensureHttps(link))
s"https://go.skimresources.com/?id=$skimlinksId&url=$urlEncodedLink&sref=$host$pageUrl"
}
diff --git a/common/app/views/support/ImageProfile.scala b/common/app/views/support/ImageProfile.scala
index 614df858eb2d..e95712a5b0c4 100644
--- a/common/app/views/support/ImageProfile.scala
+++ b/common/app/views/support/ImageProfile.scala
@@ -150,6 +150,14 @@ class ShareImage(
val overlayAlignParam = "overlay-align=bottom%2Cleft"
val overlayWidthParam = "overlay-width=100p"
+ // If we only use "fit=crop", then fastly will crop to the centre of the image.
+ // This often means that we lose the tops of people's faces which looks bad,
+ // especially since the switch from 5:4 to 5:3 images means that they tend to be
+ // even taller than the social share image's ratios.
+ // Instead, tell fastly to crop to 40:21 (equivalent to 1200:630), anchoring to the top
+ // vertically, but the centre horizontally, BEFORE it resizes and fits the image to 1200x630.
+ val precropParam = "precrop=40:21,offset-x50,offset-y0"
+
override def resizeString: String = {
if (shouldIncludeOverlay) {
val params = Seq(
@@ -158,6 +166,7 @@ class ShareImage(
qualityparam,
autoParam,
fitParam,
+ precropParam,
dprParam,
overlayAlignParam,
overlayWidthParam,
@@ -184,6 +193,8 @@ case class GuardianOldContent(publicationYear: Int) extends ShareImageCategory
case class ObserverStarRating(rating: Int) extends ShareImageCategory
case class GuardianStarRating(rating: Int) extends ShareImageCategory
case object Paid extends ShareImageCategory
+case object FilterUk extends ShareImageCategory
+case object FilterUs extends ShareImageCategory
trait OverlayBase64 {
def overlayUrlBase64(overlay: String): String =
@@ -214,6 +225,10 @@ object OpenGraphImage extends OverlayBase64 {
case ObserverStarRating(rating) => starRatingObserver(rating, shouldIncludeOverlay, shouldUpscale)
case GuardianStarRating(rating) => starRating(rating, shouldIncludeOverlay, shouldUpscale)
case Paid => Item700
+ case FilterUk =>
+ new ShareImage(s"overlay-base64=${overlayUrlBase64("filter-uk.png")}", shouldIncludeOverlay, shouldUpscale)
+ case FilterUs =>
+ new ShareImage(s"overlay-base64=${overlayUrlBase64("filter-us.png")}", shouldIncludeOverlay, shouldUpscale)
}
}
diff --git a/common/app/views/support/MostPopular.scala b/common/app/views/support/MostPopular.scala
index 7d60a3f2bbb5..aa34eb336879 100644
--- a/common/app/views/support/MostPopular.scala
+++ b/common/app/views/support/MostPopular.scala
@@ -9,7 +9,6 @@ object MostPopular extends implicits.Requests {
}
def showMPU(maybeContainer: Option[FaciaContainer]): Boolean = {
- !maybeContainer.exists(_.commercialOptions.omitMPU) &&
!isAdFree(maybeContainer)
}
diff --git a/common/app/views/support/package.scala b/common/app/views/support/package.scala
index 1ff524e6ded1..1b1a6b37b856 100644
--- a/common/app/views/support/package.scala
+++ b/common/app/views/support/package.scala
@@ -239,7 +239,7 @@ object RenderOtherStatus {
result.header.status match {
case 404 => NoCache(NotFound)
case 410 if request.isJson => Cached(60)(JsonComponent(gonePage, "status" -> "GONE"))
- case 410 =>
+ case 410 =>
Cached(60)(
WithoutRevalidationResult(
Gone(
diff --git a/common/test/CommonTestSuite.scala b/common/test/CommonTestSuite.scala
index 9400a56d2ca4..f8f700c52f24 100644
--- a/common/test/CommonTestSuite.scala
+++ b/common/test/CommonTestSuite.scala
@@ -1,5 +1,6 @@
package test
+import ab.ABTestsTest
import conf.CachedHealthCheckTest
import conf.audio.FlagshipFrontContainerSpec
import navigation.NavigationTest
@@ -8,6 +9,7 @@ import renderers.DotcomRenderingServiceTest
class CommonTestSuite
extends Suites(
+ new ABTestsTest,
new CachedHealthCheckTest,
new NavigationTest,
new FlagshipFrontContainerSpec,
diff --git a/common/test/ab/ABTestsTest.scala b/common/test/ab/ABTestsTest.scala
new file mode 100644
index 000000000000..af8c43a04cce
--- /dev/null
+++ b/common/test/ab/ABTestsTest.scala
@@ -0,0 +1,226 @@
+package ab
+
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import play.api.test.FakeRequest
+import play.api.mvc.RequestHeader
+import play.api.libs.typedmap.TypedMap
+
+class ABTestsTest extends AnyFlatSpec with Matchers {
+
+ private val abTestHeader = "X-GU-Server-AB-Tests"
+
+ "ABTests.decorateRequest" should "parse AB test header correctly" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test1" -> "variant1",
+ "test2" -> "variant2",
+ )
+ }
+
+ it should "handle empty header" in {
+ val request = FakeRequest()
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should be(empty)
+ }
+
+ it should "handle malformed test entries" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,malformed,test2:variant2:extra")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test1" -> "variant1",
+ )
+ }
+
+ it should "handle test entries with missing variant" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test2" -> "variant2",
+ )
+ }
+
+ it should "handle test entries with colons in values" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant:with:colons,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test2" -> "variant2",
+ )
+ }
+
+ it should "handle empty string header" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should be(empty)
+ }
+
+ "ABTests.isParticipating" should "return true when test exists" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.isParticipating(enrichedRequest, "test1") should be(true)
+ ABTests.isParticipating(enrichedRequest, "test2") should be(true)
+ }
+
+ it should "return false when test does not exist" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.isParticipating(enrichedRequest, "test3") should be(false)
+ }
+
+ it should "return false for empty request" in {
+ val request = FakeRequest()
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.isParticipating(enrichedRequest, "test1") should be(false)
+ }
+
+ it should "return false when request has no AB test attributes" in {
+ val request = FakeRequest()
+
+ ABTests.isParticipating(request, "test1") should be(false)
+ }
+
+ "ABTests.isInVariant" should "return true when test and variant match" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.isInVariant(enrichedRequest, "test1", "variant1") should be(true)
+ ABTests.isInVariant(enrichedRequest, "test2", "variant2") should be(true)
+ }
+
+ it should "return false when test exists but variant does not match" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.isInVariant(enrichedRequest, "test1", "variant2") should be(false)
+ ABTests.isInVariant(enrichedRequest, "test2", "variant1") should be(false)
+ }
+
+ it should "return false when test does not exist" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.isInVariant(enrichedRequest, "test3", "variant1") should be(false)
+ }
+
+ it should "return false for empty request" in {
+ val request = FakeRequest()
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.isInVariant(enrichedRequest, "test1", "variant1") should be(false)
+ }
+
+ it should "return false when request has no AB test attributes" in {
+ val request = FakeRequest()
+
+ ABTests.isInVariant(request, "test1", "variant1") should be(false)
+ }
+
+ "ABTests.allTests" should "return all parsed tests" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2,test3:control")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test1" -> "variant1",
+ "test2" -> "variant2",
+ "test3" -> "control",
+ )
+ }
+
+ it should "return empty map for request without AB tests" in {
+ val request = FakeRequest()
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should be(empty)
+ }
+
+ it should "return empty map when request has no AB test attributes" in {
+ val request = FakeRequest()
+
+ ABTests.allTests(request) should be(empty)
+ }
+
+ "ABTests.getJavascriptConfig" should "return properly formatted JavaScript config" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ val jsConfig = ABTests.getJavascriptConfig(enrichedRequest)
+
+ // The order might vary, so check both possible orders
+ jsConfig should (equal(""""test1":"variant1","test2":"variant2"""") or
+ equal(""""test2":"variant2","test1":"variant1""""))
+ }
+
+ it should "return empty string for request without AB tests" in {
+ val request = FakeRequest()
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.getJavascriptConfig(enrichedRequest) should be("")
+ }
+
+ it should "handle single test" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.getJavascriptConfig(enrichedRequest) should be(""""test1":"variant1"""")
+ }
+
+ it should "return empty string when request has no AB test attributes" in {
+ val request = FakeRequest()
+
+ ABTests.getJavascriptConfig(request) should be("")
+ }
+
+ "ABTests header parsing" should "handle whitespace around test entries" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> " test1:variant1 , test2:variant2 ")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test1" -> "variant1",
+ "test2" -> "variant2",
+ )
+ }
+
+ it should "handle trailing commas" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,test2:variant2,")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test1" -> "variant1",
+ "test2" -> "variant2",
+ )
+ }
+
+ it should "handle leading commas" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> ",test1:variant1,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test1" -> "variant1",
+ "test2" -> "variant2",
+ )
+ }
+
+ it should "handle multiple consecutive commas" in {
+ val request = FakeRequest().withHeaders(abTestHeader -> "test1:variant1,,test2:variant2")
+ val enrichedRequest = ABTests.decorateRequest(request, abTestHeader)
+
+ ABTests.allTests(enrichedRequest) should contain theSameElementsAs Map(
+ "test1" -> "variant1",
+ "test2" -> "variant2",
+ )
+ }
+
+ "ABTests constant values" should "have correct header name" in {
+ abTestHeader should be("X-GU-Server-AB-Tests")
+ }
+}
diff --git a/common/test/common/LinkToTest.scala b/common/test/common/LinkToTest.scala
index de47ca71cb69..eae3aedffa1b 100644
--- a/common/test/common/LinkToTest.scala
+++ b/common/test/common/LinkToTest.scala
@@ -127,8 +127,8 @@ class LinkToTest extends AnyFlatSpec with Matchers with implicits.FakeRequests {
TheGuardianLinkTo("/thefilter", Europe) should endWith(s"www.theguardian.com/uk/thefilter")
}
- it should "correctly editionalise thefilter US to point to uk/thefilter temporarily until we create us/thefilter" in {
- TheGuardianLinkTo("/thefilter", Us) should endWith(s"www.theguardian.com/uk/thefilter")
+ it should "correctly editionalise thefilter US to point to us/thefilter" in {
+ TheGuardianLinkTo("/thefilter", Us) should endWith(s"www.theguardian.com/us/thefilter")
}
object TestCanonicalLink extends CanonicalLink
diff --git a/common/test/common/ModelOrResultTest.scala b/common/test/common/ModelOrResultTest.scala
index e98c296f5962..063fae9a2021 100644
--- a/common/test/common/ModelOrResultTest.scala
+++ b/common/test/common/ModelOrResultTest.scala
@@ -28,8 +28,8 @@ class ModelOrResultTest extends AnyFlatSpec with Matchers with WithTestExecution
sectionName = None,
webPublicationDate = Some(offsetDate.toCapiDateTime),
webTitle = "the title",
- webUrl = "http://www.guardian.co.uk/canonical",
- apiUrl = "http://foo.bar",
+ webUrl = "https://www.theguardian.com/the/id",
+ apiUrl = "https://foo.bar",
elements = None,
)
@@ -46,6 +46,7 @@ class ModelOrResultTest extends AnyFlatSpec with Matchers with WithTestExecution
val audioTag = articleTag.copy(id = "type/audio")
val testArticle = testContent.copy(tags = List(articleTag))
+ val testEvolvedArticle = testArticle.copy(webUrl = "https://www.theguardian.com/the-new-url")
val testGallery = testContent.copy(tags = List(galleryTag))
val testVideo = testContent.copy(tags = List(videoTag))
val testAudio = testContent.copy(tags = List(audioTag))
@@ -102,6 +103,18 @@ class ModelOrResultTest extends AnyFlatSpec with Matchers with WithTestExecution
headers(notFound).apply("X-Accel-Redirect") should be("/type/article/the/id")
}
+ it should "internal redirect to an article with evolved url if it has shown up at the wrong server" in {
+ val notFound = Future {
+ ModelOrResult(
+ item = None,
+ response = stubResponse.copy(content = Some(testEvolvedArticle)),
+ ).left.value
+ }
+
+ status(notFound) should be(200)
+ headers(notFound).apply("X-Accel-Redirect") should be("/type/article/the-new-url")
+ }
+
it should "internal redirect to a video if it has shown up at the wrong server" in {
val notFound = Future {
ModelOrResult(
diff --git a/common/test/common/TrailsToShowcaseTest.scala b/common/test/common/TrailsToShowcaseTest.scala
index a20d88a67f3e..fd68d3958133 100644
--- a/common/test/common/TrailsToShowcaseTest.scala
+++ b/common/test/common/TrailsToShowcaseTest.scala
@@ -1346,7 +1346,13 @@ class TrailsToShowcaseTest extends AnyFlatSpec with Matchers with EitherValues {
isBreaking = false,
showByline = false,
showKickerTag = false,
- imageSlideshowReplace = false,
+ mediaSelect = Some(
+ MediaSelect(
+ showMainVideo = false,
+ imageSlideshowReplace = false,
+ videoReplace = false,
+ ),
+ ),
maybeContent = mayBeContent,
maybeContentId = None,
isLiveBlog = false,
@@ -1363,7 +1369,6 @@ class TrailsToShowcaseTest extends AnyFlatSpec with Matchers with EitherValues {
webUrl = Some("an-article"),
editionBrandings = None,
atomId = None,
- showMainVideo = false,
)
val kicker = kickerText.map { k =>
@@ -1407,6 +1412,7 @@ class TrailsToShowcaseTest extends AnyFlatSpec with Matchers with EitherValues {
val displaySettings = PressedDisplaySettings(
isBoosted = false,
boostLevel = Some(BoostLevel.Default),
+ isImmersive = Some(false),
showBoostedHeadline = false,
showQuotedHeadline = false,
showLivePlayable = false,
@@ -1423,6 +1429,7 @@ class TrailsToShowcaseTest extends AnyFlatSpec with Matchers with EitherValues {
enriched = None,
supportingContent = supportingContent.toList,
cardStyle = CardStyle.make(Editorial),
+ mediaAtom = None,
)
}
}
diff --git a/common/test/common/dfp/LiveBlogTopSponsorshipTest.scala b/common/test/common/dfp/LiveBlogTopSponsorshipTest.scala
new file mode 100644
index 000000000000..2b997b603d36
--- /dev/null
+++ b/common/test/common/dfp/LiveBlogTopSponsorshipTest.scala
@@ -0,0 +1,85 @@
+package common.dfp
+
+import com.gu.contentapi.client.model.v1.TagType
+import common.Edition
+import common.editions.{Uk, Us}
+import model.{Tag, TagProperties}
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+
+class LiveBlogTopSponsorshipTest extends AnyFlatSpec with Matchers {
+ def mkLiveBlogTopSponsorship(
+ lineItemName: String = "test-sponsorship",
+ lineItemId: Long = 7000726282L,
+ sections: Seq[String] = Seq.empty,
+ editions: Seq[Edition] = Seq.empty,
+ keywords: Seq[String] = Seq.empty,
+ adTest: Option[String] = Some("ad-test-param"),
+ targetsAdTest: Boolean = true,
+ ): LiveBlogTopSponsorship = {
+ LiveBlogTopSponsorship(lineItemName, lineItemId, sections, editions, keywords, adTest, targetsAdTest)
+ }
+
+ def mkTag(
+ tagId: String = "sport/cricket",
+ tagSection: String = "sport",
+ tagType: String = "Keyword",
+ ): Tag = {
+ Tag(
+ properties = TagProperties(
+ id = tagId,
+ url = s"https://content.guardianapis.com/$tagId",
+ tagType = tagType,
+ sectionId = tagSection,
+ sectionName = tagSection,
+ webTitle = tagId.split("/").last,
+ webUrl = s"https://www.theguardian.com/$tagId",
+ twitterHandle = None,
+ bio = None,
+ description = None,
+ emailAddress = None,
+ contributorLargeImagePath = None,
+ bylineImageUrl = None,
+ podcast = None,
+ references = Seq.empty,
+ paidContentType = None,
+ commercial = None,
+ ),
+ pagination = None,
+ richLinkId = None,
+ )
+ }
+
+ "matchesKeywordTargeting" should "be true if there is no keyword targeting on the sponsorship" in {
+ val cricketKeywordTag = mkTag()
+ val liveBlogTopSponsorship = mkLiveBlogTopSponsorship(keywords = Seq.empty)
+ liveBlogTopSponsorship.matchesKeywordTargeting(Seq(cricketKeywordTag)) shouldBe true
+ }
+
+ it should "be true if sponsorship keyword targeting matches article keyword tags" in {
+ val cricketKeywordTag = mkTag()
+ val liveBlogTopSponsorship = mkLiveBlogTopSponsorship(keywords = Seq("cricket"))
+ liveBlogTopSponsorship.matchesKeywordTargeting(Seq(cricketKeywordTag)) shouldBe true
+ }
+
+ it should "be false if sponsorship keyword targeting does not match article keyword tags" in {
+ val cricketKeywordTag = mkTag()
+ val liveBlogTopSponsorship = mkLiveBlogTopSponsorship(keywords = Seq("football"))
+ liveBlogTopSponsorship.matchesKeywordTargeting(Seq(cricketKeywordTag)) shouldBe false
+ }
+
+ "matchesEditionTargeting" should "be true if no editions in sponsorship" in {
+ val liveBlogTopSponsorship = mkLiveBlogTopSponsorship(editions = Seq.empty)
+ liveBlogTopSponsorship.matchesEditionTargeting(Uk) shouldBe true
+ }
+
+ it should "be true if edition matches sponsorship" in {
+ val liveBlogTopSponsorship = mkLiveBlogTopSponsorship(editions = Seq(Uk))
+ liveBlogTopSponsorship.matchesEditionTargeting(Uk) shouldBe true
+ }
+
+ it should "be false if edition does not match sponsorship targeted editions" in {
+ val liveBlogTopSponsorship = mkLiveBlogTopSponsorship(editions = Seq(Uk))
+ liveBlogTopSponsorship.matchesEditionTargeting(Us) shouldBe false
+ }
+}
diff --git a/common/test/common/dfp/TakeoverWithEmptyMPUsTest.scala b/common/test/common/dfp/TakeoverWithEmptyMPUsTest.scala
deleted file mode 100644
index 843de67bb33c..000000000000
--- a/common/test/common/dfp/TakeoverWithEmptyMPUsTest.scala
+++ /dev/null
@@ -1,52 +0,0 @@
-package common.dfp
-
-import org.scalatest.flatspec.AnyFlatSpec
-import org.scalatest.matchers.should.Matchers
-import play.api.data.validation.{Invalid, Valid}
-
-class TakeoverWithEmptyMPUsTest extends AnyFlatSpec with Matchers {
-
- "TakeoverWithEmptyMPUs" should "recognise as valid urls that are at least 1 directory deep" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("http://www.theguardian.com/uk") should equal(Valid)
- }
-
- "TakeoverWithEmptyMPUs" should "recognise as valid urls that have multiple directories deep" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("http://www.theguardian.com/abc/def/ghi") should equal(Valid)
- }
-
- "TakeoverWithEmptyMPUs" should "recognise as invalid urls that have no path" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("http://www.theguardian.com") should equal(
- Invalid("Must be at least one directory deep. eg: http://www.theguardian.com/us"),
- )
- }
-
- "TakeoverWithEmptyMPUs" should "recognise as invalid urls that have a naked slash path" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("http://www.theguardian.com/") should equal(
- Invalid("Must be at least one directory deep. eg: http://www.theguardian.com/us"),
- )
- }
-
- "TakeoverWithEmptyMPUs" should "recognise as invalid urls that are invalid" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("123") should equal(
- Invalid("Must be a valid URL. eg: http://www.theguardian.com/us"),
- )
- }
-
- "TakeoverWithEmptyMPUs" should "recognise as invalid urls that are empty" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("") should equal(
- Invalid("Must be a valid URL. eg: http://www.theguardian.com/us"),
- )
- }
-
- "TakeoverWithEmptyMPUs" should "recognise as invalid urls that are empty beyond the naked slash" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("http://www.theguardian.com/ ") should equal(
- Invalid("Must be at least one directory deep. eg: http://www.theguardian.com/us"),
- )
- }
-
- "TakeoverWithEmptyMPUs" should "recognise as invalid urls that are empty beyond the root" in {
- TakeoverWithEmptyMPUs.mustBeAtLeastOneDirectoryDeep("http://www.theguardian.com ") should equal(
- Invalid("Must be at least one directory deep. eg: http://www.theguardian.com/us"),
- )
- }
-}
diff --git a/common/test/common/facia/FixtureBuilder.scala b/common/test/common/facia/FixtureBuilder.scala
index ab84a7e1563a..5ab2352d882f 100644
--- a/common/test/common/facia/FixtureBuilder.scala
+++ b/common/test/common/facia/FixtureBuilder.scala
@@ -26,7 +26,6 @@ object FixtureBuilder {
href = None,
description = None,
collectionType = "unknown",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -51,10 +50,15 @@ object FixtureBuilder {
def mkProperties(id: Int): PressedProperties =
PressedProperties(
isBreaking = false,
- showMainVideo = false,
+ mediaSelect = Some(
+ MediaSelect(
+ showMainVideo = false,
+ imageSlideshowReplace = false,
+ videoReplace = false,
+ ),
+ ),
showKickerTag = false,
showByline = false,
- imageSlideshowReplace = false,
maybeContent = None,
maybeContentId = Some(id.toString),
isLiveBlog = false,
@@ -114,6 +118,7 @@ object FixtureBuilder {
PressedDisplaySettings(
isBoosted = false,
boostLevel = Some(BoostLevel.Default),
+ isImmersive = Some(false),
showBoostedHeadline = false,
showQuotedHeadline = false,
imageHide = false,
@@ -132,6 +137,7 @@ object FixtureBuilder {
supportingContent = Nil,
cardStyle = DefaultCardstyle,
format = ContentFormat.defaultContentFormat,
+ mediaAtom = None,
)
}
@@ -145,6 +151,7 @@ object FixtureBuilder {
display = mkDisplay(),
enriched = None,
format = ContentFormat.defaultContentFormat,
+ mediaAtom = None,
)
}
}
diff --git a/common/test/common/facia/PressedCollectionBuilder.scala b/common/test/common/facia/PressedCollectionBuilder.scala
index 1310d940c04c..0c5486e74e9f 100644
--- a/common/test/common/facia/PressedCollectionBuilder.scala
+++ b/common/test/common/facia/PressedCollectionBuilder.scala
@@ -51,7 +51,6 @@ object PressedCollectionBuilder {
),
description = Some("desc"),
collectionType,
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
diff --git a/common/test/conf/CachedHealthCheckTest.scala b/common/test/conf/CachedHealthCheckTest.scala
index 886f9550e630..5c2581711f62 100644
--- a/common/test/conf/CachedHealthCheckTest.scala
+++ b/common/test/conf/CachedHealthCheckTest.scala
@@ -32,7 +32,7 @@ import scala.util.Random
val path = s"/path/${Random.alphanumeric.take(12).mkString}"
statusCode match {
case 200 => HealthCheckResult(path, HealthCheckResultTypes.Success(statusCode), date, expiration)
- case _ =>
+ case _ =>
HealthCheckResult(path, HealthCheckResultTypes.Failure(statusCode, "Something went bad"), date, expiration)
}
}
diff --git a/common/test/helpers/FaciaTestData.scala b/common/test/helpers/FaciaTestData.scala
index 2cd6b88728f3..39000abcd11f 100644
--- a/common/test/helpers/FaciaTestData.scala
+++ b/common/test/helpers/FaciaTestData.scala
@@ -118,7 +118,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -147,7 +146,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -176,7 +174,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -205,7 +202,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -234,7 +230,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -263,7 +258,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -292,7 +286,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -321,7 +314,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -350,7 +342,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -371,7 +362,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
@@ -392,7 +382,6 @@ trait FaciaTestData extends ModelHelper {
href = None,
description = None,
collectionType = "",
- groupsConfig = None,
uneditable = false,
showTags = false,
showSections = false,
diff --git a/common/test/html/BrazeEmailFormatterTest.scala b/common/test/html/BrazeEmailFormatterTest.scala
index 48d64e4d8031..f83308ed70c1 100644
--- a/common/test/html/BrazeEmailFormatterTest.scala
+++ b/common/test/html/BrazeEmailFormatterTest.scala
@@ -3,6 +3,7 @@ package html
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import play.twirl.api.Html
+import org.jsoup.Jsoup
class BrazeEmailFormatterTest extends AnyFlatSpec with Matchers {
@@ -83,7 +84,10 @@ class BrazeEmailFormatterTest extends AnyFlatSpec with Matchers {
|