diff --git a/common/app/common/configuration.scala b/common/app/common/configuration.scala index 59d8e7f32ce..32ea1bc1da5 100644 --- a/common/app/common/configuration.scala +++ b/common/app/common/configuration.scala @@ -159,10 +159,6 @@ class GuardianConfiguration extends GuLogging { lazy val url = configuration.getMandatoryStringProperty("contributionsService.url") } - object weather { - lazy val apiKey = configuration.getStringProperty("weather.api.key") - } - object indexes { lazy val tagIndexesBucket = configuration.getMandatoryStringProperty("tag_indexes.bucket") diff --git a/common/app/conf/switches/FeatureSwitches.scala b/common/app/conf/switches/FeatureSwitches.scala index 7b3a2d5c49c..659b5bef03e 100644 --- a/common/app/conf/switches/FeatureSwitches.scala +++ b/common/app/conf/switches/FeatureSwitches.scala @@ -173,17 +173,6 @@ trait FeatureSwitches { highImpact = false, ) - val WeatherSwitch = Switch( - SwitchGroup.Feature, - "weather", - "If this is switched on then the weather component is displayed", - owners = Seq(Owner.withGithub("johnduffell")), - safeState = Off, - sellByDate = never, - exposeClientSide = true, - highImpact = false, - ) - val HistoryTags = Switch( SwitchGroup.Feature, "history-tags", diff --git a/dev-build/conf/routes b/dev-build/conf/routes index 8329b037133..19279330f1a 100644 --- a/dev-build/conf/routes +++ b/dev-build/conf/routes @@ -54,21 +54,6 @@ OPTIONS /email # Business data GET /business-data/stocks.json controllers.StocksController.stocks -# Weather - EVEN OLDER -GET /weather/city/:id.json weather.controllers.WeatherController.forCity(id) -GET /weather/city.json weather.controllers.LocationsController.whatIsMyCity() -GET /weather/locations weather.controllers.LocationsController.findCity(query: String) -GET /weather/forecast/:id.json weather.controllers.WeatherController.forecastForCityId(id) - -# Weather - OLD -GET /weatherapi/city/:id.json weather.controllers.WeatherController.forCity(id) -GET /weatherapi/city.json weather.controllers.LocationsController.whatIsMyCity() -GET /weatherapi/locations weather.controllers.LocationsController.findCity(query: String) -GET /weatherapi/forecast/:id.json weather.controllers.WeatherController.forecastForCityId(id) - -#Weather - DCR -GET /weather weather.controllers.WeatherController.theWeather() - # Analytics GET /analytics/abtests controllers.admin.AnalyticsController.abtests() GET /analytics/confidence controllers.admin.AnalyticsConfidenceController.renderConfidence() diff --git a/onward/app/AppLoader.scala b/onward/app/AppLoader.scala index df9509c43f8..032a30eac3a 100644 --- a/onward/app/AppLoader.scala +++ b/onward/app/AppLoader.scala @@ -19,7 +19,6 @@ import play.api.routing.Router import play.api.libs.ws.WSClient import router.Routes import services.{OphanApi, PopularInTagService} -import weather.WeatherApi import _root_.commercial.targeting.TargetingLifecycle import scala.concurrent.ExecutionContext @@ -41,7 +40,6 @@ trait OnwardServices { lazy val contentApiClient = wire[ContentApiClient] lazy val ophanApi = wire[OphanApi] lazy val stocksData = wire[StocksData] - lazy val weatherApi = wire[WeatherApi] lazy val geoMostPopularAgent = wire[GeoMostPopularAgent] lazy val dayMostPopularAgent = wire[DayMostPopularAgent] lazy val mostPopularAgent = wire[MostPopularAgent] diff --git a/onward/app/controllers/OnwardControllers.scala b/onward/app/controllers/OnwardControllers.scala index 4919dc62639..8a7eb0c2e1c 100644 --- a/onward/app/controllers/OnwardControllers.scala +++ b/onward/app/controllers/OnwardControllers.scala @@ -2,14 +2,12 @@ package controllers import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem} import com.softwaremill.macwire._ -import weather.controllers.{LocationsController, WeatherController} import business.StocksData import contentapi.ContentApiClient import feed._ import model.ApplicationContext import play.api.libs.ws.WSClient import play.api.mvc.ControllerComponents -import weather.WeatherApi import agents.DeeplyReadAgent import renderers.DotcomRenderingService import services.PopularInTagService @@ -20,7 +18,6 @@ trait OnwardControllers { def wsClient: WSClient def contentApiClient: ContentApiClient def stocksData: StocksData - def weatherApi: WeatherApi def geoMostPopularAgent: GeoMostPopularAgent def dayMostPopularAgent: DayMostPopularAgent def mostPopularAgent: MostPopularAgent @@ -36,8 +33,6 @@ trait OnwardControllers { def popularInTagService: PopularInTagService lazy val navigationController = wire[NavigationController] - lazy val weatherController = wire[WeatherController] - lazy val locationsController = wire[LocationsController] lazy val mostViewedSocialController = wire[MostViewedSocialController] lazy val mostPopularController = wire[MostPopularController] lazy val topStoriesController = wire[TopStoriesController] diff --git a/onward/app/views/weatherFragments/cityForecast.scala.html b/onward/app/views/weatherFragments/cityForecast.scala.html deleted file mode 100644 index f2fe95d1b5e..00000000000 --- a/onward/app/views/weatherFragments/cityForecast.scala.html +++ /dev/null @@ -1,18 +0,0 @@ -@(forecasts: Seq[weather.models.ForecastResponse])(implicit request: RequestHeader) - -@import common.Edition - - -@for((forecast, index) <- forecasts.drop(1).zipWithIndex) { - @defining(forecast.temperatureForEdition(Edition(request))) { temp => -
  • - - @fragments.inlineSvg(s"weather-${forecast.weatherIcon}", "weather", Seq("weather__icon", "js-weather-icon"), Some(s"${forecast.hourString}: ${temp}, ${forecast.weatherText.toLowerCase()}.")) - -
  • - } -} diff --git a/onward/app/views/weatherFragments/cityWeather.scala.html b/onward/app/views/weatherFragments/cityWeather.scala.html deleted file mode 100644 index d6cbcb8ede6..00000000000 --- a/onward/app/views/weatherFragments/cityWeather.scala.html +++ /dev/null @@ -1,44 +0,0 @@ -@(weatherResponse: weather.models.accuweather.WeatherResponse)(implicit request: RequestHeader) - -@import common.Edition - -@defining(weatherResponse.temperatureForEdition(Edition(request))) { temp => - -} diff --git a/onward/app/weather/WeatherApi.scala b/onward/app/weather/WeatherApi.scala deleted file mode 100644 index 5bc3a496d11..00000000000 --- a/onward/app/weather/WeatherApi.scala +++ /dev/null @@ -1,140 +0,0 @@ -package weather - -import java.net.{URI, URLEncoder} -import java.util.concurrent.TimeoutException -import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem, Scheduler} -import common.{GuLogging, ResourcesHelper} -import conf.Configuration -import play.api.libs.json.{JsValue, Json} -import play.api.libs.ws.WSClient -import weather.geo.LatitudeLongitude -import weather.models.CityId -import weather.models.accuweather.{ForecastResponse, LocationResponse, WeatherResponse} -import model.ApplicationContext - -import scala.concurrent.duration._ -import play.api.{MarkerContext, Mode} -import net.logstash.logback.marker.Markers.append -import org.apache.pekko.pattern.after - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.control.NonFatal - -class WeatherApi(wsClient: WSClient, context: ApplicationContext, pekkoActorSystem: PekkoActorSystem)(implicit - ec: ExecutionContext, -) extends ResourcesHelper - with GuLogging { - - // NOTE: If you change the API Key, you must also update the weatherapi fastly configuration, as it is enforced there - lazy val weatherApiKey: String = Configuration.weather.apiKey.getOrElse( - throw new RuntimeException("Weather API Key not set"), - ) - - val requestTimeout: FiniteDuration = 300.milliseconds - val requestRetryMax: Int = 3 - val requestRetryDelay: FiniteDuration = 100.milliseconds - - val accuWeatherApiUri = "https://weather.guardianapis.com" - - private def autocompleteUrl(query: String): String = - s"$accuWeatherApiUri/locations/v1/cities/autocomplete?apikey=$weatherApiKey&q=${URLEncoder.encode(query, "utf-8")}" - - private def cityLookUp(cityId: CityId): String = - s"$accuWeatherApiUri/currentconditions/v1/${cityId.id}.json?apikey=$weatherApiKey" - - private def forecastLookUp(cityId: CityId): String = - s"$accuWeatherApiUri/forecasts/v1/hourly/24hour/${cityId.id}.json?details=true&apikey=$weatherApiKey" - - private def latitudeLongitudeUrl(latitudeLongitude: LatitudeLongitude): String = { - s"$accuWeatherApiUri/locations/v1/cities/geoposition/search.json?q=$latitudeLongitude&apikey=$weatherApiKey" - } - - private def searchForCityUrl(countryCode: String, city: String): String = { - s"$accuWeatherApiUri/locations/v1/cities/$countryCode/search.json?q=$city&alias=Always&apikey=$weatherApiKey" - } - - private def getJson(url: String): Future[JsValue] = { - if (context.environment.mode == Mode.Test) { - Future(Json.parse(slurpOrDie(new URI(url).getPath.stripPrefix("/")))) - } else { - getJsonWithRetry(url) - } - } - - private def getJsonWithRetry(url: String): Future[JsValue] = { - val weatherLogsMarkerContext: MarkerContext = MarkerContext(append("weatherRequestPath", url)) - val weatherApiResponse: Future[JsValue] = WeatherApi.retryWeatherRequest( - () => getJsonRequest(url), - requestRetryDelay, - pekkoActorSystem.scheduler, - requestRetryMax, - ) - weatherApiResponse.failed.foreach { - case error: TimeoutException => - log.warn( - s"Request to weather api ($url) timed out (this is expected, especially at 0 and 30 mins past the hour due to" + - s" a problem with accuweather).", - error, - )(weatherLogsMarkerContext) - case error: Throwable => - log.error("Weather API request failed", error)(weatherLogsMarkerContext) - } - weatherApiResponse - } - - private def getJsonRequest(url: String): Future[JsValue] = { - wsClient - .url(url) - .withRequestTimeout(requestTimeout) - .get() - .map { response => - if (response.status == 200) response.json else throw new RuntimeException(s"Weather API response: $response") - } - } - - def searchForLocations(query: String): Future[Seq[LocationResponse]] = - getJson(autocompleteUrl(query)).map({ r => - Json.fromJson[Seq[LocationResponse]](r).get - }) - - def searchForCity(countryCode: String, city: String) = - getJson(searchForCityUrl(countryCode, city)).map({ r => - Json.fromJson[Seq[LocationResponse]](r).get - }) - - def getNearestCity(latitudeLongitude: LatitudeLongitude): Future[LocationResponse] = - getJson(latitudeLongitudeUrl(latitudeLongitude)).map({ r => - Json.fromJson[LocationResponse](r).get - }) - - def getWeatherForCityId(cityId: CityId): Future[WeatherResponse] = - getJson(cityLookUp(cityId)).map({ r => - Json.fromJson[Seq[WeatherResponse]](r).get.headOption getOrElse { - throw new RuntimeException(s"Empty weather response for $cityId") - } - }) - - def getForecastForCityId(cityId: CityId): Future[Seq[ForecastResponse]] = - getJson(forecastLookUp(cityId)).map({ r => - Json.fromJson[Seq[ForecastResponse]](r).get - }) -} - -object WeatherApi extends GuLogging { - - def retryWeatherRequest( - request: () => Future[JsValue], - retryDelay: FiniteDuration, - scheduler: Scheduler, - attempts: Int, - )(implicit ec: ExecutionContext): Future[JsValue] = { - def loop(attemptsRemaining: Int): Future[JsValue] = { - request().recoverWith { case NonFatal(error) => - if (attemptsRemaining <= 1) Future.failed(error) - else after(retryDelay, scheduler)(loop(attemptsRemaining - 1)) - } - } - loop(attempts) - } - -} diff --git a/onward/app/weather/controllers/LocationsController.scala b/onward/app/weather/controllers/LocationsController.scala deleted file mode 100644 index cd317e80607..00000000000 --- a/onward/app/weather/controllers/LocationsController.scala +++ /dev/null @@ -1,75 +0,0 @@ -package weather.controllers - -import common._ -import model.{CacheTime, Cached} -import weather.models.CityResponse -import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} -import weather.WeatherApi - -import scala.language.postfixOps -import scala.concurrent.duration._ -import scala.concurrent.Future - -class LocationsController(weatherApi: WeatherApi, val controllerComponents: ControllerComponents) - extends BaseController - with ImplicitControllerExecutionContext - with GuLogging { - - def findCity(query: String): Action[AnyContent] = - Action.async { implicit request => - weatherApi.searchForLocations(query) map { locations => - Cached(7.days)(JsonComponent.fromWritable(CityResponse.fromLocationResponses(locations.toList))) - } - } - - val CityHeader: String = "X-GU-GeoCity" - val RegionHeader: String = "X-GU-GeoRegion" - val CountryHeader: String = "X-GU-GeoCountry" - - def whatIsMyCity(): Action[AnyContent] = - Action.async { implicit request => - def cityFromRequestEdition = CityResponse.fromEdition(Edition(request)) - - def getEncodedHeader(key: String) = - request.headers.get(key).map(java.net.URLDecoder.decode(_, "latin1")) - - val maybeCountry = getEncodedHeader(CountryHeader).filter(_.nonEmpty) - val maybeCity = getEncodedHeader(CityHeader).filter(_.nonEmpty) - val maybeRegion = getEncodedHeader(RegionHeader).filter(_.nonEmpty) - - (maybeCountry, maybeCity) match { - case (Some(countryCode), Some(city)) => - weatherApi.searchForCity(countryCode, city) map { locations => - val cities = CityResponse.fromLocationResponses( - locations - .filter(_.Country.ID == countryCode) - .sortBy(_.AdministrativeArea.ID match { - // We want to get cities within a matching region to come up first - case region if maybeRegion.contains(region) => 0 - case _ => 1 - }) - .toList, - ) - cities.headOption.fold { - log.warn(s"Could not find $countryCode, $city, $maybeRegion") - Cached(CacheTime.NotFound)(JsonNotFound()) - } { weatherCity => - log.info(s"Matched $countryCode, $city, $maybeRegion to ${weatherCity.id}") - // We do this as AccuWeather writes "New York, New York" if no region is specified, where as we - // just get "New York" from Fastly. - val weatherCityWithoutRegion = weatherCity.copy(city = city) - Cached(1 hour)(JsonComponent.fromWritable(weatherCityWithoutRegion)) - } - } - - case (_, _) => - Future.successful( - cityFromRequestEdition.fold { - Cached(CacheTime.NotFound)(JsonNotFound()) - } { city => - Cached(1 hour)(JsonComponent.fromWritable(city)) - }, - ) - } - } -} diff --git a/onward/app/weather/controllers/WeatherController.scala b/onward/app/weather/controllers/WeatherController.scala deleted file mode 100644 index e9ad4f99edf..00000000000 --- a/onward/app/weather/controllers/WeatherController.scala +++ /dev/null @@ -1,110 +0,0 @@ -package weather.controllers - -import common.JsonComponent.resultFor -import common.Seqs.RichSeq -import common.{GuLogging, ImplicitControllerExecutionContext, JsonComponent, JsonNotFound} -import model.{CacheTime, Cached} -import play.api.libs.json.Json.{stringify, toJson} -import play.api.mvc._ -import weather.WeatherApi -import weather.models.{CityId, CityResponse, Weather, WeatherResponse} - -import scala.concurrent.Future -import scala.concurrent.duration._ - -class WeatherController(weatherApi: WeatherApi, val controllerComponents: ControllerComponents) - extends BaseController - with ImplicitControllerExecutionContext - with GuLogging { - - def theWeather(): Action[AnyContent] = - Action.async { implicit request => - val (country, city, region) = readLocationHeaders - - val weatherFuture: Future[Result] = for { - location <- whatIsMyCity(country, city, region) - currentWeather <- weatherApi.getWeatherForCityId(CityId(location.id)) - forecasts <- weatherApi.getForecastForCityId(CityId(location.id)) - } yield { - val weather = Weather( - location = location, - weather = WeatherResponse.fromAccuweather(currentWeather), - forecast = forecasts.map(WeatherResponse.fromAccuweather), - ) - val weatherJson = stringify(toJson(weather)) - Cached(10.minutes)(resultFor(request, weatherJson)) - } - - weatherFuture.recover({ - case _: CityNotFoundException => - Cached(CacheTime.NotFound)(JsonNotFound()) - - case error: Throwable => - InternalServerError(s"An error occurred: ${error.getMessage}") - }) - } - - def forCity(cityId: String): Action[AnyContent] = - Action.async { implicit request => - weatherApi.getWeatherForCityId(CityId(cityId)).map { weather => - Cached(10.minutes)(JsonComponent(views.html.weatherFragments.cityWeather(weather))) - } - } - - def forecastForCityId(cityId: String): Action[AnyContent] = - Action.async { implicit request => - weatherApi - .getForecastForCityId(CityId(cityId)) - .map({ forecastDays => - val response = - forecastDays.map(weather.models.ForecastResponse.fromAccuweather).filterByIndex(_ % 3 == 0).take(5) - - Cached(10.minutes)(JsonComponent(views.html.weatherFragments.cityForecast(response))) - }) - } - - private def readLocationHeaders(implicit request: Request[AnyContent]) = { - val CityHeader = "X-GU-GeoCity" - val RegionHeader = "X-GU-GeoRegion" - val CountryHeader = "X-GU-GeoCountry" - - def getEncodedHeader(key: String)(implicit request: Request[AnyContent]) = - request.headers.get(key).map(java.net.URLDecoder.decode(_, "latin1")) - - val country = getEncodedHeader(CountryHeader) - val city = getEncodedHeader(CityHeader) - val region = getEncodedHeader(RegionHeader) - - (country, city, region) - } - - private def whatIsMyCity(maybeCountry: Option[String], maybeCity: Option[String], maybeRegion: Option[String])( - implicit request: Request[AnyContent], - ): Future[CityResponse] = { - (maybeCountry, maybeCity) match { - case (Some(countryCode), Some(city)) if countryCode.nonEmpty && city.nonEmpty => - weatherApi.searchForCity(countryCode, city) flatMap { locations => - locations - .filter(_.Country.ID == countryCode) - .sortBy(_.AdministrativeArea.ID match { - // We want to get cities within a matching region to come up first - case region if maybeRegion.contains(region) => 0 - case _ => 1 - }) - .headOption match { - case Some(location) => Future.successful(CityResponse.fromLocationResponse(location)) - case None => - log.warn( - s"Could not match country [$maybeCountry], " + - s"city [$maybeCity] and region [$maybeRegion]" + - s"to a valid location.", - ) - Future.failed(CityNotFoundException()) - } - } - case (_, _) => Future.failed(CityNotFoundException()) - } - } -} - -case class CityNotFoundException() extends Exception("City not found") diff --git a/onward/app/weather/geo/LatitudeLongitude.scala b/onward/app/weather/geo/LatitudeLongitude.scala deleted file mode 100644 index fffda9acebc..00000000000 --- a/onward/app/weather/geo/LatitudeLongitude.scala +++ /dev/null @@ -1,5 +0,0 @@ -package weather.geo - -case class LatitudeLongitude(latitude: Double, longitude: Double) { - override def toString: String = s"$latitude,$longitude" -} diff --git a/onward/app/weather/models/CityId.scala b/onward/app/weather/models/CityId.scala deleted file mode 100644 index 871f8f33815..00000000000 --- a/onward/app/weather/models/CityId.scala +++ /dev/null @@ -1,5 +0,0 @@ -package weather.models - -case class City(name: String) extends AnyVal - -case class CityId(id: String) extends AnyVal diff --git a/onward/app/weather/models/CityResponse.scala b/onward/app/weather/models/CityResponse.scala deleted file mode 100644 index 7d4baa55de1..00000000000 --- a/onward/app/weather/models/CityResponse.scala +++ /dev/null @@ -1,87 +0,0 @@ -package weather.models - -import common.Edition -import common.editions.{Au, Uk, Us} -import play.api.libs.json.{Json, Reads, Writes} -import weather.models.accuweather.LocationResponse - -object CityResponse { - implicit val jsonReads: Reads[CityResponse] = Json.reads[CityResponse] - - implicit val writes: Writes[CityResponse] = new Writes[CityResponse] { - def writes(model: CityResponse) = { - Json.obj( - "id" -> model.id, - "city" -> model.city, - "country" -> model.country, - ) - } - } - - def fromLocationResponses(locations: List[LocationResponse]): Seq[CityResponse] = { - def cityAndCountry(location: LocationResponse): (String, String) = - (location.LocalizedName, location.Country.LocalizedName) - - val citiesWithSameNameByCountry = locations.foldLeft(Map.empty[(String, String), Int]) { (accumulation, location) => - val key = cityAndCountry(location) - accumulation + (key -> (accumulation.getOrElse(key, 0) + 1)) - } - - locations.map { location => - val needsDisambiguating = citiesWithSameNameByCountry.get(cityAndCountry(location)).exists(_ > 1) - - val cityName = - if (needsDisambiguating) - s"${location.LocalizedName}, ${location.AdministrativeArea.LocalizedName}" - else - location.LocalizedName - - CityResponse( - location.Key, - cityName, - location.Country.LocalizedName, - ) - } - } - - def fromLocationResponse(location: LocationResponse): CityResponse = { - CityResponse( - location.Key, - location.LocalizedName, - location.Country.LocalizedName, - ) - } - - val London = CityResponse( - id = "328328", - city = "London", - country = "England", - ) - - val NewYork = CityResponse( - id = "349727", - city = "New York", - country = "US", - ) - - val Sydney = CityResponse( - id = "22889", - city = "Sydney", - country = "Australia", - ) - - def fromEdition(edition: Edition): Option[CityResponse] = { - edition match { - case Uk => Some(London) - case Us => Some(NewYork) - case Au => Some(Sydney) - case _ => None - } - } -} - -case class CityResponse( - id: String, - city: String, - country: String, -) diff --git a/onward/app/weather/models/ForecastResponse.scala b/onward/app/weather/models/ForecastResponse.scala deleted file mode 100644 index 3d9477b3d9a..00000000000 --- a/onward/app/weather/models/ForecastResponse.scala +++ /dev/null @@ -1,50 +0,0 @@ -package weather.models - -import common.Edition -import common.editions.Us -import org.joda.time.DateTime -import org.joda.time.format.ISODateTimeFormat - -import scala.util.Try - -object ForecastResponse { - - def fromAccuweather(forecastResponse: accuweather.ForecastResponse): ForecastResponse = { - val dateTime = Try( - ISODateTimeFormat - .dateTimeNoMillis() - .withOffsetParsed() - .parseDateTime(forecastResponse.DateTime), - ).toOption - ForecastResponse( - dateTime, - forecastResponse.WeatherIcon, - forecastResponse.IconPhrase, - forecastResponse.Temperature.Unit match { - case "C" => Temperatures.fromCelsius(forecastResponse.Temperature.Value) - case "F" => Temperatures.fromFahrenheit(forecastResponse.Temperature.Value) - case _ => - throw new RuntimeException( - "Temperature of neither celsius nor fahrenheit from " + - s"Accuweather! $forecastResponse", - ) - }, - ) - } -} - -case class ForecastResponse( - dateTime: Option[DateTime], - weatherIcon: Int, - weatherText: String, - temperature: Temperatures, -) { - def temperatureForEdition(edition: Edition): String = { - edition match { - case Us => s"${temperature.imperial}°F" - case _ => s"${temperature.metric}°C" - } - } - - def hourString: String = dateTime.map(_.toString("HH:00")).getOrElse("") -} diff --git a/onward/app/weather/models/Temperatures.scala b/onward/app/weather/models/Temperatures.scala deleted file mode 100644 index 71a98a02266..00000000000 --- a/onward/app/weather/models/Temperatures.scala +++ /dev/null @@ -1,34 +0,0 @@ -package weather.models - -import play.api.libs.json.{JsValue, Json, Reads, Writes} - -object Temperatures { - implicit val jsonReads: Reads[Temperatures] = Json.reads[Temperatures] - - implicit val jsonWrites: Writes[Temperatures] = new Writes[Temperatures] { - override def writes(o: Temperatures): JsValue = { - - Json.obj( - "metric" -> o.metric, - "imperial" -> o.imperial, - ) - } - } - - def fromCelsius(celsius: Double): Temperatures = - Temperatures( - metric = celsius.round, - imperial = ((celsius * 9d / 5) + 32).round, - ) - - def fromFahrenheit(fahrenheit: Double): Temperatures = - Temperatures( - metric = ((fahrenheit - 32) * 5d / 9).round, - imperial = fahrenheit.round, - ) -} - -case class Temperatures( - metric: Long, - imperial: Long, -) diff --git a/onward/app/weather/models/Weather.scala b/onward/app/weather/models/Weather.scala deleted file mode 100644 index a6a5472a0df..00000000000 --- a/onward/app/weather/models/Weather.scala +++ /dev/null @@ -1,23 +0,0 @@ -package weather.models - -import play.api.libs.json.{Json, Reads, Writes} - -object Weather { - implicit val jsonReads: Reads[Weather] = Json.reads[Weather] - - implicit val writes: Writes[Weather] = new Writes[Weather] { - def writes(model: Weather) = { - Json.obj( - "location" -> model.location, - "weather" -> model.weather, - "forecast" -> model.forecast, - ) - } - } -} - -case class Weather( - location: CityResponse, - weather: WeatherResponse, - forecast: Seq[WeatherResponse], -) diff --git a/onward/app/weather/models/WeatherResponse.scala b/onward/app/weather/models/WeatherResponse.scala deleted file mode 100644 index 28017fa3423..00000000000 --- a/onward/app/weather/models/WeatherResponse.scala +++ /dev/null @@ -1,45 +0,0 @@ -package weather.models - -import play.api.libs.json.{Json, Reads, Writes} - -object WeatherResponse { - implicit val jsonReads: Reads[WeatherResponse] = Json.reads[WeatherResponse] - - implicit val writes: Writes[WeatherResponse] = new Writes[WeatherResponse] { - def writes(model: WeatherResponse) = { - Json.obj( - "description" -> model.weatherText, - "icon" -> model.weatherIcon, - "link" -> model.weatherLink, - "temperature" -> model.temperature, - "dateTime" -> model.dateTime, - ) - } - } - - def fromAccuweather(weatherResponse: accuweather.WeatherResponse): WeatherResponse = - WeatherResponse( - weatherText = weatherResponse.WeatherText, - weatherIcon = weatherResponse.WeatherIcon, - weatherLink = Some(weatherResponse.Link), - temperature = Temperatures.fromCelsius(weatherResponse.Temperature("Metric").Value), - dateTime = None, - ) - - def fromAccuweather(forecastResponse: accuweather.ForecastResponse): WeatherResponse = - WeatherResponse( - weatherText = forecastResponse.IconPhrase, - weatherIcon = forecastResponse.WeatherIcon, - weatherLink = None, - temperature = Temperatures.fromFahrenheit(forecastResponse.Temperature.Value), - dateTime = Some(forecastResponse.DateTime), - ) -} - -case class WeatherResponse( - weatherText: String, - weatherIcon: Int, - weatherLink: Option[String], - temperature: Temperatures, - dateTime: Option[String], -) diff --git a/onward/app/weather/models/accuweather/ForecastResponse.scala b/onward/app/weather/models/accuweather/ForecastResponse.scala deleted file mode 100644 index e540cbef1b7..00000000000 --- a/onward/app/weather/models/accuweather/ForecastResponse.scala +++ /dev/null @@ -1,25 +0,0 @@ -package weather.models.accuweather - -import play.api.libs.json.{Json, OFormat} - -/** Not all the fields AccuWeather provides, but the ones we want */ - -object Temperature { - implicit val jsonFormat: OFormat[Temperature] = Json.format[Temperature] -} - -case class Temperature( - Value: Double, - Unit: String, -) - -object ForecastResponse { - implicit val jsonFormat: OFormat[ForecastResponse] = Json.format[ForecastResponse] -} - -case class ForecastResponse( - DateTime: String, - WeatherIcon: Int, - IconPhrase: String, - Temperature: Temperature, -) diff --git a/onward/app/weather/models/accuweather/LocationResponse.scala b/onward/app/weather/models/accuweather/LocationResponse.scala deleted file mode 100644 index 630c6a0ba6e..00000000000 --- a/onward/app/weather/models/accuweather/LocationResponse.scala +++ /dev/null @@ -1,26 +0,0 @@ -package weather.models.accuweather - -import play.api.libs.json.{Json, Reads} - -/* Not all the fields the AccuWeather API returns, but the ones we care about */ - -object LocationName { - implicit val jsonReads: Reads[LocationName] = Json.reads[LocationName] -} - -case class LocationName( - ID: String, - LocalizedName: String, -) - -object LocationResponse { - implicit val jsonReads: Reads[LocationResponse] = Json.reads[LocationResponse] -} - -case class LocationResponse( - Key: String, - LocalizedName: String, - Country: LocationName, - AdministrativeArea: LocationName, - Type: String, -) diff --git a/onward/app/weather/models/accuweather/WeatherResponse.scala b/onward/app/weather/models/accuweather/WeatherResponse.scala deleted file mode 100644 index a642e0cdb8d..00000000000 --- a/onward/app/weather/models/accuweather/WeatherResponse.scala +++ /dev/null @@ -1,25 +0,0 @@ -package weather.models.accuweather - -import common.Edition -import common.editions.Us -import model.dotcomrendering.{DotcomRenderingDataModel, ElementsEnhancer} -import play.api.libs.json.{Json, Reads, Writes} - -/** Not all the fields AccuWeather supplies, just the ones we care about */ - -object WeatherResponse { - implicit val jsonReads: Reads[WeatherResponse] = Json.reads[WeatherResponse] -} - -case class WeatherResponse( - WeatherText: String, - WeatherIcon: Int, - Link: String, - Temperature: Map[String, Temperature], -) { - def temperatureForEdition(edition: Edition): Temperature = - edition match { - case Us => Temperature("Imperial") - case _ => Temperature("Metric") - } -} diff --git a/onward/conf/routes b/onward/conf/routes index 51ab64bab1c..423b7f2d538 100644 --- a/onward/conf/routes +++ b/onward/conf/routes @@ -7,22 +7,6 @@ GET /assets/*path dev.DevAssetsController.at GET /_healthcheck controllers.HealthCheck.healthCheck() -# Onward public endpoints - -# Weather -GET /weather/city/:id.json weather.controllers.WeatherController.forCity(id) -GET /weather/city.json weather.controllers.LocationsController.whatIsMyCity() -GET /weather/locations weather.controllers.LocationsController.findCity(query: String) -GET /weather/forecast/:id.json weather.controllers.WeatherController.forecastForCityId(id) - -GET /weatherapi/city/:id.json weather.controllers.WeatherController.forCity(id) -GET /weatherapi/city.json weather.controllers.LocationsController.whatIsMyCity() -GET /weatherapi/locations weather.controllers.LocationsController.findCity(query: String) -GET /weatherapi/forecast/:id.json weather.controllers.WeatherController.forecastForCityId(id) - -# Weather - DCR -GET /weather.json weather.controllers.WeatherController.theWeather() - # Most Read GET /most-read-facebook.json controllers.MostViewedSocialController.renderMostViewed(socialContext: String = "facebook") GET /most-read-twitter.json controllers.MostViewedSocialController.renderMostViewed(socialContext: String = "twitter") diff --git a/onward/test/weather/WeatherApiTest.scala b/onward/test/weather/WeatherApiTest.scala deleted file mode 100644 index 24f9b8d544f..00000000000 --- a/onward/test/weather/WeatherApiTest.scala +++ /dev/null @@ -1,29 +0,0 @@ -package weather - -import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem} -import org.scalatest.concurrent.ScalaFutures -import play.api.libs.json.{JsString, JsValue} -import org.mockito.Mockito._ -import org.scalatest.flatspec.AnyFlatSpec -import org.scalatest.matchers.should.Matchers -import org.scalatestplus.mockito.MockitoSugar - -import scala.concurrent.ExecutionContext.Implicits.global -import scala.concurrent.duration._ -import scala.concurrent.Future -import scala.language.postfixOps - -class WeatherApiTest extends AnyFlatSpec with ScalaFutures with Matchers with MockitoSugar { - val actorSystem = PekkoActorSystem() - - "retryWeatherRequest" should "return for a successful future" in { - val jsValue = JsString("Test") - - val funMock = mock[() => Future[JsValue]] - when(funMock.apply()) thenReturn Future.successful(jsValue) - - whenReady(WeatherApi.retryWeatherRequest(funMock, 100 milli, actorSystem.scheduler, 5))(_ shouldBe jsValue) - verify(funMock).apply() - } - -} diff --git a/preview/conf/routes b/preview/conf/routes index 8b238b70dc9..bf1265d279c 100644 --- a/preview/conf/routes +++ b/preview/conf/routes @@ -184,16 +184,6 @@ POST /email/footer POST /email controllers.EmailSignupController.submit() OPTIONS /email controllers.EmailSignupController.options() -# Weather - OLD -GET /weather/city/:id.json weather.controllers.WeatherController.forCity(id) -GET /weather/city.json weather.controllers.LocationsController.whatIsMyCity() -GET /weather/locations weather.controllers.LocationsController.findCity(query: String) - -# Weather -GET /weatherapi/city/:id.json weather.controllers.WeatherController.forCity(id) -GET /weatherapi/city.json weather.controllers.LocationsController.whatIsMyCity() -GET /weatherapi/locations weather.controllers.LocationsController.findCity(query: String) - # Articles GET /$path<[^/]+/([^/]+/)?live/.*>.json controllers.LiveBlogController.renderJson(path, page: Option[String], lastUpdate: Option[String], rendered: Option[Boolean], isLivePage: Option[Boolean], filterKeyEvents: Option[Boolean]) GET /$path<[^/]+/([^/]+/)?live/.*>/email controllers.LiveBlogController.renderEmail(path) diff --git a/static/src/javascripts/bootstraps/enhanced/facia.js b/static/src/javascripts/bootstraps/enhanced/facia.js index 30deb13cc46..d5eef86abbd 100644 --- a/static/src/javascripts/bootstraps/enhanced/facia.js +++ b/static/src/javascripts/bootstraps/enhanced/facia.js @@ -13,7 +13,6 @@ import { lazyLoadContainers } from 'facia/modules/ui/lazy-load-containers'; import { showUpdatesFromLiveBlog } from 'facia/modules/ui/live-blog-updates'; import { init as initSnaps } from 'facia/modules/ui/snaps'; import { init as initRegionSelector } from 'facia/modules/ui/au-region-selector' -import { Weather } from 'facia/modules/onwards/weather'; import partial from 'lodash/partial'; import { videoContainerInit } from 'common/modules/video/video-container'; import { addContributionsBanner } from 'journalism/modules/audio-series-add-contributions'; @@ -60,14 +59,6 @@ const upgradeMostPopularToGeo = () => { } }; -const showWeather = () => { - if (config.get('switches.weather')) { - mediator.on('page:front:ready', () => { - Weather.init(); - }); - } -}; - const showLiveblogUpdates = () => { if ( isBreakpoint({ @@ -147,7 +138,6 @@ const init = () => { ['f-geo-most-popular', upgradeMostPopularToGeo], ['f-lazy-load-containers', lazyLoadContainers], ['f-stocks', stocks], - ['f-weather', showWeather], ['f-live-blog-updates', showLiveblogUpdates], ['f-video-playlists', upgradeVideoPlaylists], ['f-audio-flagship-contributions', addContributionBannerToAudioSeries], diff --git a/static/src/javascripts/projects/facia/modules/onwards/weather.js b/static/src/javascripts/projects/facia/modules/onwards/weather.js deleted file mode 100644 index e8f35d8f8b0..00000000000 --- a/static/src/javascripts/projects/facia/modules/onwards/weather.js +++ /dev/null @@ -1,195 +0,0 @@ -/** - "WEATHER" - - Whether the weather be fine, - Or whether the weather be not, - Whether the weather be cold, - Or whether the weather be hot, - We'll weather the weather - Whatever the weather, - Whether we like it or not! - - Author: Anonymous British - */ - -import bean from 'bean'; -import { reportError } from 'lib/report-error'; -import $ from 'lib/$'; -import config from 'lib/config'; -import { fetchJson } from 'lib/fetch-json'; -import { mediator } from 'lib/mediator'; -import userPrefs from 'common/modules/user-prefs'; -import { SearchTool } from 'facia/modules/onwards/search-tool'; - -let $holder = null; -let searchTool = null; -let eventsBound = false; -const prefName = 'weather-location'; - - - -const isNetworkFront = () => - ['uk', 'us', 'au', 'international'].includes(config.get('page.pageId')); - -export const Weather = { - init() { - if (!config.get('switches.weather', false) || !isNetworkFront()) { - return false; - } - - this.getDefaultLocation(); - }, - - /** - * Check if user has data in local storage. - * If yes return data from local storage else return default location data. - */ - getUserLocation() { - const prefs = userPrefs.get(prefName); - - if (prefs && prefs.id) { - return prefs; - } - }, - - getWeatherData(url) { - return fetchJson(url, { - mode: 'cors', - }); - }, - - /** - * Save user location into localStorage - */ - saveUserLocation(location) { - userPrefs.set(prefName, { - id: location.id, - city: location.city, - }); - }, - - getDefaultLocation() { - const location = this.getUserLocation(); - - if (location) { - return this.fetchWeatherData(location); - } - return this.getWeatherData(`${config.get('page.weatherapiurl')}.json`) - .then(response => { - this.fetchWeatherData(response); - }) - .catch(err => { - reportError(err, { - feature: 'weather', - }); - }); - }, - - fetchWeatherData(location) { - const weatherApiBase = config.get('page.weatherapiurl'); - const edition = config.get('page.edition'); - if (!location) return; - return this.getWeatherData( - `${weatherApiBase}/${ - location.id - }.json?_edition=${edition.toLowerCase()}` - ) - .then(response => { - this.render(response, location.city); - this.fetchForecastData(location); - }) - .catch(err => { - reportError(err, { - feature: 'weather', - }); - }); - }, - - clearLocation() { - userPrefs.remove(prefName); - if (searchTool !== null) { - searchTool.setInputValue(); - } - }, - - fetchForecastData(location) { - return this.getWeatherData( - `${config.get('page.forecastsapiurl')}/${ - location.id - }.json?_edition=${config.get('page.edition').toLowerCase()}` - ) - .then(response => { - this.renderForecast(response); - }) - .catch(err => { - reportError(err, { - feature: 'weather', - }); - }); - }, - - saveDeleteLocalStorage(response) { - if (response.store === 'set') { - // After user interaction we want to store the location in localStorage - this.saveUserLocation(response); - this.fetchWeatherData(response).then(() => this.toggleForecast()); - } else if (response.store === 'remove') { - // After user sent empty data we want to remove location and get the default location - this.clearLocation(); - this.getDefaultLocation(); - } - }, - - bindEvents() { - bean.on(document.body, 'click', '.js-toggle-forecast', e => { - e.preventDefault(); - this.toggleForecast(); - }); - - mediator.on( - 'autocomplete:fetch', - this.saveDeleteLocalStorage.bind(this) - ); - }, - - toggleForecast() { - $('.weather').toggleClass('is-expanded'); - }, - - addSearch() { - searchTool = new SearchTool({ - container: $('.js-search-tool'), - apiUrl: config.get('page.locationapiurl'), - }); - }, - - render(weatherData, city) { - if (!weatherData) return; - this.attachToDOM(weatherData.html, city); - - if (!eventsBound) { - this.bindEvents(); - eventsBound = true; - } - - if (searchTool === null) { - this.addSearch(); - } else { - searchTool.bindElements($('.js-search-tool')); - } - }, - - attachToDOM(tmpl, city) { - $holder = $('#headlines .js-container__header'); - $('.js-weather', $holder).remove(); - $holder.append(tmpl.replace(new RegExp('<%=city%>', 'g'), city)); - }, - - renderForecast(forecastData) { - if (!forecastData) return; - const $forecastHolder = $('.js-weather-forecast'); - const tmpl = forecastData.html; - - $forecastHolder.empty().html(tmpl); - }, -}; diff --git a/static/src/javascripts/projects/facia/modules/onwards/weather.spec.js b/static/src/javascripts/projects/facia/modules/onwards/weather.spec.js deleted file mode 100644 index 9accbc43042..00000000000 --- a/static/src/javascripts/projects/facia/modules/onwards/weather.spec.js +++ /dev/null @@ -1,234 +0,0 @@ -/* eslint-disable guardian-frontend/no-direct-access-config */ -import config from 'lib/config'; -import { fetchJson } from 'lib/fetch-json'; -import userPrefs from 'common/modules/user-prefs'; -import { Weather } from 'facia/modules/onwards/weather'; - -jest.mock('lib/raven'); -jest.mock('lib/config'); -jest.mock('common/modules/user-prefs'); -jest.mock('lib/fetch-json', () => ({ fetchJson: jest.fn() })); - -const fetchJsonMock = (fetchJson); - -describe('Weather component', () => { - beforeEach(() => { - if (document.body) { - document.body.innerHTML = ` -
    -
    -
    -
    - `; - } - fetchJsonMock.mockImplementation(() => Promise.resolve()); - }); - afterEach(() => { - config.page = null; - config.switches = { - weather: true, - }; - userPrefs.remove('weather-location'); - fetchJsonMock.mockReset(); - }); - - describe('initialisation', () => { - it('should be behind a switch', () => { - config.page = { - pageId: 'uk', - edition: 'uk', - }; - config.switches = { - weather: false, - }; - - expect(Weather.init()).toEqual(false); - - config.switches = null; - expect(Weather.init()).toEqual(false); - - config.switches = { - weather: true, - }; - expect(Weather.init()).not.toEqual(false); - }); - - it('should initialize only if on front page', () => { - config.page = { - pageId: '/social', - }; - expect(Weather.init()).toEqual(false); - - config.page.pageId = 'uk'; - expect(Weather.init()).not.toEqual(false); - }); - - it('should return false when the page is not network front', () => { - config.page = { - pageId: 'uk', - }; - expect(Weather.init()).not.toEqual(false); - - config.page.pageId = 'us'; - expect(Weather.init()).not.toEqual(false); - - config.page.pageId = 'au'; - expect(Weather.init()).not.toEqual(false); - - config.page.pageId = 'social'; - expect(Weather.init()).toEqual(false); - }); - }); - - it('should get location from user prefs', () => { - const result = { - id: 'qux', - city: 'doo', - }; - expect(typeof Weather.getUserLocation()).toEqual('undefined'); - - Weather.saveUserLocation(result); - expect(Weather.getUserLocation()).toEqual(result); - }); - - it('should get the default location', () => { - config.page = { - weatherapiurl: 'foo', - edition: 'bar', - }; - - fetchJsonMock.mockImplementationOnce(() => - Promise.resolve({ - id: 'qux', - }) - ); - - return Weather.getDefaultLocation().then(() => { - expect(fetchJsonMock.mock.calls[0][0]).toEqual('foo.json'); - expect(fetchJsonMock.mock.calls[1][0]).toEqual( - 'foo/qux.json?_edition=bar' - ); - }); - }); - - it('should set data in userprefs and fetchWeatherData if user searches', () => { - config.page = { - weatherapiurl: 'foo', - edition: 'bar', - }; - - const cityPreference = { - store: 'set', - id: 'qux', - city: 'doo', - }; - - Weather.saveDeleteLocalStorage(cityPreference); - - expect(userPrefs.get('weather-location')).toEqual({ - id: 'qux', - city: 'doo', - }); - expect(fetchJsonMock.mock.calls[0][0]).toEqual( - 'foo/qux.json?_edition=bar' - ); - }); - - it('should remove data from userprefs and getDefaultLocation if user removes data', () => { - config.page = { - weatherapiurl: 'foo', - edition: 'bar', - }; - - const cityPreference = { - store: 'remove', - id: 'qux', - city: 'doo', - }; - - Weather.saveDeleteLocalStorage(cityPreference); - - expect(userPrefs.get('weather-location')).toBeUndefined(); - expect(fetchJsonMock.mock.calls[0][0]).toEqual('foo.json'); - }); - - it('should fetch the data', () => { - config.page = { - weatherapiurl: 'foo', - edition: 'bar', - }; - - Weather.fetchWeatherData({ - id: 'qux', - city: 'doo', - }); - expect(fetchJsonMock.mock.calls[0][0]).toEqual( - 'foo/qux.json?_edition=bar' - ); - }); - - it('should call render after fetching the weather data', () => { - config.page = { - weatherapiurl: 'foo', - edition: 'bar', - }; - - fetchJsonMock.mockImplementationOnce(() => - Promise.resolve({ - html: ` -
    - - 4°C - `, - }) - ); - - return Weather.fetchWeatherData({ - id: 'qux', - city: 'doo', - }).then(() => { - if (document.body) { - expect(document.body.innerHTML).toContain('value="doo"'); - } - }); - }); - - it('should fetch the forecast data', () => { - config.page = { - forecastsapiurl: 'foo', - edition: 'bar', - }; - - Weather.fetchForecastData({ - id: 'qux', - city: 'doo', - }); - expect(fetchJsonMock.mock.calls[0][0]).toEqual( - 'foo/qux.json?_edition=bar' - ); - }); - - it('should call render after fetching the forecast data', () => { - config.page = { - forecastsapiurl: 'foo', - edition: 'bar', - }; - - fetchJsonMock.mockImplementationOnce(() => - Promise.resolve({ - html: '
    ', - }) - ); - - return Weather.fetchForecastData({ - id: 'qux', - city: 'doo', - }).then(() => { - if (document.body) { - expect(document.body.innerHTML).toContain( - '
    ' - ); - } - }); - }); -});