diff --git a/scala/src/main/scala/ru/org/codingteam/loglist/dto/StagedQuoteDTO.scala b/scala/src/main/scala/ru/org/codingteam/loglist/dto/StagedQuoteDTO.scala new file mode 100644 index 0000000..d01c0a9 --- /dev/null +++ b/scala/src/main/scala/ru/org/codingteam/loglist/dto/StagedQuoteDTO.scala @@ -0,0 +1,3 @@ +package ru.org.codingteam.loglist.dto + +case class StagedQuoteDTO(token: String, content: String, time: Long) diff --git a/scalajvm/app/controllers/SuggestedQuotes.scala b/scalajvm/app/controllers/SuggestedQuotes.scala index f0ade5c..bff1d5f 100644 --- a/scalajvm/app/controllers/SuggestedQuotes.scala +++ b/scalajvm/app/controllers/SuggestedQuotes.scala @@ -2,7 +2,8 @@ package controllers import helpers.{ActionWithTx, Notifications, ReCaptcha} import models.forms.SuggestQuoteForm -import models.queries.{ApproverQueries, SuggestedQuoteQueries} +import models.queries._ +import models.forms._ import play.api.data.Forms._ import play.api.data._ import play.api.mvc._ @@ -21,8 +22,10 @@ object SuggestedQuotes extends Controller { Ok(json).as("application/json") } - def newQuote() = Action { request => - Ok(views.html.newQuote(quoteForm)) + def newQuote(stagedQuoteToken: String) = ActionWithTx { request => + import request.dbSession + val content = StagedQuoteQueries().getStagedQuoteByToken(stagedQuoteToken).map(_.content).getOrElse("") + Ok(views.html.newQuote(quoteForm.fill(SuggestQuoteForm(content, "")))) } def addQuote() = ActionWithTx { implicit request => diff --git a/scalajvm/app/controllers/api/Quotes.scala b/scalajvm/app/controllers/api/Quotes.scala index e3807c0..6a9664a 100644 --- a/scalajvm/app/controllers/api/Quotes.scala +++ b/scalajvm/app/controllers/api/Quotes.scala @@ -1,12 +1,19 @@ package controllers.api import helpers.ActionWithTx -import models.queries.QuoteQueries -import models.data.Quote +import models.queries._ +import models.data._ import play.api.mvc._ -import ru.org.codingteam.loglist.dto.QuoteDTO +import play.api.Play.current +import ru.org.codingteam.loglist.dto.{StagedQuoteDTO, QuoteDTO} object Quotes extends Controller { + + private val stagingMaxCount = + current.configuration.getInt("staging.maxCount").get + private val stagingLifeTimePeriod = + current.configuration.getInt("staging.lifeTimePeriod").get + def getQuote(id: Long) = ActionWithTx { request => import request.dbSession prepareResponse(QuoteQueries().getQuoteById(id)) @@ -17,6 +24,26 @@ object Quotes extends Controller { prepareResponse(QuoteQueries().getRandomQuote) } + def stageQuote = ActionWithTx { request => + import request.dbSession + request.body.asText match { + case Some(content) => { + StagedQuoteQueries().deleteOldStagedQuotes(stagingLifeTimePeriod) + + if (StagedQuoteQueries().countStagedQuotes() < stagingMaxCount) { + val id = StagedQuoteQueries().insertStagedQuote(content, Some(request.remoteAddress)) + StagedQuoteQueries().getStagedQuoteById(id) match { + case Some(stagedQuote) => Ok(upickle.write(buildStagedQuoteDto(stagedQuote))).as("application/json; charset=utf-8") + case None => InternalServerError("The quote was not staged properly").as("text/plain") + } + } else { + InsufficientStorage("Staged quotes count limit exceeded. Please try again later.").as("text/plain") + } + } + case None => BadRequest("The request body was not found!").as("text/plain") + } + } + private def prepareResponse(probablyQuote: Option[Quote]) = probablyQuote.map(buildQuoteDto) match { case Some(quoteDTO) => Ok(upickle.write(quoteDTO)).as("application/json; charset=utf-8") @@ -25,4 +52,7 @@ object Quotes extends Controller { private def buildQuoteDto(quote: Quote): QuoteDTO = QuoteDTO(quote.id, quote.time.getMillis, quote.content.getOrElse(""), quote.rating) + + private def buildStagedQuoteDto(stagedQuote: StagedQuote): StagedQuoteDTO = + StagedQuoteDTO(stagedQuote.token, stagedQuote.content, stagedQuote.time.getMillis) } diff --git a/scalajvm/app/helpers/ActionWithTx.scala b/scalajvm/app/helpers/ActionWithTx.scala index bd2ae50..db8c7ea 100644 --- a/scalajvm/app/helpers/ActionWithTx.scala +++ b/scalajvm/app/helpers/ActionWithTx.scala @@ -14,5 +14,3 @@ object ActionWithTx extends ActionBuilder[RequestWithSession] { } } } - - diff --git a/scalajvm/app/models/data/StagedQuote.scala b/scalajvm/app/models/data/StagedQuote.scala new file mode 100644 index 0000000..356f4f0 --- /dev/null +++ b/scalajvm/app/models/data/StagedQuote.scala @@ -0,0 +1,13 @@ +package models.data + +import org.joda.time.DateTime +import scalikejdbc._ + +case class StagedQuote(id: Long, token: String, content: String, time: DateTime, stagerIp: Option[String]) +object StagedQuote extends SQLSyntaxSupport[StagedQuote] { + override val tableName = "staged_quote" + def apply(rs: WrappedResultSet) = + new StagedQuote(rs.long("id"), rs.string("token"), + rs.string("content"), rs.jodaDateTime("time"), + rs.stringOpt("stager_ip")) +} \ No newline at end of file diff --git a/scalajvm/app/models/queries/StagedQuoteQueries.scala b/scalajvm/app/models/queries/StagedQuoteQueries.scala new file mode 100644 index 0000000..aba352f --- /dev/null +++ b/scalajvm/app/models/queries/StagedQuoteQueries.scala @@ -0,0 +1,44 @@ +package models.queries + +import models.data.StagedQuote +import org.joda.time.DateTime +import scalikejdbc._ + +case class StagedQuoteQueries(implicit session: DBSession) { + def getStagedQuoteByToken(token: String): Option[StagedQuote] = { + val sq = StagedQuote.syntax("sq") + withSQL { + select(sq.*).from(StagedQuote as sq).where.eq(sq.token, token) + }.map(rs => StagedQuote(rs)).first().apply() + } + + def getStagedQuoteById(id: Long): Option[StagedQuote] = { + val sq = StagedQuote.syntax("sq") + withSQL { + select(sq.*).from(StagedQuote as sq).where.eq(sq.id, id) + }.map(rs => StagedQuote(rs)).first().apply() + } + + def insertStagedQuote(content: String, stagerIp: Option[String]) = { + val sq = StagedQuote.column + withSQL { + insert.into(StagedQuote) + .columns(sq.time, sq.content, sq.stagerIp) + .values(DateTime.now(), content, stagerIp) + }.updateAndReturnGeneratedKey().apply() + } + + def countStagedQuotes() = { + val sq = StagedQuote.syntax("sq") + withSQL { + select(sqls.count).from(StagedQuote as sq) + }.map(rs => rs.int(1)).first().apply().getOrElse(0) + } + + def deleteOldStagedQuotes(interval: Int): Boolean = { + val sq = StagedQuote.column + withSQL { + delete.from(StagedQuote).where.lt(sq.time, DateTime.now().minusMinutes(interval)) + }.update().apply() != 0 + } +} diff --git a/scalajvm/conf/application.conf b/scalajvm/conf/application.conf index 9677af7..0379e9a 100644 --- a/scalajvm/conf/application.conf +++ b/scalajvm/conf/application.conf @@ -77,4 +77,8 @@ basicAuth.password = ${BASIC_AUTH_PASSWORD} # Approval Notifications approval.smtpHost = ${APPROVAL_SMTP_HOST} approval.email = ${APPROVAL_EMAIL} -approval.emailPassword = ${APPROVAL_EMAIL_PASSWORD} \ No newline at end of file +approval.emailPassword = ${APPROVAL_EMAIL_PASSWORD} + +# Staging +staging.maxCount = ${STAGING_MAX_COUNT} +staging.lifeTimePeriod = ${STAGING_LIFE_TIME_PERIOD} \ No newline at end of file diff --git a/scalajvm/conf/evolutions/default/8.sql b/scalajvm/conf/evolutions/default/8.sql new file mode 100644 index 0000000..13d06f8 --- /dev/null +++ b/scalajvm/conf/evolutions/default/8.sql @@ -0,0 +1,18 @@ +# --- !Ups +create table if not exists staged_quote ( + id serial primary key, + token varchar(32) not null default encode(gen_random_bytes(16), 'hex'), + content varchar not null, + time timestamp not null, + stager_ip varchar +); + +create table if not exists maintainer ( + id serial primary key, + name varchar not null, + email varchar not null +); + +# --- !Downs +drop table if exists maintainer; +drop table if exists staged_quote; diff --git a/scalajvm/conf/routes b/scalajvm/conf/routes index 7306232..13858c5 100644 --- a/scalajvm/conf/routes +++ b/scalajvm/conf/routes @@ -4,7 +4,7 @@ # Main routes GET / controllers.Quotes.list(page: Int ?= 0, order: models.queries.QuoteOrdering.Value ?= models.queries.QuoteOrdering.Time, filter: models.queries.QuoteFilter.Value ?= models.queries.QuoteFilter.None) -GET /quote/new controllers.SuggestedQuotes.newQuote +GET /quote/new controllers.SuggestedQuotes.newQuote(stagedQuoteToken: String ?= "") POST /quote/new controllers.SuggestedQuotes.addQuote GET /quote/$id<[0-9]+> controllers.Quotes.quote(id: Long) POST /quote/$id<[0-9]+>/upvote controllers.Voting.vote(id: Long, up: Boolean = true) @@ -19,6 +19,7 @@ GET /quote/count/suggested controllers.SuggestedQuotes.cou # API routes GET /api/quote/$id<[0-9]+> controllers.api.Quotes.getQuote(id: Long) GET /api/quote/random controllers.api.Quotes.getRandomQuote() +POST /api/quote/stage controllers.api.Quotes.stageQuote # Map static resources from the /public folder to the /assets URL path GET /assets/*file controllers.Assets.at(path="/public", file) diff --git a/scalajvm/test/ApproverModelSpec.scala b/scalajvm/test/ApproverModelSpec.scala index ca4642f..84ba74a 100644 --- a/scalajvm/test/ApproverModelSpec.scala +++ b/scalajvm/test/ApproverModelSpec.scala @@ -1,3 +1,4 @@ +import helpers.DatabaseHelpers import org.specs2.mutable._ import play.api.test._ @@ -5,7 +6,9 @@ import play.api.test.Helpers._ import scalikejdbc._ -class ApproverModelSpec extends Specification { +import models.data.Approver + +class ApproverModelSpec extends Specification with DatabaseHelpers { val approvers = List( ("rexim", "rexim@loglist.net"), ("ForNeVeR", "fornever@loglist.net") @@ -15,13 +18,14 @@ class ApproverModelSpec extends Specification { "be able to add new approvers and return them back" in { running(FakeApplication()) { DB localTx { implicit session => + clearTable(Approver) + for ((name, email) <- approvers) { models.queries.ApproverQueries().insertApprover(name, email) } models.queries.ApproverQueries().getAllApprovers must have size approvers.size } } - } } } diff --git a/scalajvm/test/StagingSpec.scala b/scalajvm/test/StagingSpec.scala new file mode 100644 index 0000000..7da8471 --- /dev/null +++ b/scalajvm/test/StagingSpec.scala @@ -0,0 +1,75 @@ +import helpers.DatabaseHelpers +import org.specs2.mutable._ + +import play.api.test._ +import play.api.test.Helpers._ +import play.api.Play.current + +import scalikejdbc._ + +import models.data.StagedQuote +import models.queries.StagedQuoteQueries +import ru.org.codingteam.loglist.dto.StagedQuoteDTO + +class StagingSpec extends Specification with DatabaseHelpers { + + def stageRequestForText(text: String) = FakeRequest( + Helpers.POST, + controllers.api.routes.Quotes.stageQuote().url, + FakeHeaders(), + text + ) + + "The quote stage operation" should { + "stage the quote and return StagedQuoteDTO as JSON" in { + running(FakeApplication()) { + DB localTx { implicit session => + clearTable(StagedQuote) + } + + val request = route(stageRequestForText("Hello, World")).get + + val dto = upickle.read[StagedQuoteDTO](contentAsString(request)) + val stagedQuote = DB localTx { implicit session => + StagedQuoteQueries().getStagedQuoteByToken(dto.token).get + } + + dto.token mustEqual stagedQuote.token + dto.content mustEqual stagedQuote.content + dto.time mustEqual stagedQuote.time.getMillis + } + } + + "should return 507 in case count limit is exceeded" in { + running(FakeApplication()) { + DB localTx { implicit session => + clearTable(StagedQuote) + } + + val stagingText = "Hello, World" + + for (i <- 1 to 5) { status(route(stageRequestForText(stagingText)).get) mustEqual 200 } + status(route(stageRequestForText(stagingText)).get) mustEqual 507 + } + } + } + + "The submit form page" should { + "contain the staged quote if you provide appropriate stagedQuoteToken" in { + running(FakeApplication()) { + DB localTx { implicit session => + clearTable(StagedQuote) + } + + val expectedText = "Foo, Bar" + + val stageResponse = route(stageRequestForText(expectedText)).get + val dto = upickle.read[StagedQuoteDTO](contentAsString(stageResponse)) + + val submitFormResponse = controllers.SuggestedQuotes.newQuote(dto.token)(FakeRequest()) + + contentAsString(submitFormResponse) must contain(expectedText) + } + } + } +} diff --git a/scalajvm/test/helpers/DatabaseHelpers.scala b/scalajvm/test/helpers/DatabaseHelpers.scala new file mode 100644 index 0000000..c81748f --- /dev/null +++ b/scalajvm/test/helpers/DatabaseHelpers.scala @@ -0,0 +1,8 @@ +package helpers + +import scalikejdbc._ + +trait DatabaseHelpers { + def clearTable[T](table : SQLSyntaxSupport[T])(implicit db: DBSession): Unit = + withSQL { deleteFrom(table) }.update().apply() +}