offer swiss results download as CSV - for #8869

pull/8888/head
Thibault Duplessis 2021-05-06 13:19:03 +02:00
parent 6957c0fc16
commit c8e280c51c
9 changed files with 96 additions and 29 deletions

View File

@ -301,20 +301,22 @@ final class Api(
}
}
def swissResults(id: String) =
Action.async { implicit req =>
env.swiss.api byId lila.swiss.Swiss.Id(id) flatMap {
_ ?? { swiss =>
jsonStream {
env.swiss.api
.resultStream(swiss, MaxPerSecond(50), getInt("nb", req) | Int.MaxValue)
.mapAsync(8) { case (player, rank) =>
env.swiss.json.playerResult(player, rank.toInt)
}
}.fuccess
}
def swissResults(id: String) = Action.async { implicit req =>
val csv = HTTPRequest.acceptsCsv(req) || get("as", req).has("csv")
env.swiss.api byId lila.swiss.Swiss.Id(id) map {
_ ?? { swiss =>
val source = env.swiss.api
.resultStream(swiss, MaxPerSecond(50), getInt("nb", req) | Int.MaxValue)
.mapAsync(8) { p =>
env.user.lightUserApi.asyncFallback(p.player.userId) map p.withUser
}
val result =
if (csv) csvStream(lila.swiss.SwissCsv(source))
else jsonStream(source.map(env.swiss.json.playerResult))
result.pipe(asAttachment(env.api.gameApiV2.filename(swiss, if (csv) "csv" else "ndjson")))
}
}
}
def gamesByUsersStream =
AnonOrScopedBody(parse.tolerantText)()(
@ -426,21 +428,27 @@ final class Api(
.map(some)
.keepAlive(70.seconds, () => none) // play's idleTimeout = 75s
def sourceToNdJson(source: Source[JsValue, _]) =
def sourceToNdJson(source: Source[JsValue, _]): Result =
sourceToNdJsonString {
source.map { o =>
Json.stringify(o) + "\n"
}
}
def sourceToNdJsonOption(source: Source[Option[JsValue], _]) =
def sourceToNdJsonOption(source: Source[Option[JsValue], _]): Result =
sourceToNdJsonString {
source.map { _ ?? Json.stringify + "\n" }
}
private def sourceToNdJsonString(source: Source[String, _]) =
private def sourceToNdJsonString(source: Source[String, _]): Result =
Ok.chunked(source).as(ndJsonContentType) pipe noProxyBuffer
def csvStream(makeSource: => Source[String, _])(implicit req: RequestHeader): Result =
GlobalConcurrencyLimitPerIP(HTTPRequest ipAddress req)(makeSource)(sourceToCsv)
private def sourceToCsv(source: Source[String, _]): Result =
Ok.chunked(source.map(_ + "\n")).as(csvContentType) pipe noProxyBuffer
private[controllers] val GlobalConcurrencyLimitPerIP = new lila.memo.ConcurrencyLimit[IpAddress](
name = "API concurrency per IP",
key = "api.ip",

View File

@ -63,6 +63,7 @@ final class GameApiV2(
}
private val fileR = """[\s,]""".r
def filename(game: Game, format: Format): Fu[String] =
gameLightUsers(game) map { case (wu, bu) =>
fileR.replaceAllIn(
@ -76,6 +77,7 @@ final class GameApiV2(
"_"
)
}
def filename(tour: Tournament, format: Format): String =
fileR.replaceAllIn(
"lichess_tournament_%s_%s_%s.%s".format(
@ -86,13 +88,17 @@ final class GameApiV2(
),
"_"
)
def filename(swiss: lila.swiss.Swiss, format: Format): String =
filename(swiss, format.toString.toLowerCase)
def filename(swiss: lila.swiss.Swiss, format: String): String =
fileR.replaceAllIn(
"lichess_swiss_%s_%s_%s.%s".format(
Tag.UTCDate.format.print(swiss.startsAt),
swiss.id,
lila.common.String.slugify(swiss.name),
format.toString.toLowerCase
format
),
"_"
)

View File

@ -95,6 +95,7 @@ object HTTPRequest {
def acceptsNdJson(req: RequestHeader) = req.headers get HeaderNames.ACCEPT contains "application/x-ndjson"
def acceptsJson(req: RequestHeader) = req.headers get HeaderNames.ACCEPT contains "application/json"
def acceptsCsv(req: RequestHeader) = req.headers get HeaderNames.ACCEPT contains "text/csv"
def actionName(req: RequestHeader): String =
req.attrs.get(Router.Attrs.ActionName).getOrElse("NoHandler")

View File

@ -638,7 +638,7 @@ final class SwissApi(
}
}
def resultStream(swiss: Swiss, perSecond: MaxPerSecond, nb: Int): Source[(SwissPlayer, Long), _] =
def resultStream(swiss: Swiss, perSecond: MaxPerSecond, nb: Int): Source[SwissPlayer.WithRank, _] =
SwissPlayer.fields { f =>
colls.player
.find($doc(f.swissId -> swiss.id))
@ -648,6 +648,9 @@ final class SwissApi(
.documentSource(nb)
.throttle(perSecond.value, 1 second)
.zipWithIndex
.map { case (player, index) =>
SwissPlayer.WithRank(player, index.toInt + 1)
}
}
private val idNameProjection = $doc("name" -> true)

View File

@ -0,0 +1,34 @@
package lila.swiss
import akka.stream.scaladsl.Source
object SwissCsv {
def apply(results: Source[SwissPlayer.WithUserAndRank, _]): Source[String, _] =
Source(
List(
toCsv(
"Rank",
"Title",
"Username",
"Rating",
"Score",
"Tie Break",
"Performance"
)
)
) concat
results.map(apply)
def apply(p: SwissPlayer.WithUserAndRank): String = toCsv(
p.rank.toString,
~p.user.title,
p.user.name,
p.player.rating.toString,
p.player.score.value.toString,
p.player.tieBreak.value.toString,
p.player.performance.??(_.value.toString)
)
private def toCsv(values: String*) = values mkString ","
}

View File

@ -141,8 +141,8 @@ final class SwissJson(
}
}
def playerResult(player: SwissPlayer, rank: Int): Fu[JsObject] =
lightUserApi.asyncFallback(player.userId) map { user =>
def playerResult(p: SwissPlayer.WithUserAndRank): JsObject = p match {
case SwissPlayer.WithUserAndRank(player, user, rank) =>
Json
.obj(
"rank" -> rank,
@ -154,7 +154,7 @@ final class SwissJson(
.add("title" -> user.title)
.add("performance" -> player.performance)
.add("absent" -> player.absent)
}
}
}
object SwissJson {

View File

@ -53,13 +53,16 @@ object SwissPlayer {
byes = Set.empty
).recomputeScore
case class Ranked(rank: Int, player: SwissPlayer) {
def is(other: Ranked) = player is other.player
override def toString = s"$rank. ${player.userId}[${player.rating}]"
case class WithRank(player: SwissPlayer, rank: Int) {
def is(other: WithRank) = player is other.player
def withUser(user: LightUser) = WithUserAndRank(player, user, rank)
override def toString = s"$rank. ${player.userId}[${player.rating}]"
}
case class WithUser(player: SwissPlayer, user: LightUser)
case class WithUserAndRank(player: SwissPlayer, user: LightUser, rank: Int)
sealed private[swiss] trait Viewish {
val player: SwissPlayer
val rank: Int

View File

@ -89,7 +89,7 @@ final class SwissStandingApi(
"players" -> rankedPlayers
.zip(users)
.zip(sheets)
.map { case ((SwissPlayer.Ranked(rank, player), user), sheet) =>
.map { case ((SwissPlayer.WithRank(player, rank), user), sheet) =>
SwissJson.playerJson(
swiss,
SwissPlayer.View(
@ -103,16 +103,16 @@ final class SwissStandingApi(
}
)
private def bestWithRank(id: Swiss.Id, nb: Int, skip: Int): Fu[List[SwissPlayer.Ranked]] =
private def bestWithRank(id: Swiss.Id, nb: Int, skip: Int): Fu[List[SwissPlayer.WithRank]] =
best(id, nb, skip).map { res =>
res
.foldRight(List.empty[SwissPlayer.Ranked] -> (res.size + skip)) { case (p, (res, rank)) =>
(SwissPlayer.Ranked(rank, p) :: res, rank - 1)
.foldRight(List.empty[SwissPlayer.WithRank] -> (res.size + skip)) { case (p, (res, rank)) =>
(SwissPlayer.WithRank(p, rank) :: res, rank - 1)
}
._1
}
private def bestWithRankByPage(id: Swiss.Id, nb: Int, page: Int): Fu[List[SwissPlayer.Ranked]] =
private def bestWithRankByPage(id: Swiss.Id, nb: Int, page: Int): Fu[List[SwissPlayer.WithRank]] =
bestWithRank(id, nb, (page - 1) * nb)
private def best(id: Swiss.Id, nb: Int, skip: Int): Fu[List[SwissPlayer]] =

View File

@ -227,6 +227,7 @@ function stats(ctrl: SwissCtrl): VNode | undefined {
const s = ctrl.data.stats,
noarg = ctrl.trans.noarg,
slots = ctrl.data.round * ctrl.data.nbPlayers;
console.log(ctrl.data);
return s
? h('div.swiss__stats', [
h('h2', noarg('tournamentComplete')),
@ -281,7 +282,18 @@ function stats(ctrl: SwissCtrl): VNode | undefined {
download: true,
},
},
'Download results'
'Download results as NDJSON'
),
h(
'a.text',
{
attrs: {
'data-icon': 'x',
href: `/api/swiss/${ctrl.data.id}/results?as=csv`,
download: true,
},
},
'Download results as CSV'
),
h('br'),
h(