From 48dacc2783ff8155d73fcabf26fd5d10c46aa905 Mon Sep 17 00:00:00 2001 From: Robert Berry Date: Wed, 23 Jul 2014 11:41:36 +0100 Subject: [PATCH 1/4] Add keyword and contributor alpha indexes --- admin/app/Global.scala | 29 +++- .../indexes/ContentApiTagsEnumerator.scala | 55 +++++++ admin/app/indexes/TagPages.scala | 111 +++++++++++++ admin/app/indexes/package.scala | 7 + admin/app/jobs/RebuildIndexJob.scala | 71 ++++++++ admin/test/indexes/TagPagesTest.scala | 108 +++++++++++++ applications/app/Global.scala | 10 ++ .../app/controllers/TagIndexController.scala | 48 ++++++ .../app/model/TagIndexListingMetaData.scala | 25 +++ .../app/model/TagIndexPageMetadata.scala | 51 ++++++ .../app/services/indexAutoRefreshes.scala | 33 ++++ .../views/contributorsIndexListing.scala.html | 7 + .../keywordIndexListingBody.scala.html | 43 +++++ .../views/fragments/tagIndexBody.scala.html | 27 ++++ .../fragments/tagIndexListingBody.scala.html | 30 ++++ .../app/views/keywordsIndexListing.scala.html | 7 + .../app/views/tagIndexPage.scala.html | 7 + applications/conf/routes | 5 + common/app/assets/stylesheets/head.index.scss | 9 ++ .../assets/stylesheets/ie9.head.index.scss | 6 + .../app/assets/stylesheets/module/_index.scss | 151 ++++++++++++++++++ .../stylesheets/module/content/_content.scss | 1 - .../assets/stylesheets/old-ie.head.index.scss | 7 + common/app/common/AutoRefresh.scala | 41 +++++ common/app/common/Maps.scala | 7 + common/app/common/Navigation.scala | 23 +-- common/app/common/StopWatch.scala | 11 ++ common/app/common/configuration.scala | 8 + common/app/model/TagIndexPage.scala | 50 ++++++ common/app/model/diagnostics/CloudWatch.scala | 2 +- common/app/model/meta.scala | 4 +- common/app/services/TagIndexesS3.scala | 67 ++++++++ .../fragments/nav/signposting.scala.html | 5 +- common/conf/env/CODE.properties | 2 + common/conf/env/DEV.properties | 2 + common/conf/env/DEVINFRA.properties | 2 + common/conf/env/GUDEV.properties | 2 + common/conf/env/PROD.properties | 4 +- common/test/common/MapsTest.scala | 14 ++ 39 files changed, 1069 insertions(+), 23 deletions(-) create mode 100644 admin/app/indexes/ContentApiTagsEnumerator.scala create mode 100644 admin/app/indexes/TagPages.scala create mode 100644 admin/app/indexes/package.scala create mode 100644 admin/app/jobs/RebuildIndexJob.scala create mode 100644 admin/test/indexes/TagPagesTest.scala create mode 100644 applications/app/controllers/TagIndexController.scala create mode 100644 applications/app/model/TagIndexListingMetaData.scala create mode 100644 applications/app/model/TagIndexPageMetadata.scala create mode 100644 applications/app/services/indexAutoRefreshes.scala create mode 100644 applications/app/views/contributorsIndexListing.scala.html create mode 100644 applications/app/views/fragments/keywordIndexListingBody.scala.html create mode 100644 applications/app/views/fragments/tagIndexBody.scala.html create mode 100644 applications/app/views/fragments/tagIndexListingBody.scala.html create mode 100644 applications/app/views/keywordsIndexListing.scala.html create mode 100644 applications/app/views/tagIndexPage.scala.html create mode 100644 common/app/assets/stylesheets/head.index.scss create mode 100644 common/app/assets/stylesheets/ie9.head.index.scss create mode 100644 common/app/assets/stylesheets/module/_index.scss create mode 100644 common/app/assets/stylesheets/old-ie.head.index.scss create mode 100644 common/app/common/AutoRefresh.scala create mode 100644 common/app/common/Maps.scala create mode 100644 common/app/common/StopWatch.scala create mode 100644 common/app/model/TagIndexPage.scala create mode 100644 common/app/services/TagIndexesS3.scala create mode 100644 common/test/common/MapsTest.scala diff --git a/admin/app/Global.scala b/admin/app/Global.scala index 0aedeb4db200..e1331e8eb00e 100644 --- a/admin/app/Global.scala +++ b/admin/app/Global.scala @@ -2,9 +2,11 @@ import commercial.TravelOffersCacheJob import common.{AkkaAsync, Jobs, CloudWatchApplicationMetrics} import conf.{Configuration, Gzipper, Management} import dfp.DfpDataCacheJob -import jobs.RefreshFrontsJob +import jobs.{RebuildIndexJob, RefreshFrontsJob} import model.AdminLifecycle import ophan.SurgingContentAgentLifecycle +import play.api.Play +import play.api.Play.current import play.api.mvc.{WithFilters, Results, RequestHeader} import scala.concurrent.Future @@ -17,6 +19,8 @@ with SurgingContentAgentLifecycle { val adminPressJobPushRateInMinutes: Int = Configuration.faciatool.adminPressJobPushRateInMinutes + val adminRebuildIndexRateInMinutes: Int = Configuration.indexes.adminRebuildIndexRateInMinutes + override def onError(request: RequestHeader, ex: Throwable) = Future.successful(InternalServerError( views.html.errorPage(ex) )) @@ -27,6 +31,10 @@ with SurgingContentAgentLifecycle { RefreshFrontsJob.run() } + Jobs.schedule("RebuildIndexJob", s"0 0/$adminRebuildIndexRateInMinutes * 1/1 * ? *") { + RebuildIndexJob.run() + } + // every 30 minutes Jobs.schedule("DfpDataCacheJob", "0 1/30 * * * ? *") { DfpDataCacheJob.run() @@ -41,17 +49,24 @@ with SurgingContentAgentLifecycle { override def onStart(app: play.api.Application) { super.onStart(app) - descheduleJobs() - scheduleJobs() - AkkaAsync { - DfpDataCacheJob.run() - TravelOffersCacheJob.run() + if (!Play.isTest) { + descheduleJobs() + scheduleJobs() + + AkkaAsync { + RebuildIndexJob.run() + DfpDataCacheJob.run() + TravelOffersCacheJob.run() + } } } override def onStop(app: play.api.Application) { - descheduleJobs() + if (!Play.isTest) { + descheduleJobs() + } + super.onStop(app) } } diff --git a/admin/app/indexes/ContentApiTagsEnumerator.scala b/admin/app/indexes/ContentApiTagsEnumerator.scala new file mode 100644 index 000000000000..31364828b232 --- /dev/null +++ b/admin/app/indexes/ContentApiTagsEnumerator.scala @@ -0,0 +1,55 @@ +package indexes + +import common.Logging +import conf.LiveContentApi + +import scala.concurrent.Future +import com.gu.openplatform.contentapi.model.{Tag, TagsResponse} +import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext.Implicits.global +import play.api.libs.iteratee.{Enumeratee, Enumerator} + +object ContentApiTagsEnumerator extends Logging { + val DelayBetweenRetries = 100.millis + val MaxNumberRetries = 5 + val MaxPageSize = 1000 + + def enumeratePages(getPage: Int => Future[TagsResponse]): Enumerator[Tag] = { + def getPageWithRetries(page: Int, retriesRemaining: Int = MaxNumberRetries): Future[TagsResponse] = + if (retriesRemaining == 0) + getPage(page) + else + getPage(page) recoverWith { + case error: Throwable => + log.error(s"Error getting tag page $page, $retriesRemaining retries remaining", error) + getPageWithRetries(page, retriesRemaining - 1) + } + + Enumerator.unfoldM(Option(1)) { + case Some(nextPage) => getPageWithRetries(nextPage) map { response => + val next = if (response.isLastPage) None else Some(response.currentPage + 1) + + Some(next, response.results) + } + + case None => Future.successful(None) + }.flatMap(Enumerator.apply(_: _*)) + } + + def enumerateTagType(tagType: String) = enumeratePages { page => + LiveContentApi.tags.tagType(tagType).pageSize(MaxPageSize).page(page).response + } + + implicit class RichTag(tag: Tag) { + def isSectionTag = tag.id.split("/").toList match { + case first :: second :: Nil => first == second + case _ => false + } + } + + def enumerateTagTypeFiltered(tagType: String) = + enumerateTagType(tagType) through Enumeratee.filter({ tag => + /** Believe it or not, we actually have tags whose titles start with HTML tags ... */ + !tag.id.startsWith("weather/") && tag.webTitle.charAt(0).isLetterOrDigit && !tag.isSectionTag + }) +} diff --git a/admin/app/indexes/TagPages.scala b/admin/app/indexes/TagPages.scala new file mode 100644 index 000000000000..254b00740e78 --- /dev/null +++ b/admin/app/indexes/TagPages.scala @@ -0,0 +1,111 @@ +package indexes + +import common.Maps._ +import com.gu.openplatform.contentapi.model.Tag +import java.text.Normalizer +import model.{TagDefinition, TagIndexPage} + +import play.api.libs.iteratee.{Enumeratee, Iteratee} +import scala.concurrent.ExecutionContext.Implicits.global + +object TagPages { + /** To be curated by Peter Martin */ + val ValidSections = Map( + ("artanddesign", "Art and design"), + ("better-business", "Better Business"), + ("books", "Books"), + ("business", "Business"), + ("cardiff", "Cardiff"), + ("cities", "Cities"), + ("commentisfree", "Comment is free"), + ("community", "Community"), + ("crosswords", "Crosswords"), + ("culture", "Culture"), + ("culture-network", "Culture Network"), + ("culture-professionals-network", "Culture professionals network"), + ("edinburgh", "Edinburgh"), + ("education", "Education"), + ("enterprise-network", "Guardian Enterprise Network"), + ("environment", "Environment"), + ("extra", "Extra"), + ("fashion", "Fashion"), + ("film", "Film"), + ("football", "Football"), + ("global-development", "Global development"), + ("global-development-professionals-network", "Global Development Professionals Network"), + ("government-computing-network", "Guardian Government Computing"), + ("guardian-professional", "Guardian Professional"), + ("healthcare-network", "Healthcare Professionals Network"), + ("help", "Help"), + ("higher-education-network", "Higher Education Network"), + ("housing-network", "Housing Network"), + ("info", "Info"), + ("katine", "Katine"), + ("law", "Law"), + ("leeds", "Leeds"), + ("lifeandstyle", "Life and style"), + ("local", "Local"), + ("local-government-network", "Local Leaders Network"), + ("media", "Media"), + ("media-network", "Media Network"), + ("money", "Money"), + ("music", "Music"), + ("news", "News"), + ("politics", "Politics"), + ("public-leaders-network", "Public Leaders Network"), + ("science", "Science"), + ("search", "Search"), + ("small-business-network", "Guardian Small Business Network"), + ("social-care-network", "Social Care Network"), + ("social-enterprise-network", "Social Enterprise Network"), + ("society", "Society"), + ("society-professionals", "Society Professionals"), + ("sport", "Sport"), + ("stage", "Stage"), + ("teacher-network", "Teacher Network"), + ("technology", "Technology"), + ("theguardian", "From the Guardian"), + ("theobserver", "From the Observer"), + ("travel", "Travel"), + ("travel/offers", "Guardian holiday offers"), + ("tv-and-radio", "Television & radio"), + ("uk-news", "UK news"), + ("voluntary-sector-network", "Voluntary Sector Network"), + ("weather", "Weather"), + ("women-in-leadership", "Women in Leadership"), + ("world", "World news") + ) + + def asAscii(s: String) = + Normalizer.normalize(s, Normalizer.Form.NFD).replaceAll("[^\\p{ASCII}]", "") + + def alphaIndexKey(s: String) = { + val firstChar = asAscii(s).toLowerCase.charAt(0) + + if (firstChar.isDigit) { + "1-9" + } else { + firstChar.toString + } + } + + private def mappedByKey(key: Tag => String) = + Iteratee.fold[Tag, Map[String, Set[Tag]]](Map.empty) { (acc, tag) => + insertWith(acc, key(tag), Set(tag))(_ union _) + } + + def toPages(tagsByKey: Map[String, Set[Tag]])(titleFromKey: String => String) = tagsByKey.toSeq.sortBy(_._1) map { case (id, tagSet) => + TagIndexPage( + id, + titleFromKey(id), + tagSet.toSeq.sortBy(tag => asAscii(tag.webTitle)).map(TagDefinition.fromContentApiTag) + ) + } + + val invalidSectionsFilter = Enumeratee.filter[Tag](_.sectionId.exists(ValidSections.contains)) + + val byWebTitle = mappedByKey(tag => alphaIndexKey(tag.webTitle)) + + val bySection = invalidSectionsFilter &>> mappedByKey(_.sectionId.get) +} + diff --git a/admin/app/indexes/package.scala b/admin/app/indexes/package.scala new file mode 100644 index 000000000000..527e542447b3 --- /dev/null +++ b/admin/app/indexes/package.scala @@ -0,0 +1,7 @@ +import com.gu.openplatform.contentapi.model.TagsResponse + +package object indexes { + implicit class RichTagsResponse(tagsResponse: TagsResponse) { + def isLastPage = tagsResponse.currentPage >= tagsResponse.pages + } +} diff --git a/admin/app/jobs/RebuildIndexJob.scala b/admin/app/jobs/RebuildIndexJob.scala new file mode 100644 index 000000000000..7d25cc977764 --- /dev/null +++ b/admin/app/jobs/RebuildIndexJob.scala @@ -0,0 +1,71 @@ +package jobs + +import common.{Logging, ExecutionContexts, StopWatch} +import indexes.ContentApiTagsEnumerator +import indexes.TagPages._ +import model.{TagIndexListings, TagIndexPage} +import play.api.libs.iteratee.Enumeratee +import services.TagIndexesS3 + +import scala.concurrent.{Future, blocking} + +object RebuildIndexJob extends ExecutionContexts with Logging { + def saveToS3(parentKey: String, tagPages: Seq[TagIndexPage]) { + val s3StopWatch = new StopWatch + + tagPages foreach { tagPage => + log.info(s"Uploading $parentKey ${tagPage.title} index to S3") + TagIndexesS3.putIndex(parentKey, tagPage) + } + + log.info(s"Uploaded ${tagPages.length} $parentKey index pages to S3 after ${s3StopWatch.elapsed}ms") + + val listingStopWatch = new StopWatch + + TagIndexesS3.putListing(parentKey, TagIndexListings.fromTagIndexPages(tagPages)) + + log.info(s"Uploaded $parentKey listing in ${listingStopWatch.elapsed}ms") + } + + /** The title for the alpha keys (A, B, C ... ) + * + * Replace the hyphen with an ndash here as it looks better in the HTML. (The key needs to be a hyphen though so it + * works in a web uri.) + */ + private def alphaTitle(key: String) = key.toUpperCase.replace("-", "–") + + def rebuildKeywordIndexes() = { + /** Keywords are indexed both alphabetically and by their parent section */ + ContentApiTagsEnumerator.enumerateTagTypeFiltered("keyword") + .run(Enumeratee.zip(bySection, byWebTitle)) map { case (sectionMap, alphaMap) => + blocking { + saveToS3("keywords", toPages(alphaMap)(alphaTitle)) + saveToS3("keywords_by_section", toPages(sectionMap)(ValidSections(_))) + } + } + } + + def rebuildContributorIndex() = { + ContentApiTagsEnumerator.enumerateTagTypeFiltered("contributor") + .run(byWebTitle) map { alphaMap => + blocking { + saveToS3("contributors", toPages(alphaMap)(alphaTitle)) + } + } + } + + implicit class RichFuture[A](future: Future[A]) { + def withErrorLogging = { + future onFailure { + case throwable: Throwable => + log.error("Error rebuilding index", throwable) + } + + future + } + } + + def run() { + rebuildKeywordIndexes().withErrorLogging andThen { case _ => rebuildContributorIndex().withErrorLogging } + } +} diff --git a/admin/test/indexes/TagPagesTest.scala b/admin/test/indexes/TagPagesTest.scala new file mode 100644 index 000000000000..2f7609e5569e --- /dev/null +++ b/admin/test/indexes/TagPagesTest.scala @@ -0,0 +1,108 @@ +package indexes + +import com.gu.openplatform.contentapi.model.{Tag => ApiTag} +import model.{TagDefinition, TagIndexPage} +import org.scalatest.concurrent.PatienceConfiguration.Timeout +import org.scalatest.concurrent.ScalaFutures +import org.scalatest.{Matchers, FlatSpec} +import play.api.libs.iteratee.Enumerator +import TagPages._ + +import scala.language.postfixOps +import scala.concurrent.duration._ + +class TagPagesTest extends FlatSpec with Matchers with ScalaFutures { + "alphaIndexKey" should "return the downcased first character of an ASCII string" in { + val words = Seq( + "monads" -> "m", + "are" -> "a", + "cool" -> "c", + "So" -> "s", + "Is" -> "i", + "Rob" -> "r" + ) + + for ((word, char) <- words) { + alphaIndexKey(word) shouldEqual char + } + } + + it should "return the downcased ASCII equivalent of the first letter in a Unicode string, if available" in { + val words = Seq( + "á" -> "a", + "č" -> "c", + "ž" -> "z", + "ý" -> "y", + "Á" -> "a", + "Ò" -> "o", + "Ù" -> "u" + ) + + for ((unicode, ascii) <- words) { + alphaIndexKey(unicode) shouldEqual ascii + } + } + + it should "index by 0-9 if the first character is a digit" in { + val fixtures = Seq( + "100 Years of Solitude", + "1984", + "20,000 Leagues Under the Sea" + ) + + for (fixture <- fixtures) { + alphaIndexKey(fixture) shouldEqual "1-9" + } + } + + "byWebTitle" should "convert an enumerator of tags into a Future of alpha-ordered TagPages" in { + def tagFixture(webTitle: String) = + ApiTag( + "", + "", + None, + None, + webTitle, + "", + "" + ) + + val activateTag = tagFixture("Activate") + val archivedSpeakersTag = tagFixture("Archived speakers") + val blogTag = tagFixture("Blog") + val advertisingTag = tagFixture("Advertising") + val otherDigitalSolutionsTag = tagFixture("Other digital solutions") + + toPages(Enumerator( + activateTag, + archivedSpeakersTag, + blogTag, + advertisingTag, + otherDigitalSolutionsTag + ).run(byWebTitle).futureValue(Timeout(1 second)))(_.toUpperCase) shouldEqual Seq( + TagIndexPage( + "a", + "A", + List( + activateTag, + advertisingTag, + archivedSpeakersTag + ).map(TagDefinition.fromContentApiTag) + ), + TagIndexPage( + "b", + "B", + List( + TagDefinition.fromContentApiTag(blogTag) + ) + ), + TagIndexPage( + "o", + "O", + List( + TagDefinition.fromContentApiTag(otherDigitalSolutionsTag) + ) + ) + ) + } +} diff --git a/applications/app/Global.scala b/applications/app/Global.scala index 1b3d75fdd82f..a9fb56198980 100644 --- a/applications/app/Global.scala +++ b/applications/app/Global.scala @@ -3,7 +3,9 @@ import conf.{Management, Filters} import dev.DevParametersLifecycle import dfp.DfpAgentLifecycle import ophan.SurgingContentAgentLifecycle +import play.api.Application import play.api.mvc.WithFilters +import services.{ContributorAlphaIndexAutoRefresh, KeywordSectionIndexAutoRefresh, KeywordAlphaIndexAutoRefresh} object Global extends WithFilters(Filters.common: _*) with DevParametersLifecycle @@ -11,4 +13,12 @@ object Global extends WithFilters(Filters.common: _*) with DfpAgentLifecycle with SurgingContentAgentLifecycle{ override lazy val applicationName = Management.applicationName + + override def onStart(app: Application): Unit = { + super.onStart(app) + + KeywordSectionIndexAutoRefresh.start() + KeywordAlphaIndexAutoRefresh.start() + ContributorAlphaIndexAutoRefresh.start() + } } diff --git a/applications/app/controllers/TagIndexController.scala b/applications/app/controllers/TagIndexController.scala new file mode 100644 index 000000000000..0d2e02cb97a7 --- /dev/null +++ b/applications/app/controllers/TagIndexController.scala @@ -0,0 +1,48 @@ +package controllers + +import common.{Logging, ExecutionContexts} +import model._ +import play.api.mvc.{Action, Controller} +import services._ + +object TagIndexController extends Controller with ExecutionContexts with Logging { + private def forTagType(keywordType: String, metaData: TagIndexPageMetaData) = Action { implicit request => + TagIndexesS3.getIndex(keywordType, metaData.page) match { + case Left(TagIndexNotFound) => + log.error(s"404 error serving tag index page for $keywordType ${metaData.page}") + NotFound + + case Left(TagIndexReadError(error)) => + log.error(s"JSON parse error serving tag index page for $keywordType ${metaData.page}: $error") + InternalServerError + + case Right(tagPage) => + Ok(views.html.tagIndexPage( + metaData, + tagPage, + keywordType + )) + } + } + + def keywords() = Action { implicit request => + (for { + alphaListing <- KeywordAlphaIndexAutoRefresh.get + sectionListing <- KeywordSectionIndexAutoRefresh.get + } yield { + Ok(views.html.keywordsIndexListing(new KeywordsListingMetaData(), alphaListing)) + }) getOrElse InternalServerError("Not yet loaded alpha and section index for keywords") + } + + def contributors() = Action { implicit request => + (for { + alphaListing <- ContributorAlphaIndexAutoRefresh.get + } yield { + Ok(views.html.contributorsIndexListing(new ContributorsListingMetaData(), alphaListing)) + }) getOrElse InternalServerError("Not yet loaded contributor index listing") + } + + def keyword(page: String) = forTagType("keywords", new KeywordIndexPageMetaData(page)) + + def contributor(page: String) = forTagType("contributors", new ContributorsIndexPageMetaData(page)) +} diff --git a/applications/app/model/TagIndexListingMetaData.scala b/applications/app/model/TagIndexListingMetaData.scala new file mode 100644 index 000000000000..5a0af2c2e3b7 --- /dev/null +++ b/applications/app/model/TagIndexListingMetaData.scala @@ -0,0 +1,25 @@ +package model + +class KeywordsListingMetaData extends MetaData { + override def id: String = "index/keywords" + + override def section: String = "Index" + + override def analyticsName: String = "Keywords" + + override def webTitle: String = "keywords" + + override def customSignPosting = Some(IndexNav.keywordsAlpha) +} + +class ContributorsListingMetaData extends MetaData { + override def id: String = "index/contributors" + + override def section: String = "Index" + + override def analyticsName: String = "Contributors" + + override def webTitle: String = "contributors" + + override def customSignPosting = Some(IndexNav.contributorsAlpha) +} diff --git a/applications/app/model/TagIndexPageMetadata.scala b/applications/app/model/TagIndexPageMetadata.scala new file mode 100644 index 000000000000..559417080fc0 --- /dev/null +++ b/applications/app/model/TagIndexPageMetadata.scala @@ -0,0 +1,51 @@ +package model + +import common.{SectionLink, NavItem} +import services.{KeywordAlphaIndexAutoRefresh, ContributorAlphaIndexAutoRefresh} + +object IndexNav { + private def tagIndexSignposting(tagType: String)(get: => Option[TagIndexListings]) = { + val sectionRoot = SectionLink(tagType, tagType, tagType.capitalize, s"/index/$tagType") + + get match { + case Some(listings) => NavItem( + sectionRoot, + listings.pages map { page => + SectionLink(tagType, page.title.toLowerCase, page.title, s"/index/$tagType/${page.id}") + } + ) + + case None => NavItem(sectionRoot, Nil) + } + } + + val contributorsAlpha = tagIndexSignposting("contributors")(ContributorAlphaIndexAutoRefresh.get) + + val keywordsAlpha = tagIndexSignposting("keywords")(KeywordAlphaIndexAutoRefresh.get) +} + +trait TagIndexPageMetaData extends MetaData { + val page: String + + val tagType: String + + override def id: String = s"index/$tagType/$page" + + override def section: String = tagType + + override def analyticsName: String = tagType + + override def webTitle: String = page.capitalize +} + +class KeywordIndexPageMetaData(val page: String) extends TagIndexPageMetaData { + override val tagType: String = "keywords" + + override def customSignPosting = Some(IndexNav.keywordsAlpha) +} + +class ContributorsIndexPageMetaData(val page: String) extends TagIndexPageMetaData { + override val tagType: String = "contributors" + + override def customSignPosting = Some(IndexNav.contributorsAlpha) +} diff --git a/applications/app/services/indexAutoRefreshes.scala b/applications/app/services/indexAutoRefreshes.scala new file mode 100644 index 000000000000..14c851612cdb --- /dev/null +++ b/applications/app/services/indexAutoRefreshes.scala @@ -0,0 +1,33 @@ +package services + +import common.AutoRefresh +import model.TagIndexListings + +import scala.concurrent.{Future, blocking} +import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext.Implicits.global +import scala.language.postfixOps + +object KeywordSectionIndexAutoRefresh extends AutoRefresh[TagIndexListings](0 seconds, 5 minutes) { + override protected def refresh(): Future[TagIndexListings] = Future { + blocking { + TagIndexesS3.getListingOrDie("keywords_by_section") + } + } +} + +object KeywordAlphaIndexAutoRefresh extends AutoRefresh[TagIndexListings](0 seconds, 5 minutes) { + override protected def refresh(): Future[TagIndexListings] = Future { + blocking { + TagIndexesS3.getListingOrDie("keywords") + } + } +} + +object ContributorAlphaIndexAutoRefresh extends AutoRefresh[TagIndexListings](0 seconds, 5 minutes) { + override protected def refresh(): Future[TagIndexListings] = Future { + blocking { + TagIndexesS3.getListingOrDie("contributors") + } + } +} diff --git a/applications/app/views/contributorsIndexListing.scala.html b/applications/app/views/contributorsIndexListing.scala.html new file mode 100644 index 000000000000..a9b3a3454067 --- /dev/null +++ b/applications/app/views/contributorsIndexListing.scala.html @@ -0,0 +1,7 @@ +@(metaData: model.ContributorsListingMetaData, listing: model.TagIndexListings)(implicit requestHeader: RequestHeader) + +@import fragments.tagIndexListingBody + +@main(metaData, projectName = Some("index")) { } { + @tagIndexListingBody("contributors", metaData.webTitle, listing) +} diff --git a/applications/app/views/fragments/keywordIndexListingBody.scala.html b/applications/app/views/fragments/keywordIndexListingBody.scala.html new file mode 100644 index 000000000000..fefeb0ef172e --- /dev/null +++ b/applications/app/views/fragments/keywordIndexListingBody.scala.html @@ -0,0 +1,43 @@ +@(id: String, alphaIndex: model.TagIndexListings, sectionIndex: model.TagIndexListings)(implicit request: RequestHeader) + +@import common.LinkTo + +
+
+
+
+
+
+ Index +
+

Keywords

+
+
+
+ +
+

By section

+ +
+ +
+ +

By letter

+ +
+ +
+
+
diff --git a/applications/app/views/fragments/tagIndexBody.scala.html b/applications/app/views/fragments/tagIndexBody.scala.html new file mode 100644 index 000000000000..de1ebe640237 --- /dev/null +++ b/applications/app/views/fragments/tagIndexBody.scala.html @@ -0,0 +1,27 @@ +@(page: model.TagIndexPage, subTitle: String)(implicit request: RequestHeader) + +@import common.LinkTo + +
+
+
+
+
+
+ @subTitle +
+

@page.title

+
+
+
+ +
+
+ +
+
+
diff --git a/applications/app/views/fragments/tagIndexListingBody.scala.html b/applications/app/views/fragments/tagIndexListingBody.scala.html new file mode 100644 index 000000000000..5d8da7e6b0c9 --- /dev/null +++ b/applications/app/views/fragments/tagIndexListingBody.scala.html @@ -0,0 +1,30 @@ +@(id: String, subTitle: String, listings: model.TagIndexListings)(implicit request: RequestHeader) + +@import common.LinkTo + +
+ +
+
+
+
+
+ Index +
+

@subTitle

+
+
+
+ +
+
+ +
+
+
diff --git a/applications/app/views/keywordsIndexListing.scala.html b/applications/app/views/keywordsIndexListing.scala.html new file mode 100644 index 000000000000..793356743886 --- /dev/null +++ b/applications/app/views/keywordsIndexListing.scala.html @@ -0,0 +1,7 @@ +@(metaData: model.KeywordsListingMetaData, listing: model.TagIndexListings)(implicit requestHeader: RequestHeader) + +@import fragments.tagIndexListingBody + +@main(metaData, projectName = Some("index")) { } { + @tagIndexListingBody("keywords", metaData.webTitle, listing) +} diff --git a/applications/app/views/tagIndexPage.scala.html b/applications/app/views/tagIndexPage.scala.html new file mode 100644 index 000000000000..35e6b23e56a7 --- /dev/null +++ b/applications/app/views/tagIndexPage.scala.html @@ -0,0 +1,7 @@ +@(metaData: model.TagIndexPageMetaData, page: model.TagIndexPage, subTitle: String)(implicit request: RequestHeader) + +@import fragments.tagIndexBody + +@main(metaData, projectName = Some("index")) { } { + @tagIndexBody(page, subTitle) +} diff --git a/applications/conf/routes b/applications/conf/routes index 802f63bf914d..26d3e4c35ee5 100644 --- a/applications/conf/routes +++ b/applications/conf/routes @@ -43,3 +43,8 @@ GET /$path<[\w\d-]*(/[\w\d-]*)?(/[\w\d-]*)?> # Tag combiners GET /$leftSide<[^+]+>+*rightSide/rss controllers.IndexController.renderCombinerRss(leftSide, rightSide) GET /$leftSide<[^+]+>+*rightSide controllers.IndexController.renderCombiner(leftSide, rightSide) + +GET /index/keywords controllers.TagIndexController.keywords() +GET /index/keywords/*index controllers.TagIndexController.keyword(index) +GET /index/contributors controllers.TagIndexController.contributors() +GET /index/contributors/*contributor controllers.TagIndexController.contributor(contributor) diff --git a/common/app/assets/stylesheets/head.index.scss b/common/app/assets/stylesheets/head.index.scss new file mode 100644 index 000000000000..e1f94f848ced --- /dev/null +++ b/common/app/assets/stylesheets/head.index.scss @@ -0,0 +1,9 @@ +@charset 'UTF-8'; + +/* ========================================================================== + Common head + ========================================================================== */ + +@import '_head.common'; + +@import 'module/_index'; diff --git a/common/app/assets/stylesheets/ie9.head.index.scss b/common/app/assets/stylesheets/ie9.head.index.scss new file mode 100644 index 000000000000..7313f760a8cb --- /dev/null +++ b/common/app/assets/stylesheets/ie9.head.index.scss @@ -0,0 +1,6 @@ +@charset 'UTF-8'; + +$browser-supports-columns: false; +$browser-supports-flexbox: false; + +@import 'head.index'; diff --git a/common/app/assets/stylesheets/module/_index.scss b/common/app/assets/stylesheets/module/_index.scss new file mode 100644 index 000000000000..8ac7c29626a9 --- /dev/null +++ b/common/app/assets/stylesheets/module/_index.scss @@ -0,0 +1,151 @@ +.tag-index__main-column { + position: relative; + + @include mq(tablet) { + margin: auto; + @include rem(( + max-width: 620px + )); + } + + @include mq(leftCol) { + @include rem(( + margin-left: gs-span(2) + $gs-gutter + )); + } + @include mq(wide) { + @include rem(( + margin-left: gs-span(3) + $gs-gutter + )); + } +} + +.tag-index__headline { + @include fs-headline(5); + @include rem(( + padding-bottom: $gs-baseline*2, + padding-top: $gs-baseline/2 + )); + + @include mq(mobileLandscape) { + @include fs-headline(7, true); + } + @include mq(tablet) { + -webkit-font-smoothing: antialiased; + @include fs-headline(8, true); + @include rem(( + padding-bottom: $gs-row-height, + padding-top: $gs-baseline/6 + )); + border-top: 0; + } + a { + &, + &:hover, + &:active, + &:focus { + color: $c-neutral1; + } + } + em { + font-style: normal; + } + strong { + font-weight: normal; + } +} + +/** Copied from .content__section, consolidate with? */ +.tag-index__parent { + @include fs-header(3); + @include box-sizing(border-box); + position: relative; + z-index: 1; // bring-to-front fix to make it clickable + @include rem(( + padding-bottom: $gs-baseline + )); + @include mq($to: tablet) { + border-bottom: 1px solid $c-neutral7; + + .has-localnav & { + display: none; + } + } + + @include mq(tablet) { + @include rem(( + padding-bottom: ($gs-baseline/2) * 3 + )); + } + + @include mq(leftCol) { + float: left; + @include rem(( + margin-left: -(gs-span(2) + $gs-gutter), + width: gs-span(2) + )); + } + @include mq(wide) { + @include rem(( + margin-left: -(gs-span(3) + $gs-gutter), + width: gs-span(3) + )); + } + @include mq(leftCol, wide) { + @include rem(( + width: gs-span(2) + )); + } +} + +/** See the same rules in module/content/_content.scss. Once the code has been refactored and this exists in the global + * head config, it can be removed from here. + */ +.tag-index .gs-container { + @include box-sizing(border-box); + @include rem(( + padding: 0 $gs-gutter/2 + )); + @include mq(mobileLandscape) { + @include rem(( + padding: 0 $gs-gutter + )); + } +} + +@mixin fix-link-colour($colour) { + &, + &:active, + &:focus, + &:hover { + color: $colour; + } +} + +.tag-index__parent__link { + @include fix-link-colour($c-brandBlue); +} + +/** Copied from .content__head__border-top. TODO: Consolidate into a single rule if you don't change it. */ +.tag-index__head__border-top { + border-top: 1px dotted $c-neutral5; + @include rem(( + height: $gs-baseline/2 + )); + + @include mq($to: tablet) { + display: none; + } + @include mq($from:tablet, $to: rightCol) { + @include rem(( + max-width: gs-span(8) + )); + margin: auto; + } +} + +.tag-index__tag-link { + @include rem(( + min-height: get-line-height($fs-headlines, 1) * 1.5 + )); +} diff --git a/common/app/assets/stylesheets/module/content/_content.scss b/common/app/assets/stylesheets/module/content/_content.scss index 41e6cdf325ab..dd47fd145764 100644 --- a/common/app/assets/stylesheets/module/content/_content.scss +++ b/common/app/assets/stylesheets/module/content/_content.scss @@ -28,7 +28,6 @@ padding: 0 $gs-gutter )); } - } .content__main-column { diff --git a/common/app/assets/stylesheets/old-ie.head.index.scss b/common/app/assets/stylesheets/old-ie.head.index.scss new file mode 100644 index 000000000000..2834f785cc74 --- /dev/null +++ b/common/app/assets/stylesheets/old-ie.head.index.scss @@ -0,0 +1,7 @@ +@charset 'UTF-8'; + +$old-ie: true; +$mq-responsive: false; +$svg-support: false; + +@import 'head.index'; diff --git a/common/app/common/AutoRefresh.scala b/common/app/common/AutoRefresh.scala new file mode 100644 index 000000000000..715ddb09961d --- /dev/null +++ b/common/app/common/AutoRefresh.scala @@ -0,0 +1,41 @@ +package common + +import scala.concurrent.duration.FiniteDuration +import akka.agent.Agent +import akka.actor.Cancellable +import scala.concurrent.Future +import play.libs.Akka +import scala.util.{Failure, Success} +import scala.concurrent.ExecutionContext.Implicits.global + +/** Simple class for repeatedly updating a value on a schedule */ +abstract class AutoRefresh[A](initialDelay: FiniteDuration, interval: FiniteDuration) extends Logging { + private lazy val agent = Agent[Option[A]](None) + + @volatile private var subscription: Option[Cancellable] = None + + protected def refresh(): Future[A] + + def get = agent.get() + + def getOrRefresh = (for { + _ <- subscription + a <- get + } yield Future.successful(a)).getOrElse(refresh()) + + final def start() = { + log.info(s"Starting refresh cycle after $initialDelay repeatedly over $interval delay") + + subscription = Some(Akka.system.scheduler.schedule(initialDelay, interval) { + refresh() onComplete { + case Success(a) => + log.debug(s"Updated AutoRefresh: $a") + agent.send(Some(a)) + case Failure(error) => + log.warn("Failed to update AutoRefresh", error) + } + }) + } + + final def stop() = subscription foreach { _.cancel() } +} diff --git a/common/app/common/Maps.scala b/common/app/common/Maps.scala new file mode 100644 index 000000000000..70718b8d8a58 --- /dev/null +++ b/common/app/common/Maps.scala @@ -0,0 +1,7 @@ +package common + +object Maps { + /** Insert k -> v into map, resolving collisions with f */ + def insertWith[A, B](map: Map[A, B], k: A, v: B)(f: (B, B) => B) = + map + (k -> map.get(k).map(f.curried(v)).getOrElse(v)) +} diff --git a/common/app/common/Navigation.scala b/common/app/common/Navigation.scala index 66a0f092d084..b6f360a076a6 100644 --- a/common/app/common/Navigation.scala +++ b/common/app/common/Navigation.scala @@ -5,7 +5,7 @@ import play.api.mvc.RequestHeader import conf.Switches._ import dev.HttpSwitch -case class SectionLink(zone: String, title: String, breadcumbTitle: String, href: String) { +case class SectionLink(zone: String, title: String, breadcrumbTitle: String, href: String) { def currentFor(page: MetaData): Boolean = page.url == href || s"/${page.section}" == href || (Edition.all.exists(_.id.toLowerCase == page.id.toLowerCase) && href == "/") @@ -66,14 +66,14 @@ trait Navigation { //Sport val sport = SectionLink("sport", "sport", "Sport", "/sport") - val sports = sport.copy(title = "sports", breadcumbTitle = "Sports") + val sports = sport.copy(title = "sports", breadcrumbTitle = "Sports") val usSport = SectionLink("sport", "US sports", "US sports", "/sport/us-sport") val australiaSport = SectionLink("australia sport", "australia sport", "Australia sport", "/sport/australia-sport") val afl = SectionLink("afl", "afl", "afl", "/sport/afl") val nrl = SectionLink("nrl", "nrl", "nfl", "/sport/nrl") val aLeague = SectionLink("a-league", "a-league", "A-league", "/football/a-league") val football = SectionLink("football", "football", "Football", "/football") - val soccer = football.copy(title = "soccer", breadcumbTitle = "Soccer") + val soccer = football.copy(title = "soccer", breadcrumbTitle = "Soccer") val cricket = SectionLink("sport", "cricket", "Cricket", "/sport/cricket") val sportblog = SectionLink("sport", "sport blog", "Sport blog", "/sport/blog") val cycling = SectionLink("sport", "cycling", "Cycling", "/sport/cycling") @@ -104,7 +104,7 @@ trait Navigation { val artanddesign = SectionLink("culture", "art & design", "Art & design", "/artanddesign") val books = SectionLink("culture", "books", "Books", "/books") val film = SectionLink("culture", "film", "Film", "/film") - val movies = film.copy(title = "movies", breadcumbTitle = "Movies") + val movies = film.copy(title = "movies", breadcrumbTitle = "Movies") val music = SectionLink("culture", "music", "Music", "/music") val stage = SectionLink("culture", "stage", "Stage", "/stage") val televisionAndRadio = SectionLink("culture", "tv & radio", "TV & radio", "/tv-and-radio") @@ -121,7 +121,7 @@ trait Navigation { //Business val economy = SectionLink("business", "economy", "Economy", "/business") - val business = economy.copy(title = "business", breadcumbTitle = "Business") + val business = economy.copy(title = "business", breadcrumbTitle = "Business") val companies = SectionLink("business", "companies", "Companies", "/business/companies") val economics = SectionLink("business", "economics", "Economics", "/business/economics") val markets = SectionLink("business", "markets", "Markets", "/business/stock-markets") @@ -161,7 +161,7 @@ trait Navigation { val uktravel = SectionLink("travel", "UK", "UK", "/travel/uk") val europetravel = SectionLink("travel", "europe", "europe", "/travel/europe") val usTravel = SectionLink("travel", "US", "US", "/travel/usa") - val usaTravel = usTravel.copy(title = "USA", breadcumbTitle = "USA") + val usaTravel = usTravel.copy(title = "USA", breadcrumbTitle = "USA") val hotels = SectionLink("travel", "hotels", "Hotels", "/travel/hotels") val resturants = SectionLink("travel", "restaurants", "Restaurants", "/travel/restaurants") val budget = SectionLink("travel", "budget travel", "Budget travel", "/travel/budget") @@ -192,8 +192,8 @@ case class BreadcrumbItem(href: String, title: String) object Breadcrumbs { def items(navigation: Seq[NavItem], page: Content): Seq[BreadcrumbItem] = { val primaryKeywod = page.keywordTags.headOption.map(k => BreadcrumbItem(k.url, k.webTitle)) - val firstBreadcrumb = Navigation.topLevelItem(navigation, page).map(n => BreadcrumbItem(n.name.href, n.name.breadcumbTitle)).orElse(Some(BreadcrumbItem(s"/${page.section}", page.sectionName))) - val secondBreadcrumb = Navigation.subNav(navigation, page).map(s => BreadcrumbItem(s.href, s.breadcumbTitle)).orElse(primaryKeywod) + val firstBreadcrumb = Navigation.topLevelItem(navigation, page).map(n => BreadcrumbItem(n.name.href, n.name.breadcrumbTitle)).orElse(Some(BreadcrumbItem(s"/${page.section}", page.sectionName))) + val secondBreadcrumb = Navigation.subNav(navigation, page).map(s => BreadcrumbItem(s.href, s.breadcrumbTitle)).orElse(primaryKeywod) Seq(firstBreadcrumb, secondBreadcrumb, primaryKeywod).flatten.distinct } } @@ -201,9 +201,10 @@ object Breadcrumbs { // helper for the views object Navigation { - def topLevelItem(navigation: Seq[NavItem], page: MetaData): Option[NavItem] = navigation.find(_.exactFor(page)) - .orElse(navigation.find(_.currentFor(page))) //This includes a search on the HEAD of Tags for (page: MetaData) - .orElse(navigation.find(_.currentForIncludingAllTags(page))) //This is the search for ALL tags + def topLevelItem(navigation: Seq[NavItem], page: MetaData): Option[NavItem] = page.customSignPosting orElse + navigation.find(_.exactFor(page)) orElse + navigation.find(_.currentFor(page)) orElse + navigation.find(_.currentForIncludingAllTags(page)) def subNav(navigation: Seq[NavItem], page: MetaData): Option[SectionLink] = topLevelItem(navigation, page).flatMap(_.links.find(_.currentFor(page))) diff --git a/common/app/common/StopWatch.scala b/common/app/common/StopWatch.scala new file mode 100644 index 000000000000..d54d063af64a --- /dev/null +++ b/common/app/common/StopWatch.scala @@ -0,0 +1,11 @@ +package common + +object StopWatch { + def apply() = new StopWatch +} + +class StopWatch { + private val startTime = System.currentTimeMillis + + def elapsed = System.currentTimeMillis - startTime +} diff --git a/common/app/common/configuration.scala b/common/app/common/configuration.scala index 12b372597b7a..70befdab8e52 100644 --- a/common/app/common/configuration.scala +++ b/common/app/common/configuration.scala @@ -24,6 +24,14 @@ class GuardianConfiguration(val application: String, val webappConfDirectory: St .getOrElse(throw new BadConfigurationException(property)) } + object indexes { + lazy val tagIndexesBucket = + configuration.getMandatoryStringProperty("tag_indexes.bucket") + + lazy val adminRebuildIndexRateInMinutes = + configuration.getIntegerProperty("tag_indexes.rebuild_rate_in_minutes").getOrElse(60) + } + object environment { private val installVars = new File("/etc/gu/install_vars") match { case f if f.exists => IOUtils.toString(new FileInputStream(f)) diff --git a/common/app/model/TagIndexPage.scala b/common/app/model/TagIndexPage.scala new file mode 100644 index 000000000000..83c7abc01fd9 --- /dev/null +++ b/common/app/model/TagIndexPage.scala @@ -0,0 +1,50 @@ +package model + +import com.gu.openplatform.contentapi.model.{Tag => ApiTag} +import play.api.libs.json._ + +object TagDefinition { + implicit val jsonFormat = Json.format[TagDefinition] + + def fromContentApiTag(apiTag: ApiTag): TagDefinition = TagDefinition( + apiTag.webTitle, + apiTag.id + ) +} + +/** Minimal amount of information we need to serialize about tags */ +case class TagDefinition( + webTitle: String, + id: String +) + +object TagIndexListing { + implicit val jsonFormat = Json.format[TagIndexListing] + + def fromTagIndexPage(tagIndexPage: TagIndexPage) = + TagIndexListing(tagIndexPage.id, tagIndexPage.title) +} + +case class TagIndexListing( + id: String, + title: String +) + +object TagIndexListings { + implicit val jsonFormat = Json.format[TagIndexListings] + + def fromTagIndexPages(pages: Seq[TagIndexPage]) = + TagIndexListings(pages.map(TagIndexListing.fromTagIndexPage).sortBy(_.title)) +} + +case class TagIndexListings(pages: Seq[TagIndexListing]) + +object TagIndexPage { + implicit val jsonFormat = Json.format[TagIndexPage] +} + +case class TagIndexPage( + id: String, + title: String, + tags: Seq[TagDefinition] +) diff --git a/common/app/model/diagnostics/CloudWatch.scala b/common/app/model/diagnostics/CloudWatch.scala index f5515f5ec167..255c86c0ac2e 100644 --- a/common/app/model/diagnostics/CloudWatch.scala +++ b/common/app/model/diagnostics/CloudWatch.scala @@ -27,7 +27,7 @@ trait CloudWatch extends Logging { } def onSuccess(request: PutMetricDataRequest, result: Void ) { - log.info("CloudWatch PutMetricDataRequest - sucess") + log.info("CloudWatch PutMetricDataRequest - success") } } diff --git a/common/app/model/meta.scala b/common/app/model/meta.scala index c2664db271f5..06019d60c4a4 100644 --- a/common/app/model/meta.scala +++ b/common/app/model/meta.scala @@ -1,6 +1,6 @@ package model -import common.{Edition, ManifestData, Pagination} +import common.{NavItem, Edition, ManifestData, Pagination} import conf.Configuration import dfp.DfpAgent @@ -64,6 +64,8 @@ trait MetaData extends Tags { ) def cacheSeconds = 60 + + def customSignPosting: Option[NavItem] = None } class Page( diff --git a/common/app/services/TagIndexesS3.scala b/common/app/services/TagIndexesS3.scala new file mode 100644 index 000000000000..8f239ebf4e19 --- /dev/null +++ b/common/app/services/TagIndexesS3.scala @@ -0,0 +1,67 @@ +package services + +import conf.Configuration +import model.{TagIndexListings, TagIndexPage} +import play.api.Play +import play.api.Play.current +import play.api.libs.json._ + +import scala.collection.JavaConversions._ + +sealed trait TagIndexError + +case object TagIndexNotFound extends TagIndexError +case class TagIndexReadError(error: JsError) extends TagIndexError + +object TagIndexesS3 extends S3 { + override lazy val bucket = Configuration.indexes.tagIndexesBucket + + val stage = if (Play.isTest) "TEST" else Configuration.facia.stage + + val ListingKey = "_listing" + + private def indexRoot(indexType: String) = s"$stage/index/$indexType" + + private def indexKey(indexType: String, pageName: String) = + s"${indexRoot(indexType)}/$pageName.json" + + private def putJson[A: Writes](key: String, a: A) = putPublic( + key, + Json.stringify(Json.toJson(a)), + "application/json" + ) + + def putIndex(indexType: String, tagPage: TagIndexPage) = putJson( + indexKey(indexType, tagPage.id), + tagPage + ) + + def putListing(indexType: String, listing: TagIndexListings) = putJson( + indexKey(indexType, ListingKey), + listing + ) + + private def getAndRead[A: Reads](key: String): Either[TagIndexError, A] = get(key) match { + case Some(jsonString) => + Json.fromJson[A](Json.parse(jsonString)) match { + case JsSuccess(tagPage, _) => Right(tagPage) + case error @ JsError(_) => Left(TagIndexReadError(error)) + } + + case None => + Left(TagIndexNotFound) + } + + def getIndex(indexType: String, pageName: String): Either[TagIndexError, TagIndexPage] = + getAndRead[TagIndexPage](indexKey(indexType, pageName)) + + def getListing(indexType: String): Either[TagIndexError, TagIndexListings] = + getAndRead[TagIndexListings](indexKey(indexType, ListingKey)) + + def getListingOrDie(indexType: String): TagIndexListings = + getListing(indexType) match { + case Right(listings) => listings + case Left(TagIndexNotFound) => throw new RuntimeException(s"Could not find index $indexType") + case Left(TagIndexReadError(_)) => throw new RuntimeException(s"Could not deserialize index $indexType") + } +} diff --git a/common/app/views/fragments/nav/signposting.scala.html b/common/app/views/fragments/nav/signposting.scala.html index b99aee695d52..52248e9faf3a 100644 --- a/common/app/views/fragments/nav/signposting.scala.html +++ b/common/app/views/fragments/nav/signposting.scala.html @@ -1,4 +1,7 @@ -@(metaData: MetaData, navigation: Seq[common.NavItem])(implicit request: RequestHeader) +@(metaData: model.MetaData, navigation: Seq[common.NavItem])(implicit request: RequestHeader) + +@import common.{Edition, LinkTo, Navigation} +@import views.support.RenderClasses