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..e748cd52c068 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@v5
+
+ - 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@v5
- 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@v4
+ 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@v5
+
+ - run: corepack enable
+
+ - uses: actions/setup-node@v6
+ with:
+ cache: yarn
+ node-version-file: .nvmrc
+
+ - uses: actions/setup-java@v5
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@v5
+ 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..7cb4c8997138 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install Java
id: java
- uses: actions/setup-java@b36c23c0d998641eff861008f374ee103c25ac73 # v4.2.0
+ uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v4.2.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..148c12e49727 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@v5
- 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..9e4fb7300a22 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@v5
- 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/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..34c476b067c6 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,12 @@ 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 +57,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..ac18d06e9b3f 100644
--- a/admin/app/controllers/AdminControllers.scala
+++ b/admin/app/controllers/AdminControllers.scala
@@ -1,6 +1,6 @@
package controllers
import com.amazonaws.regions.Regions
-import com.amazonaws.services.s3.AmazonS3ClientBuilder
+import software.amazon.awssdk.services.s3.S3Client
import com.softwaremill.macwire._
import common.PekkoAsync
import controllers.admin._
@@ -15,6 +15,7 @@ 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 +27,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 +60,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..f3b899f09c12 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,23 +57,10 @@ 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] =
@@ -113,7 +79,7 @@ class CommercialController(
}
val sortedGroups = {
- hasNumericTestValue.toSeq.sortBy { case (testValue, _) => testValue.toInt } ++
+ hasNumericTestValue.toSeq.sortBy { case (testValue, _) => testValue.toLong } ++
hasStringTestValue.toSeq.sortBy { case (testValue, _) => testValue }
}
@@ -129,24 +95,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 +120,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..0f79bb0e1786 100644
--- a/admin/app/controllers/admin/TroubleshooterController.scala
+++ b/admin/app/controllers/admin/TroubleshooterController.scala
@@ -1,7 +1,5 @@
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}
@@ -36,14 +34,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 +53,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 +63,6 @@ class TroubleshooterController(wsClient: WSClient, val controllerComponents: Con
Seq(
directToContentApi,
directToLoadBalancer,
- directToRouter,
viaWebsite,
directToPreviewContentApi,
viaPreviewWebsite,
@@ -85,55 +73,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/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/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/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/model/AdminLifecycle.scala b/admin/app/model/AdminLifecycle.scala
index 73c204d11e24..a10ef9312b4b 100644
--- a/admin/app/model/AdminLifecycle.scala
+++ b/admin/app/model/AdminLifecycle.scala
@@ -21,7 +21,6 @@ class AdminLifecycle(
jobs: JobScheduler,
pekkoAsync: PekkoAsync,
emailService: EmailService,
- fastlyCloudwatchLoadJob: FastlyCloudwatchLoadJob,
r2PagePressJob: R2PagePressJob,
analyticsSanityCheckJob: AnalyticsSanityCheckJob,
rebuildIndexJob: RebuildIndexJob,
@@ -34,7 +33,7 @@ class AdminLifecycle(
descheduleJobs()
CloudWatch.shutdown()
emailService.shutdown()
- deleteTmpFiles()
+ // deleteTmpFiles()
}
}
@@ -56,11 +55,6 @@ class AdminLifecycle(
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()
}
@@ -110,7 +104,6 @@ class AdminLifecycle(
private def descheduleJobs(): Unit = {
jobs.deschedule("AdminLoadJob")
jobs.deschedule("LoadBalancerLoadJob")
- jobs.deschedule("FastlyCloudwatchLoadJob")
jobs.deschedule("R2PagePressJob")
jobs.deschedule("AnalyticsSanityCheckJob")
jobs.deschedule("RebuildIndexJob")
@@ -122,8 +115,6 @@ class AdminLifecycle(
jobs.deschedule("AssetMetricsCache")
}
- private def deleteTmpFiles(): Unit = AdminConfiguration.dfpApi.serviceAccountKeyFile.map(deleteIfExists)
-
override def start(): Unit = {
descheduleJobs()
scheduleJobs()
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/$%7Bfastly.serviceId%7D?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/CloudWatch.scala b/admin/app/tools/CloudWatch.scala
index 3a58f6946151..4fe0e23d4f20 100644
--- a/admin/app/tools/CloudWatch.scala
+++ b/admin/app/tools/CloudWatch.scala
@@ -10,15 +10,9 @@ import com.amazonaws.services.cloudwatch.model._
import common.GuLogging
import conf.Configuration
import conf.Configuration._
-import org.joda.time.DateTime
-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()
@@ -39,6 +33,9 @@ object CloudWatch extends GuLogging {
// some metrics are only available in the 'default' region
lazy val defaultClient: AmazonCloudWatchAsync = defaultClientBuilder.build()
+ val v1LoadBalancerNamespace = "AWS/ELB"
+ val v2LoadBalancerNamespace = "AWS/ApplicationELB"
+
val primaryLoadBalancers: Seq[LoadBalancer] = Seq(
LoadBalancer("frontend-router"),
LoadBalancer("frontend-article"),
@@ -57,47 +54,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,239 +63,6 @@ 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] =
- 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),
- ),
- )
- } 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(
@@ -347,68 +72,4 @@ object CloudWatch extends GuLogging {
),
)
}
-
- 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..a9703f31f57b 100644
--- a/admin/app/tools/LoadBalancer.scala
+++ b/admin/app/tools/LoadBalancer.scala
@@ -11,6 +11,8 @@ 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 {
@@ -20,20 +22,63 @@ object LoadBalancer extends GuLogging {
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)
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/errors.scala b/admin/app/tools/errors.scala
index 75cbb5e41a8d..99541a0fe128 100644
--- a/admin/app/tools/errors.scala
+++ b/admin/app/tools/errors.scala
@@ -4,56 +4,37 @@ import CloudWatch._
import com.amazonaws.services.cloudwatch.model.{Dimension, GetMetricStatisticsRequest}
import org.joda.time.DateTime
import awswrappers.cloudwatch._
-import conf.Configuration._
import scala.concurrent.{ExecutionContext, Future}
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.getMetricStatisticsFuture(metric(v1Metric4XX, v1LoadBalancerNamespace)),
+ ) 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"),
+ metric(v1Metric5XX, v1LoadBalancerNamespace),
) map { metric =>
- new AwsLineChart("Global 5XX", Seq("Time", "5XX/ min"), ChartFormat.SingleLineRed, 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)),
+ loadBalancerMetric(v1Metric4XX, v2Metric4XX, loadBalancer),
) map { metric =>
new AwsLineChart(loadBalancer.name, Seq("Time", "4XX/ min"), ChartFormat.SingleLineBlue, metric)
}
@@ -62,25 +43,35 @@ object HttpErrors {
def errors()(implicit executionContext: ExecutionContext): Future[Seq[AwsLineChart]] =
withErrorLogging(Future.traverse(primaryLoadBalancers ++ secondaryLoadBalancers) { loadBalancer =>
euWestClient.getMetricStatisticsFuture(
- metric("HTTPCode_Backend_5XX", Some(loadBalancer.id)),
+ loadBalancerMetric(v1Metric5XX, v2Metric5XX, loadBalancer),
) 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 = new GetMetricStatisticsRequest()
+ .withStartTime(new DateTime().minusHours(2).toDate)
+ .withEndTime(new DateTime().toDate)
+ .withPeriod(60)
+ .withStatistics("Sum")
+ .withNamespace(namespace)
+ .withMetricName(metricName)
- 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).withDimensions(
+ new Dimension().withName("LoadBalancerName").withValue(loadBalancer.id),
+ )
+ case Some(_) =>
+ metric(v2MetricName, v2LoadBalancerNamespace).withDimensions(
+ new Dimension().withName("LoadBalancer").withValue(loadBalancer.id),
+ )
+ }
}
+
}
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/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/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..d03bae65094c 100644
--- a/applications/app/controllers/ApplicationsControllers.scala
+++ b/applications/app/controllers/ApplicationsControllers.scala
@@ -39,6 +39,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/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..136419d4923c 100644
--- a/applications/app/controllers/InteractiveController.scala
+++ b/applications/app/controllers/InteractiveController.scala
@@ -126,12 +126,12 @@ class InteractiveController(
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 (AppsFormat, DotcomRendering) => renderApps(page, blocks)
+ case (AmpFormat, DotcomRendering) if page.interactive.content.shouldAmplify => renderAmp(page, blocks)
+ case (HtmlFormat | AmpFormat, DotcomRendering) => renderHtml(page, blocks)
+ case (JsonFormat, DotcomRendering) => renderJson(page, blocks)
+ case (HtmlFormat, PressedInteractive) => servePressedPage(path)
+ case _ => renderNonDCR(page)
}
}
case Left(result) => Future.successful(result)
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/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/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..e057ac1cd507 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.isTheFilterUS,
),
)
diff --git a/applications/conf/routes b/applications/conf/routes
index 2cc50e84c62f..b229ee0d0a06 100644
--- a/applications/conf/routes
+++ b/applications/conf/routes
@@ -17,11 +17,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 +64,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 +77,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 +122,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 +141,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/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/controllers/ArchiveController.scala b/archive/app/controllers/ArchiveController.scala
index 936909d45cc4..8c8dbb3b34a2 100644
--- a/archive/app/controllers/ArchiveController.scala
+++ b/archive/app/controllers/ArchiveController.scala
@@ -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/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/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/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..c71ad7500b80 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),
+ isTheFilterUS = article.content.isTheFilterUS,
),
) ++
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/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..3129e5b94d84 100644
--- a/build.sbt
+++ b/build.sbt
@@ -21,7 +21,6 @@ val common = library("common")
awsCore,
awsCloudwatch,
awsDynamodb,
- awsEc2,
awsKinesis,
awsS3,
awsSns,
@@ -71,8 +70,7 @@ val common = library("common")
pekkoSerializationJackson,
pekkoActorTyped,
janino,
- ) ++ jackson,
- TestAssets / mappings ~= filterAssets,
+ ) ++ jackson
)
val commonWithTests = withTests(common)
@@ -111,7 +109,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(https://github.com/s%22$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/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/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..37283dc448a9 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
@@ -33,3 +36,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/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/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/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/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/configuration.scala b/common/app/common/configuration.scala
index 2eff1ec15ef6..5384cb5b81b1 100644
--- a/common/app/common/configuration.scala
+++ b/common/app/common/configuration.scala
@@ -18,6 +18,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)
@@ -286,13 +287,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 +484,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 +494,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 +527,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 {
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..36ff0d87a1c0 100644
--- a/common/app/common/metrics.scala
+++ b/common/app/common/metrics.scala
@@ -138,18 +138,6 @@ 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",
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..74a8454b81ee 100644
--- a/common/app/conf/switches/ABTestSwitches.scala
+++ b/common/app/conf/switches/ABTestSwitches.scala
@@ -40,33 +40,22 @@ trait ABTestSwitches {
Switch(
ABTests,
- "ab-defer-permutive-load",
- "Test the impact of deferring the Permutive script load",
- 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(2025, 11, 26)),
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")),
+ "ab-compare-client-test-with-new-framework",
+ "Compare behaviour of new ab testing framework with existing one",
+ owners = Seq(Owner.withEmail("commercial.dev@guardian.co.uk")),
safeState = Off,
- sellByDate = Some(LocalDate.of(2025, 3, 28)),
- 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")),
- safeState = Off,
- sellByDate = Some(LocalDate.of(2025, 3, 28)),
+ sellByDate = Some(LocalDate.of(2025, 10, 31)),
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..42140b2f88f6 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,92 @@ 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 GuardianLabsRedesign = Switch(
+ SwitchGroup.Feature,
+ "guardian-labs-redesign",
+ "Shows the new style labs containers and cards",
+ owners = Seq(Owner.withEmail("commercial.dev@guardian.co.uk")),
+ sellByDate = Some(LocalDate.of(2025, 11, 18)),
+ safeState = Off,
+ exposeClientSide = true,
+ highImpact = false,
+ )
}
diff --git a/common/app/conf/switches/IdentitySwitches.scala b/common/app/conf/switches/IdentitySwitches.scala
index 795e6c6a10c0..e305ee4c920c 100644
--- a/common/app/conf/switches/IdentitySwitches.scala
+++ b/common/app/conf/switches/IdentitySwitches.scala
@@ -15,11 +15,22 @@ 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 consentOrPayEurope = Switch(
+ SwitchGroup.Identity,
+ "consent-or-pay-europe",
+ "Releasing Consent or Pay to Europe",
+ owners = Seq(Owner.withEmail("dotcom.platform@theguardian.com")),
+ safeState = Off,
+ sellByDate = never,
+ exposeClientSide = true,
+ highImpact = false,
+ )
+
+ 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/controllers/EmailSignupController.scala b/common/app/controllers/EmailSignupController.scala
index c459ce5033a3..e37b51ffb742 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,7 +447,11 @@ 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) {
@@ -453,7 +462,7 @@ class EmailSignupController(
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..f3858bedd4a7 100644
--- a/common/app/controllers/IndexControllerCommon.scala
+++ b/common/app/controllers/IndexControllerCommon.scala
@@ -75,6 +75,9 @@ 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)))
+ // 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 {
diff --git a/common/app/crosswords/CrosswordPage.scala b/common/app/crosswords/CrosswordPage.scala
index 191be74eeabe..bb6057d35d3b 100644
--- a/common/app/crosswords/CrosswordPage.scala
+++ b/common/app/crosswords/CrosswordPage.scala
@@ -59,6 +59,7 @@ class CrosswordSearchPage extends StandalonePage {
"quick-cryptic",
"quiptic",
"genius",
+ "sunday-quick",
"speedy",
"everyman",
"azed",
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..7b2af3325cf2 100644
--- a/common/app/experiments/Experiments.scala
+++ b/common/app/experiments/Experiments.scala
@@ -11,40 +11,46 @@ import java.time.LocalDate
object ActiveExperiments extends ExperimentsDefinition {
override val allExperiments: Set[Experiment] =
Set(
- EuropeBetaFront,
DarkModeWeb,
- DCRFootballMatches,
+ GoogleOneTap,
+ ConsentOrPayEuropeInternalTest,
+ LabsRedesign,
)
implicit val canCheckExperiment: CanCheckExperiment = new CanCheckExperiment(this)
}
-object EuropeBetaFront
+object ConsentOrPayEuropeInternalTest
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 = "consent-or-pay-europe-internal-test",
+ description = "Releasing Consent or Pay to Europe for internal testing",
+ owners = Seq(Owner.withEmail("identity.dev@guardian.co.uk")),
+ sellByDate = LocalDate.of(2026, 4, 1),
+ 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(2025, 12, 1),
+ participationGroup = Perc0B,
)
-object DCRFootballMatches
+object LabsRedesign
extends Experiment(
- name = "dcr-football-matches",
- description = "Render football matches lists in DCR",
+ name = "labs-redesign",
+ description = "Allows opting in to preview the Guardian Labs redesign work",
+ owners = Seq(Owner.withEmail("commercial.dev@theguardian.com")),
+ sellByDate = LocalDate.of(2025, 12, 16),
+ participationGroup = Perc0C,
+ )
+
+object DarkModeWeb
+ extends Experiment(
+ 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(2025, 10, 31),
+ participationGroup = Perc0D,
)
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..b5a1ac7ae37d 100644
--- a/common/app/http/GuardianAuthWithExemptions.scala
+++ b/common/app/http/GuardianAuthWithExemptions.scala
@@ -1,7 +1,8 @@
package http
import com.amazonaws.regions.Regions
-import com.amazonaws.services.s3.AmazonS3
+import software.amazon.awssdk.core.sync.ResponseTransformer
+import software.amazon.awssdk.services.s3.S3Client
import com.gu.pandomainauth.action.AuthActions
import com.gu.pandomainauth.model.AuthenticatedUser
import com.gu.pandomainauth.{PanDomain, PanDomainAuthSettingsRefresher, S3BucketLoader}
@@ -22,7 +23,7 @@ class GuardianAuthWithExemptions(
override val wsClient: WSClient,
toolsDomainPrefix: String,
oauthCallbackPath: String,
- s3Client: AmazonS3,
+ s3Client: S3Client,
system: String,
extraDoNotAuthenticatePathPrefixes: Seq[String],
requiredEditorialPermissionName: String,
@@ -57,7 +58,13 @@ class GuardianAuthWithExemptions(
override lazy val panDomainSettings = PanDomainAuthSettingsRefresher(
domain = toolsDomainSuffix,
system,
- S3BucketLoader.forAwsSdkV1(s3Client, "pan-domain-auth-settings"),
+ new S3BucketLoader {
+ def inputStreamFetching(key: String) =
+ s3Client.getObject(
+ _.bucket("pan-domain-auth-settings").key(key),
+ ResponseTransformer.toInputStream(),
+ )
+ },
)
override def authCallbackUrl = s"https://$toolsdomainprefix.$toolsdomainsuffix$oauthcallbackpath/"
diff --git a/common/app/implicits/Requests.scala b/common/app/implicits/Requests.scala
index 5f10adeadf4c..13d67a5d778b 100644
--- a/common/app/implicits/Requests.scala
+++ b/common/app/implicits/Requests.scala
@@ -62,6 +62,8 @@ trait Requests {
lazy val isEmailJson: Boolean = r.path.endsWith(EMAIL_JSON_SUFFIX)
+ lazy val isInteractiveRedirect: Boolean = r.path.startsWith("/interactive/")
+
lazy val isEmailTxt: Boolean = r.path.endsWith(EMAIL_TXT_SUFFIX)
lazy val isLazyLoad: Boolean =
@@ -86,6 +88,7 @@ trait Requests {
lazy val pathWithoutModifiers: String =
if (isEmail) r.path.stripSuffix(EMAIL_SUFFIX)
+ else if (isInteractiveRedirect) r.path.stripPrefix("/interactive")
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/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..24a55ef46e99 100644
--- a/common/app/layout/Front.scala
+++ b/common/app/layout/Front.scala
@@ -79,8 +79,7 @@ object Front {
collections match {
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/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/model/Cached.scala b/common/app/model/Cached.scala
index ab4c826a59e7..4d5805d4786f 100644
--- a/common/app/model/Cached.scala
+++ b/common/app/model/Cached.scala
@@ -29,6 +29,9 @@ object CacheTime {
object DiscussionDefault extends CacheTime(60)
object DiscussionClosed extends CacheTime(60, Some(longCacheTime))
object Football extends CacheTime(10)
+ object FootballMatch extends CacheTime(30)
+ object Cricket extends CacheTime(60)
+ object FootballTables extends CacheTime(60)
private def oldArticleCacheTime = if (ShorterSurrogateCacheForOlderArticles.isSwitchedOn) 60 else longCacheTime
def LastDayUpdated = CacheTime(60, Some(oldArticleCacheTime))
def NotRecentlyUpdated = CacheTime(60, Some(oldArticleCacheTime))
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..6addc07952f4 100644
--- a/common/app/model/Formats.scala
+++ b/common/app/model/Formats.scala
@@ -272,6 +272,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]
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/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/content.scala b/common/app/model/content.scala
index d73f3d011597..d2961f37592f 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
@@ -89,6 +87,7 @@ final case class Content(
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 isTheFilterUS: Boolean = productionOffice.exists(_.toLowerCase == "us")
lazy val campaigns: List[Campaign] =
_root_.commercial.targeting.CampaignAgent.getCampaignsForTags(tags.tags.map(_.id))
@@ -96,6 +95,8 @@ 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
@@ -1009,7 +1010,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..a8f4e5c43918 100644
--- a/common/app/model/content/Atom.scala
+++ b/common/app/model/content/Atom.scala
@@ -1,6 +1,7 @@
package model.content
import com.gu.contentatom.thrift.atom.media.{Asset => AtomApiMediaAsset, MediaAtom => AtomApiMediaAtom}
+import com.gu.contentatom.thrift.AtomDataAliases.{MediaAlias => MediaAtomData}
import com.gu.contentatom.thrift.atom.timeline.{TimelineItem => TimelineApiItem}
import com.gu.contentatom.thrift.{
AtomData,
@@ -166,6 +167,7 @@ final case class MediaAtom(
expired: Option[Boolean],
activeVersion: Option[Long],
channelId: Option[String],
+ trailImage: Option[ImageMedia],
) extends Atom {
def activeAssets: Seq[MediaAsset] =
@@ -193,8 +195,18 @@ final case class MediaAsset(
version: Long,
platform: MediaAssetPlatform,
mimeType: Option[String],
+ assetType: MediaAssetType,
)
+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 +218,24 @@ 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)),
+ )
+ }
+
def mediaAtomMake(id: String, defaultHtml: String, mediaAtom: AtomApiMediaAtom): MediaAtom = {
val expired: Option[Boolean] = for {
metadata <- mediaAtom.metadata
@@ -223,6 +253,7 @@ object MediaAtom extends common.GuLogging {
expired = expired,
activeVersion = mediaAtom.activeVersion,
channelId = mediaAtom.metadata.flatMap(_.channelId),
+ trailImage = mediaAtom.trailImage.map(imageMediaMake(_, mediaAtom.title)),
)
}
@@ -236,6 +267,7 @@ object MediaAtom extends common.GuLogging {
version = mediaAsset.version,
platform = MediaAssetPlatform.withName(mediaAsset.platform.name),
mimeType = mediaAsset.mimeType,
+ assetType = MediaAssetType.withName(mediaAsset.assetType.name),
)
}
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..ec41e432aac1 100644
--- a/common/app/model/dotcomrendering/DotcomRenderingDataModel.scala
+++ b/common/app/model/dotcomrendering/DotcomRenderingDataModel.scala
@@ -18,11 +18,14 @@ import model.{
CanonicalLiveBlog,
ContentFormat,
ContentPage,
+ ContentType,
CrosswordData,
DotcomContentType,
GUDateTimeFormatNew,
+ Gallery,
GalleryPage,
ImageContentPage,
+ ImageMedia,
InteractivePage,
LiveBlogPage,
MediaPage,
@@ -33,6 +36,8 @@ import play.api.libs.json._
import play.api.mvc.RequestHeader
import services.NewsletterData
import views.support.{CamelCase, ContentLayout, JavaScriptPage}
+import ab.ABTests
+
// -----------------------------------------------------------------
// 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,
@@ -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..52add15651ab 100644
--- a/common/app/model/dotcomrendering/DotcomRenderingUtils.scala
+++ b/common/app/model/dotcomrendering/DotcomRenderingUtils.scala
@@ -1,9 +1,8 @@
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
@@ -54,7 +53,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,
),
)
@@ -185,7 +184,6 @@ object DotcomRenderingUtils {
article: ContentType,
affiliateLinks: Boolean,
isMainBlock: Boolean,
- isImmersive: Boolean,
campaigns: Option[JsValue],
calloutsUrl: Option[String],
): List[PageElement] = {
@@ -201,12 +199,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.isTheFilterUS,
),
)
.filter(PageElement.isSupported)
@@ -242,27 +242,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 = {
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..d20d930710ef 100644
--- a/common/app/model/dotcomrendering/ElementsEnhancer.scala
+++ b/common/app/model/dotcomrendering/ElementsEnhancer.scala
@@ -75,6 +75,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..77e6a5a25830 100644
--- a/common/app/model/dotcomrendering/pageElements/PageElement.scala
+++ b/common/app/model/dotcomrendering/pageElements/PageElement.scala
@@ -5,6 +5,7 @@ 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,
SponsorshipType,
TimelineElementFields,
WitnessElementFields,
@@ -22,7 +23,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._
@@ -497,6 +498,18 @@ 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 QABlockElement(id: String, title: String, img: Option[String], html: String, credit: String)
extends PageElement
object QABlockElement {
@@ -883,6 +896,7 @@ object PageElement {
case _: VineBlockElement => true
case _: ListBlockElement => true
case _: TimelineBlockElement => true
+ case _: LinkBlockElement => 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 +917,8 @@ object PageElement {
overrideImage: Option[ImageElement],
edition: Edition,
webPublicationDate: DateTime,
+ isGallery: Boolean,
+ isTheFilterUS: Boolean,
): List[PageElement] = {
def extractAtom: Option[Atom] =
@@ -920,7 +936,7 @@ object PageElement {
element.`type` match {
case Text =>
val textCleaners =
- TextCleaner.affiliateLinks(pageUrl, addAffiliateLinks) _ andThen
+ TextCleaner.affiliateLinks(pageUrl, addAffiliateLinks, isTheFilterUS) _ andThen
TextCleaner.sanitiseLinks(edition)
for {
@@ -1012,7 +1028,7 @@ object PageElement {
List(
ImageBlockElement(
ImageMedia(imageAssets.toSeq),
- imageDataFor(element),
+ imageDataFor(element, isGallery, pageUrl, addAffiliateLinks, isTheFilterUS),
element.imageTypeData.flatMap(_.displayCredit),
Role(element.imageTypeData.flatMap(_.role), defaultRole),
imageSources,
@@ -1387,6 +1403,18 @@ object PageElement {
),
)
.toList
+
+ case Link =>
+ element.linkTypeData
+ .map(d =>
+ LinkBlockElement(
+ AffiliateLinksCleaner.replaceUrlInLink(d.url, pageUrl, addAffiliateLinks, isTheFilterUS),
+ d.label,
+ d.linkType.getOrElse(LinkType.ProductButton),
+ ),
+ )
+ .toList
+
case Interactive =>
element.interactiveTypeData
.map(d =>
@@ -1479,6 +1507,8 @@ object PageElement {
edition,
webPublicationDate,
item,
+ isGallery,
+ isTheFilterUS,
)
}.toSeq,
listElementType = listTypeData.`type`.map(_.name),
@@ -1498,6 +1528,8 @@ object PageElement {
edition,
webPublicationDate,
timelineTypeData,
+ isGallery,
+ isTheFilterUS,
),
)
}.toList
@@ -1517,6 +1549,8 @@ object PageElement {
edition: Edition,
webPublicationDate: DateTime,
timelineTypeData: TimelineElementFields,
+ isGallery: Boolean,
+ isTheFilterUS: Boolean,
) = {
timelineTypeData.sections.map { section =>
TimelineSection(
@@ -1540,6 +1574,8 @@ object PageElement {
overrideImage = None,
edition,
webPublicationDate,
+ isGallery,
+ isTheFilterUS,
)
.headOption
},
@@ -1556,6 +1592,8 @@ object PageElement {
overrideImage = None,
edition,
webPublicationDate,
+ isGallery,
+ isTheFilterUS,
)
}.toSeq,
)
@@ -1574,6 +1612,8 @@ object PageElement {
edition: Edition,
webPublicationDate: DateTime,
item: v1.ListItem,
+ isGallery: Boolean,
+ isTheFilterUS: Boolean,
) = {
ListItem(
elements = item.elements.flatMap { element =>
@@ -1589,6 +1629,8 @@ object PageElement {
overrideImage = None,
edition,
webPublicationDate,
+ isGallery,
+ isTheFilterUS,
)
}.toSeq,
title = item.title,
@@ -1912,12 +1954,24 @@ object PageElement {
pageElement.flatten
}
- private def imageDataFor(element: ApiBlockElement): Map[String, String] = {
+ private def imageDataFor(
+ element: ApiBlockElement,
+ isGallery: Boolean,
+ pageUrl: String,
+ addAffiliateLinks: Boolean,
+ isTheFilterUS: 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, isTheFilterUS))
+ } 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..88f9143d39a0 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, isTheFilterUS: Boolean)(
+ html: String,
+ ): String = {
if (addAffiliateLinks) {
val doc = Jsoup.parseBodyFragment(html)
val links = AffiliateLinksCleaner.getAffiliateableLinks(doc)
+ val skimlinksId =
+ if (isTheFilterUS) 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,
+ isTheFilterUS: Boolean,
+ ): String = {
+
+ val cleaners = List(
+ GalleryCaptionCleaner,
+ GalleryAffiliateLinksCleaner(
+ pageUrl,
+ shouldAddAffiliateLinks,
+ isTheFilterUS,
+ ),
+ )
+
+ 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,42 @@ 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,
+ isTheFilterUS: Boolean,
+) extends HtmlCleaner
+ with GuLogging {
+
+ override def clean(document: Document): Document = {
+ val skimlinksId = if (isTheFilterUS) 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..21af72d7a1ca 100644
--- a/common/app/model/liveblog/BlockElement.scala
+++ b/common/app/model/liveblog/BlockElement.scala
@@ -182,6 +182,7 @@ object BlockElement {
case Recipe => Some(UnsupportedBlockElement(None))
case ElementType.List => Some(UnsupportedBlockElement(None))
case Timeline => Some(UnsupportedBlockElement(None))
+ case Link => 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/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..9a2cfd778245 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,11 @@ 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("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 +243,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 +314,7 @@ object NavLinks {
climateCrisis,
middleEast,
ukraine,
+ usImmigration,
usSoccer,
usBusiness,
usEnvironment,
@@ -381,7 +375,6 @@ object NavLinks {
children = List(
auColumnists,
cartoons,
- indigenousAustraliaOpinion,
theGuardianView.copy(title = "Editorials"),
letters,
),
@@ -588,6 +581,7 @@ object NavLinks {
)
val usLifestylePillar = ukLifestylePillar.copy(
children = List(
+ theFilterUs,
usWellness,
fashion,
food,
@@ -639,11 +633,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 +650,7 @@ object NavLinks {
crosswords,
wordiply,
corrections,
+ tips,
)
val usOtherLinks = List(
apps,
@@ -667,6 +662,7 @@ object NavLinks {
crosswords,
wordiply,
corrections,
+ tips,
)
val intOtherLinks = List(
apps,
@@ -676,11 +672,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 +686,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 +705,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 +722,7 @@ object NavLinks {
holidays.copy(url = holidays.url + "?INTCMP=holidays_int_web_newheader"),
digitalNewspaperArchive,
guardianLicensing,
+ guardianLiveINT,
aboutUs,
)
val eurBrandExtensions = List(
@@ -733,6 +730,7 @@ object NavLinks {
holidays.copy(url = holidays.url + "?INTCMP=holidays_int_web_newheader"),
digitalNewspaperArchive,
guardianLicensing,
+ guardianLiveEUR,
aboutUs,
)
@@ -829,10 +827,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..93fee0006efd 100644
--- a/common/app/renderers/DotcomRenderingService.scala
+++ b/common/app/renderers/DotcomRenderingService.scala
@@ -281,8 +281,6 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
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,8 +288,6 @@ class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload
request,
pageType,
mostViewed,
- mostCommented,
- mostShared,
deeplyRead,
)
@@ -379,7 +375,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 +386,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 +398,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 +410,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 +422,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 +459,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/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/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
./service/https://github.com/url(url)
.post(payload)
diff --git a/common/app/services/repositories.scala b/common/app/services/repositories.scala
index 6a73603e413b..f2a038c12034 100644
--- a/common/app/services/repositories.scala
+++ b/common/app/services/repositories.scala
@@ -80,7 +80,15 @@ trait Index extends ConciergeRepository {
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..3de0fd30dd95 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,6 +30,10 @@ 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(
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/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..aa9fd0f06618 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],
+ isTheFilterUS: Boolean,
) extends HtmlCleaner
with GuLogging {
@@ -879,6 +880,7 @@ case class AffiliateLinksCleaner(
tags,
)
) {
+ val skimlinksId = if (isTheFilterUS) skimlinksUSId else skimlinksDefaultId
AffiliateLinksCleaner.replaceLinksInHtml(document, pageUrl, skimlinksId)
} else document
}
@@ -901,17 +903,17 @@ 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,
+ isTheFilterUS: Boolean,
+ ): Option[String] = {
+ val skimlinksId = if (isTheFilterUS) skimlinksUSId else skimlinksDefaultId
+ url match {
+ case Some(link) if addAffiliateLinks && SkimLinksCache.isSkimLink(link) =>
+ Some(linkToSkimLink(link, pageUrl, skimlinksId))
+ case _ => url
}
}
diff --git a/common/app/views/support/ImageProfile.scala b/common/app/views/support/ImageProfile.scala
index 614df858eb2d..8c936ea699fb 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,
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/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/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 {
|