One tournament files directory per user for oauth

This commit is contained in:
Claude Brisson
2024-03-04 09:08:11 +01:00
parent 5c0b763751
commit b7508a85f8
14 changed files with 112 additions and 73 deletions

View File

@@ -2,22 +2,21 @@ package org.jeudego.pairgoth.api
import com.republicate.kson.Json
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.store.getStore
import javax.servlet.http.HttpServletRequest
interface PairgothApiHandler: ApiHandler {
fun getTournament(request: HttpServletRequest): Tournament<*> {
val tournamentId = getSelector(request)?.toIntOrNull() ?: ApiHandler.badRequest("invalid tournament id")
return Store.getTournament(tournamentId) ?: ApiHandler.badRequest("unknown tournament id")
return getStore(request).getTournament(tournamentId) ?: ApiHandler.badRequest("unknown tournament id")
}
fun Tournament<*>.dispatchEvent(event: Event, data: Json? = null) {
fun Tournament<*>.dispatchEvent(event: Event, request: HttpServletRequest, data: Json? = null) {
Event.dispatch(event, Json.Object("tournament" to id, "data" to data))
// when storage is not in memory, the tournament has to be persisted
if (event != Event.TournamentAdded && event != Event.TournamentDeleted)
Store.replaceTournament(this)
getStore(request).replaceTournament(this)
}
}

View File

@@ -58,7 +58,7 @@ object PairingHandler: PairgothApiHandler {
}
val games = tournament.pair(round, pairables)
val ret = games.map { it.toJson() }.toJsonArray()
tournament.dispatchEvent(GamesAdded, Json.Object("round" to round, "games" to ret))
tournament.dispatchEvent(GamesAdded, request, Json.Object("round" to round, "games" to ret))
return ret
}
@@ -98,7 +98,7 @@ object PairingHandler: PairgothApiHandler {
if (payload.containsKey("t")) {
game.table = payload.getString("t")?.toIntOrNull() ?: badRequest("invalid table number")
}
tournament.dispatchEvent(GameUpdated, Json.Object("round" to round, "game" to game.toJson()))
tournament.dispatchEvent(GameUpdated, request, Json.Object("round" to round, "game" to game.toJson()))
if (game.table != previousTable) {
val sortedPairables = tournament.getSortedPairables(round)
val sortedMap = sortedPairables.associateBy {
@@ -113,7 +113,10 @@ object PairingHandler: PairgothApiHandler {
val games = tournament.games(round).values.sortedBy {
if (it.table == 0) Int.MAX_VALUE else it.table
}
tournament.dispatchEvent(TablesRenumbered, Json.Object("round" to round, "games" to games.map { it.toJson() }.toCollection(Json.MutableArray())))
tournament.dispatchEvent(
TablesRenumbered, request,
Json.Object("round" to round, "games" to games.map { it.toJson() }.toCollection(Json.MutableArray()))
)
}
}
return Json.Object("success" to true)
@@ -132,7 +135,10 @@ object PairingHandler: PairgothApiHandler {
val games = tournament.games(round).values.sortedBy {
if (it.table == 0) Int.MAX_VALUE else it.table
}
tournament.dispatchEvent(TablesRenumbered, Json.Object("round" to round, "games" to games.map { it.toJson() }.toCollection(Json.MutableArray())))
tournament.dispatchEvent(
TablesRenumbered, request,
Json.Object("round" to round, "games" to games.map { it.toJson() }.toCollection(Json.MutableArray()))
)
}
return Json.Object("success" to true)
}
@@ -161,7 +167,7 @@ object PairingHandler: PairgothApiHandler {
}
}
}
tournament.dispatchEvent(GamesDeleted, Json.Object("round" to round, "games" to payload))
tournament.dispatchEvent(GamesDeleted, request, Json.Object("round" to round, "games" to payload))
return Json.Object("success" to true)
}
}

View File

@@ -24,7 +24,7 @@ object PlayerHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val player = Player.fromJson(payload)
tournament.players[player.id] = player
tournament.dispatchEvent(PlayerAdded, player.toJson())
tournament.dispatchEvent(PlayerAdded, request, player.toJson())
return Json.Object("success" to true, "id" to player.id)
}
@@ -46,7 +46,7 @@ object PlayerHandler: PairgothApiHandler {
}
}
tournament.players[id] = updated
tournament.dispatchEvent(PlayerUpdated, player.toJson())
tournament.dispatchEvent(PlayerUpdated, request, player.toJson())
return Json.Object("success" to true)
}
@@ -59,7 +59,7 @@ object PlayerHandler: PairgothApiHandler {
badRequest("player is playing")
}
tournament.players.remove(id) ?: badRequest("invalid player id")
tournament.dispatchEvent(PlayerDeleted, Json.Object("id" to id))
tournament.dispatchEvent(PlayerDeleted, request, Json.Object("id" to id))
return Json.Object("success" to true)
}
}

View File

@@ -24,7 +24,7 @@ object ResultsHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val game = tournament.games(round)[payload.getInt("id")] ?: badRequest("invalid game id")
game.result = Game.Result.fromSymbol(payload.getChar("result") ?: badRequest("missing result"))
tournament.dispatchEvent(Event.ResultUpdated, Json.Object("round" to round, "data" to game))
tournament.dispatchEvent(Event.ResultUpdated, request, Json.Object("round" to round, "data" to game))
return Json.Object("success" to true)
}
}

View File

@@ -25,7 +25,7 @@ object TeamHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val team = tournament.teamFromJson(payload)
tournament.teams[team.id] = team
tournament.dispatchEvent(TeamAdded, team.toJson())
tournament.dispatchEvent(TeamAdded, request, team.toJson())
return Json.Object("success" to true, "id" to team.id)
}
@@ -37,7 +37,7 @@ object TeamHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val updated = tournament.teamFromJson(payload, team)
tournament.teams[updated.id] = updated
tournament.dispatchEvent(TeamUpdated, team.toJson())
tournament.dispatchEvent(TeamUpdated, request, team.toJson())
return Json.Object("success" to true)
}
@@ -46,7 +46,7 @@ object TeamHandler: PairgothApiHandler {
if (tournament !is TeamTournament) badRequest("tournament is not a team tournament")
val id = getSubSelector(request)?.toIntOrNull() ?: badRequest("missing or invalid team selector")
tournament.teams.remove(id) ?: badRequest("invalid team id")
tournament.dispatchEvent(TeamDeleted, Json.Object("id" to id))
tournament.dispatchEvent(TeamDeleted, request, Json.Object("id" to id))
return Json.Object("success" to true)
}
}

View File

@@ -9,9 +9,9 @@ import org.jeudego.pairgoth.model.TeamTournament
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.fromJson
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.server.ApiServlet
import org.jeudego.pairgoth.server.Event.*
import org.jeudego.pairgoth.store.getStore
import org.w3c.dom.Element
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -21,12 +21,12 @@ object TournamentHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val accept = request.getHeader("Accept")
return when (val id = getSelector(request)?.toIntOrNull()) {
null -> Store.getTournaments().toJsonObject()
null -> getStore(request).getTournaments().toJsonObject()
else ->
when {
ApiServlet.isJson(accept) -> Store.getTournament(id)?.toJson() ?: badRequest("no tournament with id #${id}")
ApiServlet.isJson(accept) -> getStore(request).getTournament(id)?.toJson() ?: badRequest("no tournament with id #${id}")
ApiServlet.isXml(accept) -> {
val export = Store.getTournament(id)?.let { OpenGotha.export(it) } ?: badRequest("no tournament with id #${id}")
val export = getStore(request).getTournament(id)?.let { OpenGotha.export(it) } ?: badRequest("no tournament with id #${id}")
response.contentType = "application/xml; charset=UTF-8"
response.writer.write(export)
null // return null to indicate that we handled the response ourself
@@ -42,8 +42,8 @@ object TournamentHandler: PairgothApiHandler {
is Element -> OpenGotha.import(payload)
else -> badRequest("missing or invalid payload")
}
Store.addTournament(tournament)
tournament.dispatchEvent(TournamentAdded, tournament.toJson())
getStore(request).addTournament(tournament)
tournament.dispatchEvent(TournamentAdded, request, tournament.toJson())
return Json.Object("success" to true, "id" to tournament.id)
}
@@ -63,14 +63,14 @@ object TournamentHandler: PairgothApiHandler {
clear()
putAll(tournament.games(round))
}
updated.dispatchEvent(TournamentUpdated, updated.toJson())
updated.dispatchEvent(TournamentUpdated, request, updated.toJson())
return Json.Object("success" to true)
}
override fun delete(request: HttpServletRequest, response: HttpServletResponse): Json {
val tournament = getTournament(request)
Store.deleteTournament(tournament)
tournament.dispatchEvent(TournamentDeleted, Json.Object("id" to tournament.id))
getStore(request).deleteTournament(tournament)
tournament.dispatchEvent(TournamentDeleted, request, Json.Object("id" to tournament.id))
return Json.Object("success" to true)
}
}

View File

@@ -7,6 +7,9 @@ import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.opengotha.TournamentType
import org.jeudego.pairgoth.opengotha.ObjectFactory
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.store.nextGameId
import org.jeudego.pairgoth.store.nextPlayerId
import org.jeudego.pairgoth.store.nextTournamentId
import org.w3c.dom.Element
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@@ -118,7 +121,7 @@ object OpenGotha {
)
val tournament = StandardTournament(
id = Store.nextTournamentId,
id = nextTournamentId,
type = Tournament.Type.INDIVIDUAL, // CB for now, TODO
name = genParams.name,
shortName = genParams.shortName,
@@ -153,7 +156,7 @@ object OpenGotha {
// import players
ogTournament.players.player.map { player ->
Player(
id = Store.nextPlayerId,
id = nextPlayerId,
name = player.name,
firstname = player.firstName,
rating = player.rating,
@@ -174,7 +177,7 @@ object OpenGotha {
}.entries.sortedBy { it.key }.map {
it.value.map { game ->
Game(
id = Store.nextGameId,
id = nextGameId,
table = game.tableNumber,
black = canonicMap[game.blackPlayer] ?: throw Error("player not found: ${game.blackPlayer}"),
white = canonicMap[game.whitePlayer] ?: throw Error("player not found: ${game.whitePlayer}"),

View File

@@ -3,6 +3,7 @@ package org.jeudego.pairgoth.model
import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.store.nextPlayerId
import java.util.*
// Pairable
@@ -101,7 +102,7 @@ class Player(
}
fun Player.Companion.fromJson(json: Json.Object, default: Player? = null) = Player(
id = json.getInt("id") ?: default?.id ?: Store.nextPlayerId,
id = json.getInt("id") ?: default?.id ?: nextPlayerId,
name = json.getString("name") ?: default?.name ?: badRequest("missing name"),
firstname = json.getString("firstname") ?: default?.firstname ?: badRequest("missing firstname"),
rating = json.getInt("rating") ?: default?.rating ?: badRequest("missing rating"),

View File

@@ -9,6 +9,8 @@ import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
import org.jeudego.pairgoth.pairing.solver.SwissSolver
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.store.nextPlayerId
import org.jeudego.pairgoth.store.nextTournamentId
import kotlin.math.max
import java.util.*
import kotlin.math.roundToInt
@@ -178,7 +180,7 @@ class TeamTournament(
}
fun teamFromJson(json: Json.Object, default: TeamTournament.Team? = null) = Team(
id = json.getInt("id") ?: default?.id ?: Store.nextPlayerId,
id = json.getInt("id") ?: default?.id ?: nextPlayerId,
name = json.getString("name") ?: default?.name ?: badRequest("missing name"),
final = json.getBoolean("final") ?: default?.final ?: badRequest("missing final")
).apply {
@@ -199,7 +201,7 @@ fun Tournament.Companion.fromJson(json: Json.Object, default: Tournament<*>? = n
// No clean way to avoid this redundancy
val tournament = if (type.playersNumber == 1)
StandardTournament(
id = json.getInt("id") ?: default?.id ?: Store.nextTournamentId,
id = json.getInt("id") ?: default?.id ?: nextTournamentId,
type = type,
name = json.getString("name") ?: default?.name ?: badRequest("missing name"),
shortName = json.getString("shortName") ?: default?.shortName ?: badRequest("missing shortName"),
@@ -217,7 +219,7 @@ fun Tournament.Companion.fromJson(json: Json.Object, default: Tournament<*>? = n
)
else
TeamTournament(
id = json.getInt("id") ?: default?.id ?: Store.nextTournamentId,
id = json.getInt("id") ?: default?.id ?: nextTournamentId,
type = type,
name = json.getString("name") ?: default?.name ?: badRequest("missing name"),
shortName = json.getString("shortName") ?: default?.shortName ?: badRequest("missing shortName"),

View File

@@ -6,6 +6,7 @@ import org.jeudego.pairgoth.pairing.BasePairingHelper
import org.jeudego.pairgoth.pairing.detRandom
import org.jeudego.pairgoth.pairing.nonDetRandom
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.store.nextGameId
import org.jgrapht.alg.matching.blossom.v5.KolmogorovWeightedPerfectMatching
import org.jgrapht.alg.matching.blossom.v5.ObjectiveSense
import org.jgrapht.graph.DefaultWeightedEdge
@@ -118,7 +119,7 @@ sealed class BaseSolver(
var result = sorted.flatMap { games(white = it[0], black = it[1]) }
// add game for ByePlayer
if (chosenByePlayer != ByePlayer) result += Game(id = Store.nextGameId, table = 0, white = ByePlayer.id, black = chosenByePlayer.id, result = Game.Result.fromSymbol('b'))
if (chosenByePlayer != ByePlayer) result += Game(id = nextGameId, table = 0, white = ByePlayer.id, black = chosenByePlayer.id, result = Game.Result.fromSymbol('b'))
val DEBUG_EXPORT_WEIGHT = false
if (DEBUG_EXPORT_WEIGHT) {
@@ -554,6 +555,6 @@ sealed class BaseSolver(
// CB TODO team of individuals pairing
val table = if (black.id == 0 || white.id == 0) 0 else usedTables.nextClearBit(1)
usedTables.set(table)
return listOf(Game(id = Store.nextGameId, table = table, black = black.id, white = white.id, handicap = hd(white = white, black = black), drawnUpDown = dudd(black, white)))
return listOf(Game(id = nextGameId, table = table, black = black.id, white = white.id, handicap = hd(white = white, black = black), drawnUpDown = dudd(black, white)))
}
}

View File

@@ -10,23 +10,40 @@ import org.jeudego.pairgoth.model.fromJson
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.toFullJson
import org.jeudego.pairgoth.model.toID
import org.jeudego.pairgoth.server.WebappManager
import java.lang.Integer.max
import java.nio.file.Path
import java.nio.file.PathMatcher
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.*
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.readText
import kotlin.io.path.useDirectoryEntries
import kotlin.io.path.walk
private const val LEFT_PAD = 6 // left padding of IDs with '0' in filename
private fun Tournament<*>.filename() = "${id.toString().padStart(LEFT_PAD, '0')}-${shortName}.tour"
class FileStore(pathStr: String): IStore {
class FileStore(pathStr: String): Store {
companion object {
private val filenameRegex = Regex("^(\\d+)-(.*)\\.tour$")
private val displayFormat: DateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
private val timestampFormat: DateFormat = SimpleDateFormat("yyyyMMddHHmmss")
private val timestamp: String get() = timestampFormat.format(Date())
@OptIn(ExperimentalPathApi::class)
private fun getMaxID(): ID {
val rootPath = Path.of(WebappManager.properties.getProperty("store.file.path") ?: ".")
val globMatcher = PathMatcher { path -> path.fileName.toString().endsWith(".tour") }
return rootPath.walk().filter { path -> globMatcher.matches(path) }.mapNotNull { path ->
val match = filenameRegex.matchEntire(path.fileName.toString())
match?.let { it.groupValues[1].toID() }
}.maxOrNull() ?: 0
}
init {
_nextTournamentId.set(getMaxID())
}
}
private val path = Path.of(pathStr).also {
@@ -34,13 +51,10 @@ class FileStore(pathStr: String): IStore {
if (!file.mkdirs() && !file.isDirectory) throw Error("Property pairgoth.store.file.path must be a directory")
}
init {
_nextTournamentId.set(getTournaments().keys.maxOrNull() ?: 0.toID())
}
private fun lastModified(path: Path) = displayFormat.format(Date(path.toFile().lastModified()))
override fun getTournaments(): Map<ID, Map<String, String>> {
return path.useDirectoryEntries("*.tour") { entries ->
entries.mapNotNull { entry ->

View File

@@ -1,22 +0,0 @@
package org.jeudego.pairgoth.store
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.Tournament
import java.util.concurrent.atomic.AtomicInteger
internal val _nextTournamentId = AtomicInteger()
internal val _nextPlayerId = AtomicInteger()
internal val _nextGameId = AtomicInteger()
interface IStore {
val nextTournamentId get() = _nextTournamentId.incrementAndGet()
val nextPlayerId get() = _nextPlayerId.incrementAndGet()
val nextGameId get() = _nextGameId.incrementAndGet()
fun getTournaments(): Map<ID, Map<String, String>>
fun addTournament(tournament: Tournament<*>)
fun getTournament(id: ID): Tournament<*>?
fun replaceTournament(tournament: Tournament<*>)
fun deleteTournament(tournament: Tournament<*>)
}

View File

@@ -3,7 +3,7 @@ package org.jeudego.pairgoth.store
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.Tournament
class MemoryStore: IStore {
class MemoryStore: Store {
private val tournaments = mutableMapOf<ID, Tournament<*>>()
override fun getTournaments(): Map<ID, Map<String, String>> = tournaments.mapValues {

View File

@@ -1,16 +1,51 @@
package org.jeudego.pairgoth.store
import com.republicate.kson.Json
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.server.ApiServlet.Companion.USER_KEY
import org.jeudego.pairgoth.server.WebappManager
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicInteger
import javax.servlet.http.HttpServletRequest
private fun createStoreImplementation(): IStore {
return when (val storeProperty = WebappManager.properties.getProperty("store") ?: "memory") {
"memory" -> MemoryStore()
"file" -> {
val filePath = WebappManager.properties.getProperty("store.file.path") ?: "."
FileStore(filePath)
}
else -> throw Error("unknown store: $storeProperty")
}
internal val _nextTournamentId = AtomicInteger()
internal val _nextPlayerId = AtomicInteger()
internal val _nextGameId = AtomicInteger()
val nextTournamentId get() = _nextTournamentId.incrementAndGet()
val nextPlayerId get() = _nextPlayerId.incrementAndGet()
val nextGameId get() = _nextGameId.incrementAndGet()
interface Store {
fun getTournaments(): Map<ID, Map<String, String>>
fun addTournament(tournament: Tournament<*>)
fun getTournament(id: ID): Tournament<*>?
fun replaceTournament(tournament: Tournament<*>)
fun deleteTournament(tournament: Tournament<*>)
}
object Store: IStore by createStoreImplementation()
fun getStore(request: HttpServletRequest): Store {
val storeType = WebappManager.getMandatoryProperty("store")
return when (val auth = WebappManager.getMandatoryProperty("auth")) {
"none", "sesame" ->
when (storeType) {
"memory" -> MemoryStore()
"file" -> {
val filePath = WebappManager.properties.getProperty("store.file.path") ?: "."
FileStore(filePath)
}
else -> throw Error("invalid store type: $storeType")
}
"oauth" -> {
if (storeType == "memory") throw Error("invalid store type for oauth: $storeType")
var rootPath = WebappManager.properties.getProperty("store.file.path") ?: "."
(request.getAttribute(USER_KEY) as Json.Object?)?.getString("email")?.also { email ->
rootPath = "$rootPath/$email"
Path.of(rootPath).toFile().mkdirs()
}
FileStore(rootPath)
}
else -> throw Error("invalid auth: $auth")
}
}