diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/MatchGroupDto.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/MatchGroupDto.kt new file mode 100644 index 00000000..32b2f3fa --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/MatchGroupDto.kt @@ -0,0 +1,21 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.admin.GenerateOverview +import hu.bme.sch.cmsch.admin.OverviewType +import hu.bme.sch.cmsch.model.IdentifiableEntity + +data class MatchGroupDto( + + @property:GenerateOverview(renderer = OverviewType.ID, columnName = "ID", order = -1) + override var id: Int = 0, + + @property:GenerateOverview(columnName = "Név", order = 1) + var name: String = "", + + @property:GenerateOverview(columnName = "Helyszín", order = 2) + var location: String = "", + + @property:GenerateOverview(columnName = "Közeli meccsek száma", order = 3) + var matchCount: Int = 0, + +): IdentifiableEntity diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/ParticipantDto.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/ParticipantDto.kt new file mode 100644 index 00000000..155b0b0c --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/ParticipantDto.kt @@ -0,0 +1,12 @@ +package hu.bme.sch.cmsch.component.tournament + +data class ParticipantDto( + var teamId: Int = 0, + var teamName: String = "", +) + +data class SeededParticipantDto( + var teamId: Int = 0, + var teamName: String = "", + var seed: Int = 0, +) \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/StageGroupDto.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/StageGroupDto.kt new file mode 100644 index 00000000..18c1d5df --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/StageGroupDto.kt @@ -0,0 +1,24 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.admin.GenerateOverview +import hu.bme.sch.cmsch.admin.OverviewType +import hu.bme.sch.cmsch.model.IdentifiableEntity + +data class StageGroupDto( + + @property:GenerateOverview(renderer = OverviewType.ID, columnName = "ID", order = -1) + override var id: Int = 0, + + @property:GenerateOverview(columnName = "Név", order = 1) + var name: String = "", + + @property:GenerateOverview(columnName = "Helyszín", order = 2) + var location: String = "", + + @property:GenerateOverview(columnName = "Résztvevők száma", order = 3) + var participantCount: Int = 0, + + @property:GenerateOverview(columnName = "Szakaszok száma", order = 4) + var stageCount: Int = 0 + +): IdentifiableEntity diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/StageResultDto.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/StageResultDto.kt new file mode 100644 index 00000000..3a2a8d36 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/StageResultDto.kt @@ -0,0 +1,48 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.annotation.JsonInclude + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +data class StageResultDto( + val teamId: Int, + val teamName: String, + val stageId: Int = 0, + var highlighted: Boolean = false, + var initialSeed: Int = 0, + var highestSeed: Int = 0, + var detailedStats: GroupStageResults? = null, +): Comparable { + override fun compareTo(other: StageResultDto): Int { + if (this.stageId != other.stageId) { + return this.stageId.compareTo(other.stageId) + } + if (this.detailedStats == null && other.detailedStats == null) { + return -this.initialSeed.compareTo(other.highestSeed) + } + if (this.detailedStats == null) { + return -1 // null detailed stats are considered better (knockout stages are later) + } + return compareValuesBy(this, other, + { it.detailedStats?:GroupStageResults()}, {it.initialSeed}, {it.highlighted}) + } +} +data class GroupStageResults( + var group: String = "", + var position: UShort = UShort.MAX_VALUE, + var points: UInt = 0u, + var won: UShort = 0u, + var drawn: UShort = 0u, + var lost: UShort = 0u, + var goalsFor: UInt = 0u, + var goalsAgainst: UInt = 0u, + var goalDifference: Int = 0 +): Comparable { + override fun compareTo(other: GroupStageResults): Int { + return compareValuesBy(this, other, + { -it.position.toInt() }, + { it.points }, + { it.won }, + { it.goalDifference }, + { it.goalsFor }) + } +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentApiController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentApiController.kt new file mode 100644 index 00000000..05df40ca --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentApiController.kt @@ -0,0 +1,81 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.annotation.JsonView +import hu.bme.sch.cmsch.config.OwnershipType +import hu.bme.sch.cmsch.dto.Preview +import hu.bme.sch.cmsch.model.RoleType +import hu.bme.sch.cmsch.util.getUserOrNull +import hu.bme.sch.cmsch.util.isAvailableForRole +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.Authentication +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api") +@ConditionalOnBean(TournamentComponent::class) +class TournamentApiController( + private val tournamentComponent: TournamentComponent, + private val tournamentService: TournamentService, +) { + @JsonView(Preview::class) + @GetMapping("/tournament") + @Operation( + summary = "List all tournaments.", + ) + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "List of tournaments") + ]) + fun listTournaments(auth: Authentication?): ResponseEntity> { + val user = auth?.getUserOrNull() + if (!tournamentComponent.minRole.isAvailableForRole(user?.role ?: RoleType.GUEST)) + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(listOf()) + if (!tournamentComponent.showTournamentsAtAll) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build() + } + + return ResponseEntity.ok(tournamentService.listAllTournaments()) + } + + + @GetMapping("/tournament/{tournamentId}") + @Operation( + summary = "Get details of a tournament.", + ) + @ApiResponses(value = [ + ApiResponse(responseCode = "200", description = "Details of the tournament"), + ApiResponse(responseCode = "404", description = "Tournament not found") + ]) + fun tournamentDetails( + @PathVariable tournamentId: Int, + auth: Authentication? + ): ResponseEntity{ + val user = auth?.getUserOrNull() + if (!tournamentComponent.minRole.isAvailableForRole(user?.role ?: RoleType.GUEST)) + return ResponseEntity.status(HttpStatus.FORBIDDEN).build() + + return tournamentService.showTournament(tournamentId, user)?.let { ResponseEntity.ok(it) } + ?: ResponseEntity.status(HttpStatus.NOT_FOUND).build() + } + + @PostMapping("/tournament/register") + fun registerTeam( + @RequestBody tournamentJoinDto: TournamentJoinDto, + auth: Authentication? + ): TournamentJoinStatus { + val user = auth?.getUserOrNull() + ?: return TournamentJoinStatus.INSUFFICIENT_PERMISSIONS + + val tournament = tournamentService.findById(tournamentJoinDto.id) + ?: return TournamentJoinStatus.TOURNAMENT_NOT_FOUND + return when (tournament.participantType) { + OwnershipType.GROUP -> tournamentService.teamRegister(tournament, user) + OwnershipType.USER -> tournamentService.userRegister(tournament, user) + } + } + +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponent.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponent.kt new file mode 100644 index 00000000..9fae5b96 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponent.kt @@ -0,0 +1,56 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.component.ComponentBase +import hu.bme.sch.cmsch.model.RoleType +import hu.bme.sch.cmsch.service.ControlPermissions +import hu.bme.sch.cmsch.setting.* +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.core.env.Environment +import org.springframework.stereotype.Service + + +@Service +@ConditionalOnProperty( + prefix = "hu.bme.sch.cmsch.component.load", + name = ["tournament"], + havingValue = "true", + matchIfMissing = false +) +class TournamentComponent ( + componentSettingService: ComponentSettingService, + env: Environment +) : ComponentBase( + componentSettingService, + "tournament", + "/tournament", + "Tournament", + ControlPermissions.PERMISSION_CONTROL_TOURNAMENT, + listOf(), + env +) { + + val tournamentGroup by SettingGroup(fieldName = "Versenyek") + final var title by StringSettingRef( + "Sportversenyek", + fieldName = "Lap címe", description = "Ez jelenik meg a böngésző címsorában" + ) + final override var menuDisplayName by StringSettingRef( + "Sportversenyek", serverSideOnly = true, + fieldName = "Menü neve", description = "Ez lesz a neve a menünek" + ) + final override var minRole by MinRoleSettingRef( + setOf(), fieldName = "Jogosultságok", + description = "Melyik roleokkal nyitható meg az oldal" + ) + + var showTournamentsAtAll by BooleanSettingRef( + false, fieldName = "Leküldött", + description = "Ha igaz, akkor leküldésre kerülnek a versenyek" + ) + + var closeMatchesTimeWindow by NumberSettingRef( + defaultValue = 120, fieldName = "Közelgő mérkőzések időablaka", + description = "Mennyi időn belül közelgő mérkőzések jelenjenek meg a match admin oldalon", + minRoleToEdit = RoleType.ADMIN + ) +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponentController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponentController.kt new file mode 100644 index 00000000..da8dc488 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponentController.kt @@ -0,0 +1,36 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.component.ComponentApiBase +import hu.bme.sch.cmsch.component.app.MenuService +import hu.bme.sch.cmsch.service.AdminMenuService +import hu.bme.sch.cmsch.service.AuditLogService +import hu.bme.sch.cmsch.service.ControlPermissions +import hu.bme.sch.cmsch.service.StorageService +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.RequestMapping + +@Controller +@RequestMapping("/admin/control/component/tournament") +@ConditionalOnBean(TournamentComponent::class) +class TournamentComponentController( + adminMenuService: AdminMenuService, + component: TournamentComponent, + private val menuService: MenuService, + private val tournamentService: TournamentService, + private val auditLogService: AuditLogService, + private val storageService: StorageService, + service: MenuService +) : ComponentApiBase( + adminMenuService, + TournamentComponent::class.java, + component, + ControlPermissions.PERMISSION_CONTROL_TOURNAMENT, + "Tournament", + "Tournament beállítások", + auditLogService = auditLogService, + menuService = menuService, + storageService = storageService +) { + +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponentEntityConfiguration.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponentEntityConfiguration.kt new file mode 100644 index 00000000..489e5030 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentComponentEntityConfiguration.kt @@ -0,0 +1,11 @@ +package hu.bme.sch.cmsch.component.tournament + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.context.annotation.Configuration + + +@Configuration +@ConditionalOnBean(TournamentComponent::class) +@EntityScan(basePackageClasses = [TournamentComponent::class]) +class TournamentComponentEntityConfiguration diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentController.kt new file mode 100644 index 00000000..0b2e982d --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentController.kt @@ -0,0 +1,66 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.databind.ObjectMapper +import hu.bme.sch.cmsch.controller.admin.OneDeepEntityPage +import hu.bme.sch.cmsch.controller.admin.calculateSearchSettings +import hu.bme.sch.cmsch.service.AdminMenuService +import hu.bme.sch.cmsch.service.AuditLogService +import hu.bme.sch.cmsch.service.ImportService +import hu.bme.sch.cmsch.service.StaffPermissions +import hu.bme.sch.cmsch.service.StorageService +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.core.env.Environment +import org.springframework.stereotype.Controller +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.web.bind.annotation.RequestMapping + +@Controller +@RequestMapping("/admin/control/tournament") +@ConditionalOnBean(TournamentComponent::class) +class TournamentController( + private val tournamentService: TournamentService, + repo: TournamentRepository, + importService: ImportService, + adminMenuService: AdminMenuService, + component: TournamentComponent, + auditLog: AuditLogService, + objectMapper: ObjectMapper, + transactionManager: PlatformTransactionManager, + storageService: StorageService, + env: Environment +) : OneDeepEntityPage( + "tournament", + TournamentEntity::class, ::TournamentEntity, + "Verseny", "Versenyek", + "A rendezvény versenyeinek kezelése.", + + transactionManager, + repo, + importService, + adminMenuService, + storageService, + component, + auditLog, + objectMapper, + env, + + showPermission = StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + createPermission = StaffPermissions.PERMISSION_CREATE_TOURNAMENTS, + editPermission = StaffPermissions.PERMISSION_EDIT_TOURNAMENTS, + deletePermission = StaffPermissions.PERMISSION_DELETE_TOURNAMENTS, + + createEnabled = true, + editEnabled = true, + deleteEnabled = true, + importEnabled = true, + exportEnabled = true, + + adminMenuIcon = "sports_esports", + adminMenuPriority = 1, + searchSettings = calculateSearchSettings(true) +){ + override fun onEntityDeleted(entity: TournamentEntity) { + tournamentService.deleteStagesForTournament(entity) + super.onEntityDeleted(entity) + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentDetailedView.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentDetailedView.kt new file mode 100644 index 00000000..9f07eae6 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentDetailedView.kt @@ -0,0 +1,49 @@ +package hu.bme.sch.cmsch.component.tournament + +data class TournamentWithParticipants( + val id: Int, + val title: String, + val description: String, + val location: String, + val joinEnabled: Boolean, + val isJoined: Boolean, + val participantCount: Int, + val participants: List, + val status: Int, +) + +data class MatchDto( + val id: Int, + val gameId: Int, + val kickoffTime: Long?, + val level: Int, + val location: String, + val homeSeed: Int, + val awaySeed: Int, + val home: ParticipantDto?, + val away: ParticipantDto?, + val homeScore: Int?, + val awayScore: Int?, + val status: MatchStatus +) + +data class KnockoutStageDetailedView( + val id: Int, + val name: String, + val level: Int, + val participantCount: Int, + val nextRound: Int, + val status: StageStatus, + val matches: List, +) + + +data class TournamentDetailedView( + val tournament: TournamentWithParticipants, + val stages: List, +) + +data class OptionalTournamentView ( + val visible: Boolean, + val tournament: TournamentDetailedView? +) \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentEntity.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentEntity.kt new file mode 100644 index 00000000..089ff776 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentEntity.kt @@ -0,0 +1,115 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.annotation.JsonView +import hu.bme.sch.cmsch.admin.* +import hu.bme.sch.cmsch.component.EntityConfig +import hu.bme.sch.cmsch.config.OwnershipType +import hu.bme.sch.cmsch.dto.Edit +import hu.bme.sch.cmsch.dto.FullDetails +import hu.bme.sch.cmsch.dto.Preview +import hu.bme.sch.cmsch.model.ManagedEntity +import hu.bme.sch.cmsch.service.StaffPermissions +import jakarta.persistence.* +import org.hibernate.Hibernate +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.core.env.Environment + +@Entity +@Table(name="tournament") +@ConditionalOnBean(TournamentComponent::class) +data class TournamentEntity( + + @Id + @GeneratedValue + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(renderer = OverviewType.NUMBER, columnName = "ID", order = -1) + override var id: Int = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(maxLength = 64, order = 1, label = "Verseny neve") + @property:GenerateOverview(columnName = "Név", order = 1) + @property:ImportFormat + var title: String = "", + + @Column(nullable = false, columnDefinition = "TEXT") + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(maxLength = 64, order = 3, label = "Verseny leírása") + @property:GenerateOverview(columnName = "Leírás", order = 2) + @property:ImportFormat + var description: String = "", + + @Column(nullable = true) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(maxLength = 64, order = 4, label = "Verseny helyszíne") + @property:GenerateOverview(columnName = "Helyszín", order = 3) + @property:ImportFormat + var location: String = "", + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.SWITCH, order = 5, label = "Lehet-e jelentkezni") + @property:GenerateOverview(columnName = "Joinable", order = 4) + @property:ImportFormat + var joinable: Boolean = false, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class ]) + @property:GenerateInput(type = InputType.SWITCH, order = 6, label = "Látható") + @property:GenerateOverview(columnName = "Visible", order = 5) + @property:ImportFormat + var visible: Boolean = false, + + @Column(nullable = false) + @field:JsonView(value = [ Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(columnName = "Résztvevők száma", order = 6) + @property:ImportFormat + var participantCount: Int = 0, + + @Column(nullable = false, columnDefinition = "TEXT") + @field:JsonView(value = [ FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(visible = false) + @property:ImportFormat + var participants: String = "", + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(visible = false) + @property:ImportFormat + var status: Int = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 7, label = "Résztvevő típus", + source = ["GROUP", "USER"]) + @property:GenerateOverview(columnName = "Résztvevő típus", order = 7) + @property:ImportFormat + var participantType: OwnershipType = OwnershipType.GROUP, + +): ManagedEntity { + + override fun getEntityConfig(env: Environment) = EntityConfig( + name = "Tournament", + view = "control/tournament", + showPermission = StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false + other as TournamentEntity + + return id != 0 && id == other.id + } + + override fun hashCode(): Int = javaClass.hashCode() + + override fun toString(): String { + return this::class.simpleName + "(id = $id, name = '$title')" + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentJoinDto.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentJoinDto.kt new file mode 100644 index 00000000..119f0f60 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentJoinDto.kt @@ -0,0 +1,5 @@ +package hu.bme.sch.cmsch.component.tournament + +data class TournamentJoinDto ( + var id: Int = 0 +) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentJoinStatus.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentJoinStatus.kt new file mode 100644 index 00000000..8973c292 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentJoinStatus.kt @@ -0,0 +1,11 @@ +package hu.bme.sch.cmsch.component.tournament + +enum class TournamentJoinStatus { + OK, + JOINING_DISABLED, + ALREADY_JOINED, + TOURNAMENT_NOT_FOUND, + NOT_JOINABLE, + INSUFFICIENT_PERMISSIONS, + ERROR +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchController.kt new file mode 100644 index 00000000..e4a561df --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchController.kt @@ -0,0 +1,257 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.databind.ObjectMapper +import hu.bme.sch.cmsch.controller.admin.ControlAction +import hu.bme.sch.cmsch.controller.admin.TwoDeepEntityPage +import hu.bme.sch.cmsch.repository.ManualRepository +import hu.bme.sch.cmsch.service.* +import hu.bme.sch.cmsch.util.getUser +import hu.bme.sch.cmsch.util.transaction +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.core.env.Environment +import org.springframework.security.core.Authentication +import org.springframework.stereotype.Controller +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.* +import kotlin.jvm.optionals.getOrNull + +@Controller +@RequestMapping("/admin/control/tournament-match") +@ConditionalOnBean(TournamentComponent::class) +class TournamentMatchController( + private val matchRepository: TournamentMatchRepository, + private val tournamentService: TournamentService, + private val stageService: TournamentStageService, + importService: ImportService, + adminMenuService: AdminMenuService, + component: TournamentComponent, + auditLog: AuditLogService, + objectMapper: ObjectMapper, + transactionManager: PlatformTransactionManager, + storageService: StorageService, + env: Environment, + private val tournamentStageRepository: TournamentStageRepository +) : TwoDeepEntityPage( + "tournament-match", + MatchGroupDto::class, + TournamentMatchEntity::class, ::TournamentMatchEntity, + "Mérkőzés", "Mérkőzések", + "A mérkőzések kezelése.", + transactionManager, + object : ManualRepository() { + override fun findAll(): Iterable { + return tournamentService.getAggregatedMatchesByTournamentId() + } + }, + matchRepository, + importService, + adminMenuService, + storageService, + component, + auditLog, + objectMapper, + env, + + showPermission = StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + createPermission = StaffPermissions.PERMISSION_CREATE_TOURNAMENTS, + editPermission = StaffPermissions.PERMISSION_EDIT_TOURNAMENTS, + deletePermission = StaffPermissions.PERMISSION_DELETE_TOURNAMENTS, + + createEnabled = true, + editEnabled = true, + deleteEnabled = true, + importEnabled = false, + exportEnabled = false, + + adminMenuIcon = "compare_arrows", + + outerControlActions = mutableListOf( + ControlAction( + "Match admin", + "admin/{id}", + "thumbs_up_down", + StaffPermissions.PERMISSION_EDIT_RESULTS, + 200, + false, + "Mérkőzések eredményeinek felvitele" + ) + ), + innerControlActions = mutableListOf( + ControlAction( + "Match show", + "show-match/{id}", + "grade", + StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + 200, + false, + "Mérkőzés megtekintése" + ) + ), +) { + override fun fetchSublist(id: Int): Iterable { + return stageService.getMatchesByStageTournamentId(id) + } + + /*private val matchAdminControlActions = mutableListOf( + ControlAction( + "Eredmény felvitele", + "score/{id}", + "grade", + StaffPermissions.PERMISSION_EDIT_RESULTS, + 200, + false, + "Mérkőzés eredményének felvitele" + ) + )*/ + + + @GetMapping("/show-match/{id}") + fun showPage( + @PathVariable id: Int, + model: Model, + auth: Authentication + ): String { + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if(StaffPermissions.PERMISSION_SHOW_TOURNAMENTS.validate(user).not()){ + model.addAttribute("permission", viewPermission.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "GET /$view/show/$id", viewPermission.permissionString) + return "admin403" + } + + model.addAttribute("title", titlePlural) + model.addAttribute("titleSingular", titleSingular) + model.addAttribute("view", view) + + model.addAttribute("user", user) + val match = transactionManager.transaction(readOnly = true) { + matchRepository.findById(id).getOrNull() ?: return "redirect:/admin/control/tournament-match/" + } + val stage = stageService.findById(match.stageId) + if (stage == null) { + model.addAttribute("error", "A mérkőzés nem tartozik érvényes szakaszhoz.") + return "error" + } + model.addAttribute("match", match) + model.addAttribute("stage", stage) + model.addAttribute("readOnly", true) + + return "matchScore" + } + + + @GetMapping("/admin/{id}") + fun matchAdminPage(@PathVariable id: Int, model: Model, auth: Authentication): String { + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if(StaffPermissions.PERMISSION_EDIT_RESULTS.validate(user).not()){ + model.addAttribute("permission", viewPermission.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "GET /$view/admin/$id", viewPermission.permissionString) + return "admin403" + } + + model.addAttribute("title", titlePlural) + model.addAttribute("titleSingular", titleSingular) + model.addAttribute("view", view) + + val tournament = transactionManager.transaction(readOnly = true) { + tournamentService.findById(id)?: return "redirect:/admin/control/tournament-match/" + } + val matches = transactionManager.transaction(readOnly = true) { + //stageService.getUpcomingMatchesByTournamentId(id) + stageService.getMatchesByStageTournamentId(id) + } + + model.addAttribute("tournament", tournament) + model.addAttribute("matches", matches) + model.addAttribute("user", user) + + return "matchAdmin" + } + + @GetMapping("/score/{id}") + fun scoreMatchPage(@PathVariable id: Int, model: Model, auth: Authentication): String { + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if(StaffPermissions.PERMISSION_EDIT_RESULTS.validate(user).not()){ + model.addAttribute("permission", viewPermission.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "GET /$view/score/$id", viewPermission.permissionString) + return "admin403" + } + + model.addAttribute("title", titlePlural) + model.addAttribute("titleSingular", titleSingular) + model.addAttribute("view", view) + model.addAttribute("user", user) + + val match = transactionManager.transaction(readOnly = true) { + matchRepository.findById(id).getOrNull()?: return "redirect:/admin/control/tournament-match/" + } + val stage = stageService.findById(match.stageId) + if (stage == null) { + model.addAttribute("error", "A mérkőzés nem tartozik érvényes szakaszhoz.") + return "error" + } + model.addAttribute("match", match) + model.addAttribute("stage", stage) + model.addAttribute("readOnly", false) + + return "matchScore" + } + + @PostMapping("/score/{id}") + fun scoreMatch( + @PathVariable id: Int, + @RequestParam allRequestParams: Map, + model: Model, + auth: Authentication + ): String { + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if(StaffPermissions.PERMISSION_EDIT_RESULTS.validate(user).not()){ + model.addAttribute("permission", viewPermission.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "POST /$view/score/$id", viewPermission.permissionString) + return "admin403" + } + val match = matchRepository.findById(id).getOrNull()?: return "redirect:/admin/control/$view" + val stage = stageService.findById(match.stageId) ?: return "redirect:/admin/control/$view" + + if (match.status == MatchStatus.FINISHED || match.status == MatchStatus.CANCELLED) { + model.addAttribute("error", "A mérkőzés már befejeződött vagy elmaradt, nem lehet új eredményt rögzíteni.") + return "error" + } + + match.homeTeamScore = allRequestParams["homeTeamScore"]?.toIntOrNull() + match.awayTeamScore = allRequestParams["awayTeamScore"]?.toIntOrNull() + match.status = when(allRequestParams["matchStatus"]){ + "FINISHED" -> MatchStatus.FINISHED + "IN_PROGRESS" -> MatchStatus.IN_PROGRESS + else -> MatchStatus.NOT_STARTED + } + + if (onEntityPreSave(match, auth)){ + transactionManager.transaction(isolation = TransactionDefinition.ISOLATION_READ_COMMITTED, propagation = TransactionDefinition.PROPAGATION_REQUIRES_NEW) { + matchRepository.save(match) + if (match.status == MatchStatus.FINISHED) { + // If the match is finished, we need to update the stage and tournament + val winner = match.winner() + if (winner != null) { + val updatedSeeds = stageService.setSeeds(stage) + stage.seeds = updatedSeeds + stageService.calculateTeamsFromSeeds(stage) + tournamentStageRepository.save(stage) + } + } + } + } + + return "redirect:/admin/control/tournament-match" + } + +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchEntity.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchEntity.kt new file mode 100644 index 00000000..c748d80f --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchEntity.kt @@ -0,0 +1,163 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.annotation.JsonView +import hu.bme.sch.cmsch.admin.* +import hu.bme.sch.cmsch.component.EntityConfig +import hu.bme.sch.cmsch.dto.* +import hu.bme.sch.cmsch.model.ManagedEntity +import hu.bme.sch.cmsch.service.StaffPermissions +import jakarta.persistence.* +import org.hibernate.Hibernate +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.core.env.Environment + +enum class MatchStatus { + NOT_STARTED, + FINISHED, + IN_PROGRESS, + CANCELLED, + BYE +} + +@Entity +@Table(name = "tournament_match") +@ConditionalOnBean(TournamentComponent::class) +data class TournamentMatchEntity( + + @Id + @GeneratedValue + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(renderer = OverviewType.ID, columnName = "ID") + override var id: Int = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.NUMBER, visible = true, ignore = true) + @property:GenerateOverview(renderer = OverviewType.ID, columnName = "Game ID") + @property:ImportFormat + var gameId: Int = 0, + + @Column(nullable = false) + @property:GenerateInput(type = InputType.NUMBER, min = 1, order = 1, label = "Stage ID") + @property:GenerateOverview(columnName = "Stage ID", order = 1) + @property:ImportFormat + var stageId: Int = 0, + + @Column(nullable = false) + @property:GenerateInput(type = InputType.NUMBER, min = 1, order = 1, label = "Level") + @property:GenerateOverview(columnName = "Level", order = 1) + @property:ImportFormat + var level: Int = 0, + + @Column(nullable = false) + @property:GenerateInput(type = InputType.NUMBER, min = Int.MIN_VALUE, order = 2, label = "Home seed") + var homeSeed: Int = 0, + + @Column(nullable = true) + var homeTeamId: Int? = null, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.TEXT, order = 2, label = "Home team name") + @property:GenerateOverview(columnName = "Home team name", order = 2) + @property:ImportFormat + var homeTeamName: String = "", + + @Column(nullable = false) + @property:GenerateInput(type = InputType.NUMBER, min = Int.MIN_VALUE, order = 3, label = "Away seed") + var awaySeed: Int = 0, + + @Column(nullable = true) + var awayTeamId: Int? = null, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.TEXT, min = 1, order = 3, label = "Away team name") + @property:GenerateOverview(columnName = "Away team name", order = 3) + @property:ImportFormat + var awayTeamName: String = "", + + @Column(nullable = true) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.DATE, order = 4, label = "Kickoff time") + @property:GenerateOverview(columnName = "Kickoff time", order = 4) + @property:ImportFormat + var kickoffTime: Long = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.TEXT, order = 5, label = "Location") + @property:GenerateOverview(columnName = "Location", order = 5) + @property:ImportFormat + var location: String = "", + + @Column(nullable = true) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateOverview(columnName = "Home team score", order = 6) + @property:ImportFormat + var homeTeamScore: Int? = null, + + @Column(nullable = true) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateOverview(columnName = "Away team score", order = 7) + @property:ImportFormat + var awayTeamScore: Int? = null, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.BLOCK_SELECT, order = 8, label = "Match status", + source = [ "NOT_STARTED", "FINISHED", "IN_PROGRESS", "CANCELLED" ], + visible = false, ignore = true + ) + @property:GenerateOverview(columnName = "Match status", order = 8) + @property:ImportFormat + var status: MatchStatus = MatchStatus.NOT_STARTED, + +): ManagedEntity{ + + override fun getEntityConfig(env: Environment) = EntityConfig( + name = "TournamentMatch", + view = "control/tournament/match", + showPermission = StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false + other as TournamentMatchEntity + + return id != 0 && id == other.id + } + + override fun toString(): String { + return javaClass.simpleName + "(id = $id)" + } + + override fun hashCode(): Int = javaClass.hashCode() + + fun winner(): ParticipantDto? { + return when { + homeSeed == null || awaySeed == null -> null // No teams set + homeTeamId == null || awayTeamId == null -> null // No teams set + homeTeamId == 0 -> ParticipantDto(awayTeamId!!, awayTeamName) + awayTeamId == 0 -> ParticipantDto(homeTeamId!!, homeTeamName) + status != MatchStatus.FINISHED -> null + homeTeamScore == null || awayTeamScore == null -> null + homeTeamScore!! > awayTeamScore!! -> ParticipantDto(homeTeamId ?: 0, homeTeamName) + awayTeamScore!! > homeTeamScore!! -> ParticipantDto(awayTeamId ?: 0, awayTeamName) + else -> null // Draw or no winner + } + } + + fun isDraw(): Boolean { + return when { + status in listOf(MatchStatus.NOT_STARTED, MatchStatus.IN_PROGRESS, MatchStatus.CANCELLED, MatchStatus.BYE) -> false + homeTeamScore == null || awayTeamScore == null -> false + homeTeamScore!! == awayTeamScore!! -> true + else -> false + } + } + +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchRepository.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchRepository.kt new file mode 100644 index 00000000..70ff4543 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchRepository.kt @@ -0,0 +1,36 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.repository.EntityPageDataSource +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository +import java.util.* + +data class MatchCountDto( + var stageId: Int = 0, + var matchCount: Long = 0 +) + +@Repository +@ConditionalOnBean(TournamentComponent::class) +interface TournamentMatchRepository : CrudRepository, + EntityPageDataSource { + + override fun findAll(): List + override fun findById(id: Int): Optional + @Query("select t from TournamentMatchEntity t where t.stageId = ?1") + fun findAllByStageId(stageId: Int): List + + @Query(""" + SELECT NEW hu.bme.sch.cmsch.component.tournament.MatchCountDto( + t.stageId, + COUNT(t.id) + ) + FROM TournamentMatchEntity t + GROUP BY t.stageId + """) + fun findAllAggregated(): List + + fun deleteAllByStageId(stageId: Int): Int +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentPreviewView.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentPreviewView.kt new file mode 100644 index 00000000..5b91bfb7 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentPreviewView.kt @@ -0,0 +1,9 @@ +package hu.bme.sch.cmsch.component.tournament + +data class TournamentPreviewView( + val id: Int, + val title: String, + val description: String, + val location: String, + val status: Int, +) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentRepository.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentRepository.kt new file mode 100644 index 00000000..ad80ea0e --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentRepository.kt @@ -0,0 +1,15 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.repository.EntityPageDataSource +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository + +@Repository +@ConditionalOnBean(TournamentComponent::class) +interface TournamentRepository : CrudRepository, + EntityPageDataSource { + + override fun findAll(): List + +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentService.kt new file mode 100644 index 00000000..ac865ca3 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentService.kt @@ -0,0 +1,204 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.databind.ObjectMapper +import hu.bme.sch.cmsch.component.login.CmschUser +import hu.bme.sch.cmsch.config.OwnershipType +import hu.bme.sch.cmsch.model.RoleType +import hu.bme.sch.cmsch.repository.GroupRepository +import hu.bme.sch.cmsch.util.isAvailableForRole +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.retry.annotation.Backoff +import org.springframework.retry.annotation.Retryable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Isolation +import org.springframework.transaction.annotation.Transactional +import java.sql.SQLException +import kotlin.jvm.optionals.getOrNull + +@Service +@ConditionalOnBean(TournamentComponent::class) +class TournamentService( + private val tournamentRepository: TournamentRepository, + private val stageRepository: TournamentStageRepository, + private val groupRepository: GroupRepository, + private val tournamentComponent: TournamentComponent, + private val objectMapper: ObjectMapper, + private val matchRepository: TournamentMatchRepository +){ + @Transactional(readOnly = true) + fun findAll(): List { + return tournamentRepository.findAll() + } + + @Transactional(readOnly = true) + fun findById(tournamentId: Int): TournamentEntity? { + return tournamentRepository.findById(tournamentId).getOrNull() + } + + + @Transactional(readOnly = true) + fun listAllTournaments(): List { + if (!tournamentComponent.showTournamentsAtAll) { + return listOf() + } + + val tournaments = tournamentRepository.findAll() + .filter { it.visible }.map { + TournamentPreviewView( + it.id, + it.title, + it.description, + it.location, + it.status + ) + } + return tournaments + } + + + @Transactional(readOnly = false) + fun showTournament(tournamentId: Int, user: CmschUser?): OptionalTournamentView? { + val tournament = tournamentRepository.findById(tournamentId).getOrNull() + ?: return null + + return if (tournament.visible && tournamentComponent.minRole.isAvailableForRole(user?.role ?: RoleType.GUEST)){ + OptionalTournamentView(true, mapTournament(tournament, user)) + } else { + OptionalTournamentView(false, null) + } + } + + private fun mapTournament(tournament: TournamentEntity, user: CmschUser?): TournamentDetailedView { + val participants = if (tournament.participants != "") tournament.participants.split("\n").map { objectMapper.readValue(it, ParticipantDto::class.java) } else listOf() + + val playerId = when (tournament.participantType) { + OwnershipType.GROUP -> user?.groupId + OwnershipType.USER -> user?.id + } + val isJoined = participants.any { it.teamId == playerId } + val joinEnabled = tournament.joinable && !isJoined && + ((user?.role ?: RoleType.GUEST) >= RoleType.PRIVILEGED || + (tournament.participantType == OwnershipType.USER && (user?.role ?: RoleType.GUEST) >= RoleType.BASIC)) + + val stages = stageRepository.findAllByTournamentId(tournament.id) + .sortedBy { it.level } + + return TournamentDetailedView( + TournamentWithParticipants( + tournament.id, + tournament.title, + tournament.description, + tournament.location, + joinEnabled, + isJoined, + tournament.participantCount, + getParticipants(tournament.id), + tournament.status + ), stages.map { KnockoutStageDetailedView( + it.id, + it.name, + it.level, + it.participantCount, + it.nextRound, + it.status, + matchRepository.findAllByStageId(it.id).map { MatchDto( + it.id, + it.gameId, + it.kickoffTime, + it.level, + it.location, + it.homeSeed, + it.awaySeed, + if(it.homeTeamId!=null) ParticipantDto(it.homeTeamId!!, it.homeTeamName) else null, + if(it.awayTeamId!=null) ParticipantDto(it.awayTeamId!!, it.awayTeamName) else null, + it.homeTeamScore, + it.awayTeamScore, + it.status + ) } + ) }) + } + + @Transactional(readOnly = true) + fun getParticipants(tournamentId: Int): List { + val tournament = tournamentRepository.findById(tournamentId) + if (tournament.isEmpty || tournament.get().participants.isEmpty()) { + return emptyList() + } + return tournament.get().participants.split("\n").map { objectMapper.readValue(it, ParticipantDto::class.java) } + } + + @Transactional(readOnly = true) + fun getResultsInStage(tournamentId: Int, stageId: Int): List { + val stage = stageRepository.findById(stageId) + if (stage.isEmpty || stage.get().tournamentId != tournamentId) { + return emptyList() + } + return stage.get().participants.split("\n").map { objectMapper.readValue(it, StageResultDto::class.java) } + } + + fun getAggregatedMatchesByTournamentId(): List { + val tournaments = findAll().associateBy { it.id } + val stages = stageRepository.findAll().associateBy { it.id } + val aggregatedByStageId = matchRepository.findAllAggregated() + val aggregated = mutableMapOf() + for(aggregatedStage in aggregatedByStageId) { + aggregated[stages[aggregatedStage.stageId]!!.tournamentId] = aggregated.getOrDefault(stages[aggregatedStage.stageId]!!.tournamentId, 0) + aggregatedStage.matchCount + } + return aggregated.map { + MatchGroupDto( + it.key, + tournaments[it.key]?.title ?:"", + tournaments[it.key]?.location ?:"", + it.value.toInt() + ) + }.sortedByDescending { it.matchCount } + } + + + @Retryable(value = [ SQLException::class ], maxAttempts = 5, backoff = Backoff(delay = 500L, multiplier = 1.5)) + @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) + fun teamRegister(tournament: TournamentEntity, user: CmschUser): TournamentJoinStatus { + val groupId = user.groupId + ?: return TournamentJoinStatus.INSUFFICIENT_PERMISSIONS + val team = groupRepository.findById(groupId).getOrNull() + ?: return TournamentJoinStatus.INSUFFICIENT_PERMISSIONS + + val participants = tournament.participants + val parsed = mutableListOf() + if(participants != "") parsed.addAll(participants.split("\n").map { objectMapper.readValue(it, ParticipantDto::class.java) }) + if (parsed.any { it.teamId == groupId }) { + return TournamentJoinStatus.ALREADY_JOINED + } + + parsed.add(ParticipantDto(groupId, team.name)) + + tournament.participants = parsed.joinToString("\n") { objectMapper.writeValueAsString(it) } + tournament.participantCount = parsed.size + + tournamentRepository.save(tournament) + return TournamentJoinStatus.OK + } + + @Retryable(value = [ SQLException::class ], maxAttempts = 5, backoff = Backoff(delay = 500L, multiplier = 1.5)) + @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) + fun userRegister(tournament: TournamentEntity, user: CmschUser): TournamentJoinStatus { + val participants = tournament.participants + val parsed = mutableListOf() + parsed.addAll(participants.split("\n").map { objectMapper.readValue(it, ParticipantDto::class.java) }) + if (parsed.any { it.teamId == user.id }) { + return TournamentJoinStatus.ALREADY_JOINED + } + parsed.add(ParticipantDto(user.id, user.userName)) + + tournament.participants = parsed.joinToString("\n") { objectMapper.writeValueAsString(it) } + tournament.participantCount = parsed.size + + tournamentRepository.save(tournament) + return TournamentJoinStatus.OK + } + + @Transactional + fun deleteStagesForTournament(tournament: TournamentEntity) { + stageRepository.deleteAllByTournamentId(tournament.id) + } +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageController.kt new file mode 100644 index 00000000..00f46330 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageController.kt @@ -0,0 +1,362 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.databind.ObjectMapper +import hu.bme.sch.cmsch.controller.admin.ButtonAction +import hu.bme.sch.cmsch.controller.admin.ControlAction +import hu.bme.sch.cmsch.controller.admin.TwoDeepEntityPage +import hu.bme.sch.cmsch.repository.ManualRepository +import hu.bme.sch.cmsch.service.* +import hu.bme.sch.cmsch.util.getUser +import hu.bme.sch.cmsch.util.transaction +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.core.env.Environment +import org.springframework.security.core.Authentication +import org.springframework.stereotype.Controller +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.TransactionDefinition +import org.springframework.ui.Model +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile +import kotlin.jvm.optionals.getOrNull + + +@Controller +@RequestMapping("/admin/control/tournament-stage") +@ConditionalOnBean(TournamentComponent::class) +class TournamentStageController( + private val stageRepository: TournamentStageRepository, + private val stageService: TournamentStageService, + private val tournamentRepository: TournamentRepository, + importService: ImportService, + adminMenuService: AdminMenuService, + component: TournamentComponent, + auditLog: AuditLogService, + objectMapper: ObjectMapper, + transactionManager: PlatformTransactionManager, + storageService: StorageService, + env: Environment +) : TwoDeepEntityPage( + "tournament-stage", + StageGroupDto::class, + TournamentStageEntity::class, ::TournamentStageEntity, + "Kiesési szakasz", "Kiesési szakaszok", + "A kiesési szakaszok kezelése.", + transactionManager, + object : ManualRepository() { + override fun findAll(): Iterable { + val stages = stageRepository.findAllAggregated().associateBy { it.tournamentId } + val tournaments = tournamentRepository.findAll() + return tournaments.map { + StageGroupDto( + it.id, + it.title, + it.location, + it.participantCount, + stages[it.id]?.stageCount?.toInt() ?: 0 + ) + }.sortedByDescending { it.stageCount } + } + }, + stageRepository, + importService, + adminMenuService, + storageService, + component, + auditLog, + objectMapper, + env, + + showPermission = StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + createPermission = StaffPermissions.PERMISSION_CREATE_TOURNAMENTS, + editPermission = StaffPermissions.PERMISSION_EDIT_TOURNAMENTS, + deletePermission = StaffPermissions.PERMISSION_DELETE_TOURNAMENTS, + + viewEnabled = false, + createEnabled = false, + editEnabled = true, + deleteEnabled = true, + importEnabled = false, + exportEnabled = false, + + adminMenuIcon = "lan", + + outerControlActions = mutableListOf( + ControlAction( + name = "Torna szakaszainak kezelése", + endpoint = "view-page/{id}", + icon = "double_arrow", + permission = StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + order = 100, + newPage = false, + usageString = "A kiválasztott torna szakaszainak kezelése" + ) + ), + + innerControlActions = mutableListOf( + ControlAction( + name = "Seedek kezelése", + endpoint = "seed/{id}", + icon = "sort_by_alpha", + permission = StaffPermissions.PERMISSION_SHOW_BRACKETS, + order = 200, + newPage = false, + usageString = "A kiesési szakasz seedjeinek kezelése" + ) + ) +) { + override fun fetchSublist(id: Int): Iterable { + return stageRepository.findAllByTournamentId(id) + } + + @GetMapping("/view-page/{id}") + fun viewPage(model: Model, auth: Authentication, @PathVariable id: Int): String { + val createKnockoutButtonAction = ButtonAction( + "Új kiesési szakasz a tornához", + "create-knockout/$id", + createPermission, + 99, + "add_box", + true + ) + val createGroupButtonAction = ButtonAction( + "Új csoport a tornához", + "create-group/$id", + createPermission, + 100, + "add_box", + true + ) + val newButtonActions = mutableListOf() + for (buttonAction in buttonActions) + newButtonActions.add(buttonAction) + newButtonActions.add(createKnockoutButtonAction) + newButtonActions.add(createGroupButtonAction) + + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if (viewPermission.validate(user).not()) { + model.addAttribute("permission", viewPermission.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "GET /$view/view/$id", viewPermission.permissionString) + return "admin403" + } + + model.addAttribute("title", titlePlural) + model.addAttribute("titleSingular", titleSingular) + model.addAttribute("description", description) + model.addAttribute("view", view) + + model.addAttribute("columnData", descriptor.getColumns()) + val overview = transactionManager.transaction(readOnly = true) { filterOverview(user, fetchSublist(id)) } + model.addAttribute("tableData", descriptor.getTableData(overview)) + + model.addAttribute("user", user) + model.addAttribute("controlActions", controlActions.filter { it.permission.validate(user) }) + model.addAttribute("allControlActions", controlActions) + model.addAttribute("buttonActions", newButtonActions.filter { it.permission.validate(user) }) + + attachPermissionInfo(model) + + return "overview4" + } + + @GetMapping(value = ["/create-knockout/{tournamentId}", "/create-group/{tournamentId}"]) + fun createStagePage(model: Model, auth: Authentication, @PathVariable tournamentId: Int): String { + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if (editPermission.validate(user).not()) { + model.addAttribute("permission", editPermission.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "GET /$view/create/$tournamentId", showPermission.permissionString) + return "admin403" + } + + if (!editEnabled) + return "redirect:/admin/control/$view/" + + val entity = TournamentStageEntity(tournamentId = tournamentId) + + val actualEntity = onPreEdit(entity) + model.addAttribute("data", actualEntity) + if (!editPermissionCheck(user, actualEntity, null)) { + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "GET /$view/create/$tournamentId", + "editPermissionCheck() validation") + return "admin403" + } + + model.addAttribute("title", titleSingular) + model.addAttribute("editMode", false) + model.addAttribute("duplicateMode", false) + model.addAttribute("view", view) + model.addAttribute("inputs", descriptor.getInputs()) + model.addAttribute("mappings", entitySourceMapping) + model.addAttribute("user", user) + model.addAttribute("readOnly", false) + model.addAttribute("entityMode", false) + + onDetailsView(user, model) + return "details" + } + + @Override + @PostMapping("/create", headers = ["Referer"]) + fun create(@ModelAttribute(binding = false) dto: TournamentStageEntity, + @RequestParam(required = false) file0: MultipartFile?, + @RequestParam(required = false) file1: MultipartFile?, + model: Model, + auth: Authentication, + @RequestHeader("Referer") referer: String, + ): String { + val toCreate = referer.substringAfterLast("/create-").split("/") + val stageType = toCreate.getOrNull(0) + ?.let { when (it) { + "knockout" -> StageType.KNOCKOUT + "group" -> StageType.GROUP_STAGE + else -> return "redirect:/admin/control/$view" + }} ?: return "redirect:/admin/control/$view" + val tournamentId = toCreate.getOrNull(1)?.toIntOrNull() + ?: return "redirect:/admin/control/$view" +// val tournamentId = referer.substringAfterLast("/create/").toIntOrNull() +// ?: return "redirect:/admin/control/$view" + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if (createPermission.validate(user).not()) { + model.addAttribute("permission", createPermission.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "POST /$view/create", createPermission.permissionString) + return "admin403" + } + + if (!editPermissionCheck(user, null, dto)) { + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "POST /$view/create", "editPermissionCheck() validation") + return "admin403" + } + + dto.tournamentId = tournamentId + dto.type = stageType + val newValues = StringBuilder("entity new value: ") + updateEntity(descriptor, user, dto, dto, newValues, false, file0, false, file1) + dto.id = 0 + if (onEntityPreSave(dto, auth)) { + auditLog.create(user, component.component, newValues.toString()) + transactionManager.transaction(readOnly = false, isolation = TransactionDefinition.ISOLATION_READ_COMMITTED) { + dataSource.save(dto) + } + } + onEntityChanged(dto) + return "redirect:/admin/control/$view" + } + + + @GetMapping("/seed/{id}") + fun seedPage(model: Model, auth: Authentication, @PathVariable id: Int): String { + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if(!StaffPermissions.PERMISSION_SHOW_BRACKETS.validate(user) ) { + model.addAttribute("permission", StaffPermissions.PERMISSION_GENERATE_BRACKETS.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "GET /$view/seed/$id", StaffPermissions.PERMISSION_GENERATE_BRACKETS.permissionString) + return "admin403" + } + val stage = stageRepository.findById(id).getOrNull() + ?: return "redirect:/admin/control/$view" + val readOnly = !StaffPermissions.PERMISSION_SET_SEEDS.validate(user) || stage.status >= StageStatus.SET + val teams = stageService.getParticipants(id).sortedBy { it.initialSeed } + val tournament = tournamentRepository.findById(stage.tournamentId).getOrNull() + ?: return "redirect:/admin/control/$view" + model.addAttribute("title", "Kiesési szakasz seedek") + model.addAttribute("view", view) + model.addAttribute("readOnly", readOnly) + model.addAttribute("entityMode", false) + model.addAttribute("tournamentTitle", tournament.title) + model.addAttribute("stage", stage) + model.addAttribute("teams", teams) + + return "seedSettings" + } + + + @PostMapping("/seed/{id}") + fun editSeed( + @PathVariable id: Int, + @RequestParam allRequestParams: Map, + model: Model, + auth: Authentication + ): String { + val user = auth.getUser() + adminMenuService.addPartsForMenu(user, model) + if(!StaffPermissions.PERMISSION_SET_SEEDS.validate(user) ) { + model.addAttribute("permission", StaffPermissions.PERMISSION_SET_SEEDS.permissionString) + model.addAttribute("user", user) + auditLog.admin403(user, component.component, "POST /$view/seed/$id", StaffPermissions.PERMISSION_SET_SEEDS.permissionString) + return "admin403" + } + val stage = stageRepository.findById(id).getOrNull() + ?: return "redirect:/admin/control/$view" + if (stage.status >= StageStatus.SET) { + model.addAttribute("error", "A szakasz már be lett állítva, nem lehet módosítani a seedeket.") + return "redirect:/admin/control/$view/seed/${id}" + } + + val dto = mutableListOf() + var i = 0 + while (allRequestParams["id_$i"] != null && allRequestParams["order_$i"] != null) { + val teamId = allRequestParams["id_$i"]!!.toInt() + val teamName = allRequestParams["name_$i"] ?: "" + val initialSeed = allRequestParams["order_$i"]?.toIntOrNull()?: 0 + val highlighted = allRequestParams["highlighted_$i"] != null && allRequestParams["highlighted_$i"] != "off" + dto.add(StageResultDto( + teamId, teamName, + stage.id, + highlighted = highlighted, + initialSeed = initialSeed + 1 // Adjusting seed to be positive + )) + i++ + } + val stageStatus: StageStatus = allRequestParams["stageStatus"]!!.let { when (it) { + "CREATED" -> StageStatus.CREATED + "SET" -> StageStatus.SET + else -> return "error" + }} + + val updatedSeeds = dto.map { it.initialSeed }.toSet() + if (updatedSeeds.size != dto.size) { + model.addAttribute("error", "Minden seednek egyedinek kell lennie.") + return "redirect:/admin/control/$view/seed/${id}" + } + var stageEntity = stage + if (onEntityPreSave(stageEntity, auth)) { + transactionManager.transaction(readOnly = false, isolation = TransactionDefinition.ISOLATION_READ_COMMITTED) { + stageEntity = stageService.setInitialSeeds(stageEntity, dto, user) + stageEntity.seeds = stageService.setSeeds(stageEntity) + stageEntity.status = stageStatus + stageRepository.save(stageEntity) + if (stageStatus == StageStatus.SET) + stageService.onSeedsFinalized(stageEntity) + stageService.calculateTeamsFromSeeds(stageEntity) + } + auditLog.edit(user, component.component+"seed", dto.toString()) + } else { + model.addAttribute("error", "A szakasz nem lett frissítve.") + } + //onEntityChanged(stageEntity) + return "redirect:/admin/control/$view/seed/${id}" + } + + override fun onEntityChanged(entity: TournamentStageEntity) { + if (entity.type == StageType.KNOCKOUT) { + entity.participants = stageService.transferTeamsForStage(entity) + stageService.createMatchesForStage(entity) + entity.seeds = stageService.setSeeds(entity) + stageService.calculateTeamsFromSeeds(entity) + } + super.onEntityChanged(entity) + } + + override fun onEntityDeleted(entity: TournamentStageEntity) { + stageService.deleteMatchesForStage(entity) + super.onEntityDeleted(entity) + } +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageEntity.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageEntity.kt new file mode 100644 index 00000000..8e3ede8c --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageEntity.kt @@ -0,0 +1,131 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.annotation.JsonView +import hu.bme.sch.cmsch.admin.* +import hu.bme.sch.cmsch.component.EntityConfig +import hu.bme.sch.cmsch.dto.Edit +import hu.bme.sch.cmsch.dto.FullDetails +import hu.bme.sch.cmsch.dto.Preview +import hu.bme.sch.cmsch.model.ManagedEntity +import hu.bme.sch.cmsch.service.StaffPermissions +import jakarta.persistence.* +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.core.env.Environment +import kotlin.math.ceil +import kotlin.math.log2 + + +enum class StageStatus { + CREATED, + SET, + ONGOING, + FINISHED, + CANCELLED +} + + +@Entity +@Table(name = "tournament_stage") +@ConditionalOnBean(TournamentComponent::class) +data class TournamentStageEntity( + + @Id + @GeneratedValue + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(renderer = OverviewType.ID, columnName = "ID") + override var id: Int = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(maxLength = 64, order = 1, label = "Szakasz neve") + @property:GenerateOverview(columnName = "Név", order = 1) + @property:ImportFormat + var name: String = "", + + @Column(nullable = false) + @property:GenerateInput(type = InputType.HIDDEN, min = 1, order = 2, label = "Verseny ID") + @property:GenerateOverview(columnName = "Verseny ID", order = 2, centered = true) + @property:ImportFormat + var tournamentId: Int = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(columnName = "Típus", order = 3, centered = true) + @property:ImportFormat + var type: StageType = StageType.KNOCKOUT, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.NUMBER, min = 1, order = 3, label = "Szint") + @property:GenerateOverview(columnName = "Szint", order = 4, centered = true) + @property:ImportFormat + var level: Int = 1, //ie. Csoportkör-1, Csoportkör-2, Kieséses szakasz-3 + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class ]) + @property:GenerateInput(type = InputType.NUMBER, min = 1, order = 3, label = "Résztvevők száma", note = "Legfeljebb annyi csapat, mint a versenyen résztvevők száma") + @property:GenerateOverview(columnName = "RésztvevőSzám", order = 5, centered = true) + @property:ImportFormat + var participantCount: Int = 1, + + @Column(nullable = false, columnDefinition = "TEXT") + @field:JsonView(value = [ FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = true, ignore = true) + @property:GenerateOverview(visible = false) + @property:ImportFormat + var participants: String = "", + + @Column(nullable = false, columnDefinition = "TEXT") + @field:JsonView(value = [ FullDetails::class ]) + @property:GenerateInput(type = InputType.HIDDEN, visible = false, ignore = true) + @property:GenerateOverview(visible = false) + @property:ImportFormat + var seeds: String = "", + + + @Column(nullable = false) + @field:JsonView(value = [ Preview::class, FullDetails::class ]) + @property:GenerateOverview(columnName = "Következő kör", order = 6, centered = true) + @property:ImportFormat + var nextRound: Int = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Preview::class, FullDetails::class ]) + @property:GenerateOverview(columnName = "Status", order = 7, centered = true) + @property:ImportFormat + var status: StageStatus = StageStatus.CREATED, + +): ManagedEntity { + + fun rounds() = ceil(log2(participantCount.toDouble())).toInt() + + override fun getEntityConfig(env: Environment) = EntityConfig( + name = "KnockoutStage", + view = "control/tournament/knockout-stage", + showPermission = StaffPermissions.PERMISSION_SHOW_TOURNAMENTS, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TournamentStageEntity) return false + + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int = javaClass.hashCode() + + override fun toString(): String { + return this::class.simpleName + "(id = $id, name = $name, tournamentId = $tournamentId, participantCount = $participantCount)" + } + +} + +enum class StageType { + KNOCKOUT, + GROUP_STAGE, +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageRepository.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageRepository.kt new file mode 100644 index 00000000..8db8a05f --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageRepository.kt @@ -0,0 +1,41 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.repository.EntityPageDataSource +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository +import org.springframework.stereotype.Repository +import java.util.* + +data class StageCountDto( + var tournamentId: Int = 0, + var stageCount: Long = 0 +) + +@Repository +@ConditionalOnBean(TournamentComponent::class) +interface TournamentStageRepository : CrudRepository, + EntityPageDataSource { + + override fun findAll(): List + override fun findById(id: Int): Optional + fun findAllByTournamentId(tournamentId: Int): List + + @Query( + """ + SELECT NEW hu.bme.sch.cmsch.component.tournament.StageCountDto( + s.tournamentId, + COUNT(s.id) + ) + FROM TournamentStageEntity s + GROUP BY s.tournamentId + """ + ) + fun findAllAggregated(): List + + + fun findByTournamentIdAndLevel(tournamentId: Int, level: Int): Optional + + fun deleteAllByTournamentId(tournamentId: Int): Int + +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageService.kt new file mode 100644 index 00000000..941ac42b --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageService.kt @@ -0,0 +1,245 @@ +package hu.bme.sch.cmsch.component.tournament + +import com.fasterxml.jackson.databind.ObjectMapper +import hu.bme.sch.cmsch.component.login.CmschUser +import hu.bme.sch.cmsch.service.StaffPermissions +import hu.bme.sch.cmsch.service.TimeService +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import kotlin.collections.map +import kotlin.jvm.optionals.getOrNull +import kotlin.math.pow + +@Service +@ConditionalOnBean(TournamentComponent::class) +class TournamentStageService( + private val stageRepository: TournamentStageRepository, + private val tournamentService: TournamentService, + private val clock: TimeService, + private val matchRepository: TournamentMatchRepository, + val objectMapper: ObjectMapper, + private val tournamentComponent: TournamentComponent +) { + + fun findById(id: Int): TournamentStageEntity? { + return stageRepository.findById(id).getOrNull() + } + + fun getParticipants(stageId: Int): List { + val stage = findById(stageId) + if (stage == null || stage.participants.isEmpty()) { + return emptyList() + } + return stage.participants.split("\n").map { objectMapper.readValue(it, StageResultDto::class.java) } + } + + fun getResultsForStage(stage: TournamentStageEntity): List { + if (stage.level <= 1) { + return tournamentService.getParticipants(stage.tournamentId) + .mapIndexed { index, participant -> + StageResultDto( + participant.teamId, + participant.teamName, + stage.id, + initialSeed = index + 1, + detailedStats = null + ) + } + } + val prevStage = stageRepository.findByTournamentIdAndLevel(stage.tournamentId, stage.level - 1).getOrNull() + ?: return emptyList() + if (prevStage.participants == "") { + return emptyList() + } + return prevStage.participants.split("\n").map { objectMapper.readValue(it, StageResultDto::class.java) } + } + + fun transferTeamsForStage(stage: TournamentStageEntity): String { + val teamSeeds = (1..stage.participantCount).asIterable().toList() + var participants = getResultsForStage(stage) + if (participants.size >= stage.participantCount) { + participants = + participants.subList(0, stage.participantCount).map { StageResultDto(it.teamId, it.teamName) } + } + for (i in 0 until stage.participantCount) { + participants[i].initialSeed = teamSeeds[i] + } + val parts = mutableListOf() + parts.addAll(participants) + for (i in stage.participantCount + 1 until 2.0.pow(stage.rounds()).toInt() + 1) { + parts.add(StageResultDto(teamId = 0, teamName = "ByeGame", initialSeed = i)) + } + stage.participants = parts.joinToString("\n") { objectMapper.writeValueAsString(it) } + return stage.participants + } + + + @Transactional + fun createMatchesForStage(stage: TournamentStageEntity) { + val firstRoundGameCount = 2.0.pow(stage.rounds() - 1).toInt() + val gameCount = 2 * firstRoundGameCount + val matches = mutableListOf() + + + // Create matches for the first round + var j = 1 + for (i in 1 until firstRoundGameCount + 1) { + matches.add( + TournamentMatchEntity( + gameId = i, + stageId = stage.id, + level = 1, + homeSeed = j++, + awaySeed = j++ + ) + ) + } + + // Create matches for subsequent rounds + var roundMatches = firstRoundGameCount; j = 1 + var k = 2 + for (i in firstRoundGameCount + 1 until gameCount) { + matches.add( + TournamentMatchEntity( + gameId = i, + stageId = stage.id, + level = k, + homeSeed = -(j++), + awaySeed = -(j++) + ) + ) + // Check if we need to increment the level number + // roundMatches is the number of matches before the current round + if (j == roundMatches + 1) { + k++ + roundMatches += roundMatches / 2 + } + } + matchRepository.saveAll(matches) + } + + @Transactional + fun setInitialSeeds(stage: TournamentStageEntity, seeds: List, user: CmschUser): TournamentStageEntity { + require(StaffPermissions.PERMISSION_SET_SEEDS.validate(user)) { + "User does not have permission to set seeds." + } + if (seeds.size != 2.0.pow(stage.rounds()).toInt()) { + throw IllegalArgumentException("Number of seeds must match the participant count of the stage.") + } + + stage.participants = seeds.joinToString("\n") { objectMapper.writeValueAsString(it) } + stageRepository.save(stage) + return stage + } + + fun setSeeds(stage: TournamentStageEntity): String { + val matches = matchRepository.findAllByStageId(stage.id) + val seeds = mutableListOf() + seeds.addAll( + stage.participants.split("\n").map { objectMapper.readValue(it, StageResultDto::class.java) } + .map { + SeededParticipantDto( + seed = it.initialSeed, + teamId = it.teamId, + teamName = it.teamName + ) + }) + for (match in matches) { + val winner = match.winner() + if (winner != null) { + seeds.add(winner.let { + SeededParticipantDto( + seed = -match.gameId, + teamId = it.teamId, + teamName = it.teamName + ) + }) + } + } + return seeds.joinToString("\n") { objectMapper.writeValueAsString(it) } + } + + + fun calculateTeamsFromSeeds(stage: TournamentStageEntity) { + val actualMatches = matchRepository.findAllByStageId(stage.id) + val seeds = getSeeds(stage) + for (match in actualMatches) { + val homeTeam = seeds[match.homeSeed] + val awayTeam = seeds[match.awaySeed] + match.homeTeamId = homeTeam?.teamId + match.homeTeamName = homeTeam?.teamName ?: "TBD" + match.awayTeamId = awayTeam?.teamId + match.awayTeamName = awayTeam?.teamName ?: "TBD" + } + matchRepository.saveAll(actualMatches) + } + + + fun getSeeds(stage: TournamentStageEntity): Map { + val seeds = mutableMapOf() + if (stage.seeds == "") { + return emptyMap() + } + stage.seeds.split("\n").forEach { + val participant = objectMapper.readValue(it, SeededParticipantDto::class.java) + seeds[participant.seed] = ParticipantDto( + teamId = participant.teamId, + teamName = participant.teamName + ) + } + return seeds + } + + fun getMatchesByStageTournamentId(tournamentId: Int): List { + val stages = stageRepository.findAllByTournamentId(tournamentId) + val matches = mutableListOf() + for (stage in stages) { + matches.addAll(matchRepository.findAllByStageId(stage.id)) + } + return matches + } + + // For testing reasons, this is not used atm + fun getUpcomingMatchesByTournamentId(tournamentId: Int): List { + val stages = stageRepository.findAllByTournamentId(tournamentId) + val matches = mutableListOf() + for (stage in stages) { + matches.addAll(matchRepository.findAllByStageId(stage.id).filter { it.kickoffTime > clock.getTime() }) + } + val timeFrame = tournamentComponent.closeMatchesTimeWindow * 60 * 1000L // Convert minutes to milliseconds + matches.filter { it.status in listOf(MatchStatus.IN_PROGRESS, MatchStatus.NOT_STARTED) } + .filter { (it.kickoffTime - clock.getTime()) in -timeFrame..timeFrame } + + return matches.sortedBy { it.kickoffTime } + } + + @Transactional + fun deleteMatchesForStage(stage: TournamentStageEntity) { + matchRepository.deleteAllByStageId(stage.id) + } + + fun onSeedsFinalized(stage: TournamentStageEntity) { + val matches = matchRepository.findAllByStageId(stage.id) + if (matches.isEmpty()) { + throw IllegalStateException("No matches found for stage ${stage.id} when finalizing seeds.") + } + + for (match in matches) { + val winner = match.winner() + if (winner != null) { + if (winner.teamId == match.homeTeamId) { + match.status = MatchStatus.BYE + matchRepository.save(match) + } else if (winner.teamId == match.awayTeamId) { + match.status = MatchStatus.BYE + matchRepository.save(match) + } else { + // Handle case where winner is not one of the teams in the match + throw IllegalStateException("Winner team ID ${winner.teamId} does not match any team in match ${match.id}.") + } + } + } + + } +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/tournament-features.md b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/tournament-features.md new file mode 100644 index 00000000..89fff3ee --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/tournament-features.md @@ -0,0 +1,64 @@ +# Tournament life cycle +1. Previously: we have booked the fields for football, volleyball (and streetball?) +2. Tournament creation + 1. Football, volleyball, streetball; chess, beer pong, video games + 2. Name, max participants, type of tournament (ie. group stage?, knockout phase?) etc. +3. Applications come in for every event +4. Application ends, we need to generate the brackets/groups + 1. Set the seedings for the events + 2. Football, Volleyball, Streetball: group phases for all, others: knockout brackets + - group sizes should be dependent on the count of the playing fields (how many matches can we play overall and concurrently) + - we might want to avoid duplicate pairings over different sports to maintain diversity +5. set the times and places for matches + - we should avoid teams being represented in different sports at the same time (at least in football, volleyball and streetball) +6. group stages for football, volleyball and streetball go, finish after a couple of days +7. get advancing teams, generate knockout brackets for them + - once again, avoid teams being represented at the same time, also try to make the finals be at a different time + +- register game results (possibly even have live scores updated) + +## Views/Pages + +### Registration +- That should be a form (don't know if it can be used as easily) + +### Tournaments +- Lists the different tournaments + - Outside sports might be on the same page, the online tourneys as well + +### Tournament (group) view page +- Tab type 1: Table/bracket for a tournament + - different tournament, different tab for bracket/group standings +- Tab type 2: Game schedules + - ? every tournament's schedule in a group on the same page ? + - Should we even group tournaments? If so, how? + + +## Schema + +### Tournament Group (?) +- Id +- Name +- url + +### Tournament +- Id +- Name +- groupId (optional) (?) +- url + +### TournamentStage +- Id +- tournamentId + +### GroupStage: TournamentStage +- teamsToAdvance + +### TournamentRegistration +- Id +- groupId +- tournamentId +- seed +#### +//- opponents: Opponents + diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/ComponentLoadConfig.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/ComponentLoadConfig.kt index bc952135..f65be9d5 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/ComponentLoadConfig.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/ComponentLoadConfig.kt @@ -32,6 +32,7 @@ data class ComponentLoadConfig @ConstructorBinding constructor( var task: Boolean, var team: Boolean, var token: Boolean, + var tournament: Boolean, var accessKeys: Boolean, var conference: Boolean, var email: Boolean, diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/TestConfig.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/TestConfig.kt index f7cecf9e..658d7deb 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/TestConfig.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/TestConfig.kt @@ -1,5 +1,6 @@ package hu.bme.sch.cmsch.config +import com.fasterxml.jackson.databind.ObjectMapper import hu.bme.sch.cmsch.component.app.ExtraMenuEntity import hu.bme.sch.cmsch.component.app.ExtraMenuRepository import hu.bme.sch.cmsch.component.debt.ProductEntity @@ -23,6 +24,7 @@ import hu.bme.sch.cmsch.component.token.TokenEntity import hu.bme.sch.cmsch.component.token.TokenPropertyEntity import hu.bme.sch.cmsch.component.token.TokenPropertyRepository import hu.bme.sch.cmsch.component.token.TokenRepository +import hu.bme.sch.cmsch.component.tournament.* import hu.bme.sch.cmsch.model.* import hu.bme.sch.cmsch.repository.GroupRepository import hu.bme.sch.cmsch.repository.GroupToUserMappingRepository @@ -82,7 +84,10 @@ class TestConfig( private val formResponseRepository: Optional, private val extraMenuRepository: ExtraMenuRepository, private val riddleCacheManager: Optional, + private val tournamentRepository: Optional, + private val stageRepository: Optional, private val startupPropertyConfig: StartupPropertyConfig, + private val objectMapper: ObjectMapper, ) { private var now = System.currentTimeMillis() / 1000 @@ -130,6 +135,12 @@ class TestConfig( addForms(form, response) } } + + tournamentRepository.ifPresent { tournament -> + stageRepository.ifPresent { stage -> + addTournaments(tournament, stage) + } + } } @Scheduled(fixedDelay = 3000L) @@ -146,7 +157,7 @@ class TestConfig( "Teszt Form", "test-from", "Form", - "[{\"fieldName\":\"phone\",\"label\":\"Telefonszám\",\"type\":\"PHONE\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"\",\"required\":true,\"permanent\":true},{\"fieldName\":\"allergy\",\"label\":\"Étel érzékenység\",\"type\":\"SELECT\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"Nincs, Glutén, Laktóz, Glutés és laktóz\",\"note\":\"Ha egyéb is van, kérem írja megjegyzésbe\",\"required\":true,\"permanent\":true},{\"fieldName\":\"love-trains\",\"label\":\"Szereted a mozdonyokat?\",\"type\":\"CHECKBOX\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"\",\"required\":true,\"permanent\":true},{\"fieldName\":\"warn1\",\"label\":\"FIGYELEM\",\"type\":\"WARNING_BOX\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"Ha nem szereti a mozdonyokat, akkor nagyon kellemetlen élete lesz magának kolléga!\",\"required\":false,\"permanent\":false},{\"fieldName\":\"text1\",\"label\":\"Szabályzat\",\"type\":\"TEXT_BOX\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"A tábor szabályzata itt olvasható: https://szabalyzat.ssl.nincs.ilyen.domain.hu/asdasdasd/kutya\",\"note\":\"\",\"required\":false,\"permanent\":false},{\"fieldName\":\"agree\",\"label\":\"A szabályzatot elfogadom\",\"type\":\"MUST_AGREE\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"Különben nem jöhet am\",\"required\":false,\"permanent\":false},{\"fieldName\":\"food\",\"label\":\"Mit enne?\",\"type\":\"SELECT\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"Gyros tál, Brassói, Pho Leves\",\"note\":\"Első napi kaja\",\"required\":true,\"permanent\":true}]", + "[{\"fieldName\":\"phone\",\"label\":\"Telefonszám\",\"type\":\"PHONE\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"\",\"required\":true,\"permanent\":true},{\"fieldName\":\"allergy\",\"label\":\"Étel érzékenység\",\"type\":\"SELECT\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"Nincs, Glutén, Laktóz, Glutén és laktóz\",\"note\":\"Ha egyéb is van, kérem írja megjegyzésbe\",\"required\":true,\"permanent\":true},{\"fieldName\":\"love-trains\",\"label\":\"Szereted a mozdonyokat?\",\"type\":\"CHECKBOX\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"\",\"required\":true,\"permanent\":true},{\"fieldName\":\"warn1\",\"label\":\"FIGYELEM\",\"type\":\"WARNING_BOX\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"Ha nem szereti a mozdonyokat, akkor nagyon kellemetlen élete lesz magának kolléga!\",\"required\":false,\"permanent\":false},{\"fieldName\":\"text1\",\"label\":\"Szabályzat\",\"type\":\"TEXT_BOX\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"A tábor szabályzata itt olvasható: https://szabalyzat.ssl.nincs.ilyen.domain.hu/asdasdasd/kutya\",\"note\":\"\",\"required\":false,\"permanent\":false},{\"fieldName\":\"agree\",\"label\":\"A szabályzatot elfogadom\",\"type\":\"MUST_AGREE\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"\",\"note\":\"Különben nem jöhet am\",\"required\":false,\"permanent\":false},{\"fieldName\":\"food\",\"label\":\"Mit enne?\",\"type\":\"SELECT\",\"formatRegex\":\".*\",\"invalidFormatMessage\":\"\",\"values\":\"Gyros tál, Brassói, Pho Leves\",\"note\":\"Első napi kaja\",\"required\":true,\"permanent\":true}]", RoleType.BASIC, RoleType.SUPERUSER, "form submitted", @@ -458,12 +469,49 @@ class TestConfig( races = false, selectable = false, leaveable = false + )), + + groupRepository.save(GroupEntity( + name = "Chillámák", + major = MajorType.UNKNOWN, + staff1 = "", + staff2 = "", + staff3 = "", + staff4 = "", + races = true, + selectable = true, + leaveable = false + )), + + groupRepository.save(GroupEntity( + name = "Bóbisch", + major = MajorType.UNKNOWN, + staff1 = "", + staff2 = "", + staff3 = "", + staff4 = "", + races = true, + selectable = true, + leaveable = false + )), + + groupRepository.save(GroupEntity( + name = "Schugár", + major = MajorType.UNKNOWN, + staff1 = "", + staff2 = "", + staff3 = "", + staff4 = "", + races = true, + selectable = true, + leaveable = false ))) + return groups } private fun addNews(news: NewsRepository) { - news.save(NewsEntity(title = "Az eslő hír", + news.save(NewsEntity(title = "Az első hír", content = LOREM_IPSUM_SHORT_1, visible = true, highlighted = false )) @@ -1127,4 +1175,31 @@ class TestConfig( extraMenuRepository.save(ExtraMenuEntity(0, "Facebook", "https://facebook.com/xddddddddddd", true)) } + private fun addTournaments(repository: TournamentRepository, stageRepository: TournamentStageRepository){ + val participants1 = mutableListOf() + participants1.add(ParticipantDto(groupRepository.findByName("V10").orElseThrow().id, "V10")) + participants1.add(ParticipantDto(groupRepository.findByName("I16").orElseThrow().id, "I16")) + participants1.add(ParticipantDto(groupRepository.findByName("I09").orElseThrow().id, "I09")) + participants1.add(ParticipantDto(groupRepository.findByName("Vendég").orElseThrow().id, "Vendég")) + participants1.add(ParticipantDto(groupRepository.findByName("Kiállító").orElseThrow().id, "Kiállító")) + participants1.add(ParticipantDto(groupRepository.findByName("Chillámák").orElseThrow().id, "Chillámák")) + participants1.add(ParticipantDto(groupRepository.findByName("Bóbisch").orElseThrow().id, "Bóbisch")) + participants1.add(ParticipantDto(groupRepository.findByName("Schugár").orElseThrow().id, "Schugár")) + val tournament1 = TournamentEntity( + title = "Foci verseny", + description = "A legjobb foci csapat nyer", + location = "BME Sporttelep", + participants = participants1.joinToString("\n") { objectMapper.writeValueAsString(it) }, + participantCount = participants1.size, + ) + repository.save(tournament1) + val stage1 = TournamentStageEntity( + name = "Kieséses szakasz", + tournamentId = tournament1.id, + level = 1, + participantCount = participants1.size, + ) + stageRepository.save(stage1) + } + } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/OneDeepEntityPage.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/OneDeepEntityPage.kt index de025afc..99f57c2f 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/OneDeepEntityPage.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/controller/admin/OneDeepEntityPage.kt @@ -578,10 +578,10 @@ open class OneDeepEntityPage( @PostMapping("/create") fun create(@ModelAttribute(binding = false) dto: T, - @RequestParam(required = false) file0: MultipartFile?, - @RequestParam(required = false) file1: MultipartFile?, - model: Model, - auth: Authentication, + @RequestParam(required = false) file0: MultipartFile?, + @RequestParam(required = false) file1: MultipartFile?, + model: Model, + auth: Authentication, ): String { val user = auth.getUser() adminMenuService.addPartsForMenu(user, model) diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/service/PermissionsService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/service/PermissionsService.kt index 97cba916..3124800b 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/service/PermissionsService.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/service/PermissionsService.kt @@ -35,6 +35,7 @@ import hu.bme.sch.cmsch.component.staticpage.StaticPageComponent import hu.bme.sch.cmsch.component.task.TaskComponent import hu.bme.sch.cmsch.component.team.TeamComponent import hu.bme.sch.cmsch.component.token.TokenComponent +import hu.bme.sch.cmsch.component.tournament.TournamentComponent import hu.bme.sch.cmsch.extending.CmschPermissionSource import org.springframework.stereotype.Component import org.springframework.stereotype.Service @@ -428,6 +429,13 @@ object ControlPermissions : PermissionGroup { component = SheetsComponent::class ) + val PERMISSION_CONTROL_TOURNAMENT = PermissionValidator( + "TOURNAMENT_CONTROL", + "Tournament komponens testreszabása", + readOnly = false, + component = TournamentComponent::class + ) + val PERMISSION_CONTROL_SCRIPT = PermissionValidator( "SCRIPT_CONTROL", "Scriptek testreszabása", @@ -479,6 +487,7 @@ object ControlPermissions : PermissionGroup { PERMISSION_CONTROL_PROTO, PERMISSION_CONTROL_CONFERENCE, PERMISSION_CONTROL_SHEETS, + PERMISSION_CONTROL_TOURNAMENT, PERMISSION_CONTROL_SCRIPT, ) @@ -1587,6 +1596,64 @@ object StaffPermissions : PermissionGroup { component = SheetsComponent::class ) + /// TournamentComponent + + val PERMISSION_SHOW_TOURNAMENTS = PermissionValidator( + "TOURNAMENTS_SHOW", + "Versenyek megtekintése", + readOnly = true, + component = TournamentComponent::class + ) + + val PERMISSION_CREATE_TOURNAMENTS = PermissionValidator( + "TOURNAMENTS_CREATE", + "Versenyek létrehozása", + readOnly = false, + component = TournamentComponent::class + ) + + val PERMISSION_DELETE_TOURNAMENTS = PermissionValidator( + "TOURNAMENTS_DELETE", + "Versenyek törlése", + readOnly = false, + component = TournamentComponent::class + ) + + val PERMISSION_EDIT_TOURNAMENTS = PermissionValidator( + "TOURNAMENTS_EDIT", + "Versenyek szerkesztése", + readOnly = false, + component = TournamentComponent::class + ) + + val PERMISSION_SET_SEEDS = PermissionValidator( + "TOURNAMENT_SET_SEEDS", + "Versenyzők seedjeinek állítása", + readOnly = false, + component = TournamentComponent::class + ) + + val PERMISSION_SHOW_BRACKETS = PermissionValidator( + "TOURNAMENT_SHOW_BRACKETS", + "Verseny táblák megtekintése", + readOnly = true, + component = TournamentComponent::class + ) + + val PERMISSION_GENERATE_BRACKETS = PermissionValidator( + "TOURNAMENT_GENERATE_BRACKETS", + "Verseny táblák generálása", + readOnly = false, + component = TournamentComponent::class + ) + + val PERMISSION_EDIT_RESULTS = PermissionValidator( + "TOURNAMENT_EDIT_RESULTS", + "Verseny eredmények szerkesztése", + readOnly = false, + component = TournamentComponent::class + ) + /// ScriptComponent val PERMISSION_SHOW_SCRIPTS = PermissionValidator( @@ -1799,6 +1866,15 @@ object StaffPermissions : PermissionGroup { PERMISSION_CREATE_SHEETS, PERMISSION_DELETE_SHEETS, + PERMISSION_SHOW_TOURNAMENTS, + PERMISSION_CREATE_TOURNAMENTS, + PERMISSION_DELETE_TOURNAMENTS, + PERMISSION_EDIT_TOURNAMENTS, + PERMISSION_SET_SEEDS, + PERMISSION_SHOW_BRACKETS, + PERMISSION_GENERATE_BRACKETS, + PERMISSION_EDIT_RESULTS, + PERMISSION_SHOW_SCRIPTS, PERMISSION_EDIT_SCRIPTS, PERMISSION_CREATE_SCRIPTS, diff --git a/backend/src/main/resources/config/application-env.properties b/backend/src/main/resources/config/application-env.properties index d5ba08d6..b9f35872 100644 --- a/backend/src/main/resources/config/application-env.properties +++ b/backend/src/main/resources/config/application-env.properties @@ -86,6 +86,7 @@ hu.bme.sch.cmsch.component.load.staticPage=${LOAD_STATIC_PAGE:false} hu.bme.sch.cmsch.component.load.task=${LOAD_TASK:false} hu.bme.sch.cmsch.component.load.team=${LOAD_TEAM:false} hu.bme.sch.cmsch.component.load.token=${LOAD_TOKEN:false} +hu.bme.sch.cmsch.component.load.tournament=${LOAD_TOURNAMENT:false} hu.bme.sch.cmsch.startup.token-ownership-mode=${OWNER_TOKEN:USER} hu.bme.sch.cmsch.startup.task-ownership-mode=${OWNER_TASK:USER} diff --git a/backend/src/main/resources/config/application.properties b/backend/src/main/resources/config/application.properties index d5024864..7d816935 100644 --- a/backend/src/main/resources/config/application.properties +++ b/backend/src/main/resources/config/application.properties @@ -109,6 +109,7 @@ hu.bme.sch.cmsch.component.load.staticPage=true hu.bme.sch.cmsch.component.load.task=true hu.bme.sch.cmsch.component.load.team=true hu.bme.sch.cmsch.component.load.token=true +hu.bme.sch.cmsch.component.load.tournament=true hu.bme.sch.cmsch.component.load.test=true hu.bme.sch.cmsch.component.load.stats=true @@ -140,7 +141,8 @@ hu.bme.sch.cmsch.proto.priority=128 hu.bme.sch.cmsch.conference.priority=129 hu.bme.sch.cmsch.gallery.priority=130 hu.bme.sch.cmsch.sheets.priority=131 -hu.bme.sch.cmsch.script.priority=132 +hu.bme.sch.cmsch.tournament.priority=132 +hu.bme.sch.cmsch.script.priority=133 hu.bme.sch.cmsch.app.priority=150 hu.bme.sch.cmsch.app.content.priority=151 hu.bme.sch.cmsch.app.style.priority=152 diff --git a/backend/src/main/resources/static/style4.css b/backend/src/main/resources/static/style4.css index 891e48e4..9410fefa 100644 --- a/backend/src/main/resources/static/style4.css +++ b/backend/src/main/resources/static/style4.css @@ -916,6 +916,12 @@ a.btn { border: 0 } +button.btn.disabled { + background-color: var(--dark-4); + color: var(--lighter-text-color); + cursor: not-allowed; +} + button.btn:hover, input[type="submit"].btn:hover, a.btn:hover { @@ -985,6 +991,15 @@ a.btn-danger:hover { color: var(--primary-color); } +.form-popup { + display: none; + position: fixed; + bottom: 0; + right: 15px; + border: 3px solid #f1f1f1; + z-index: 9; +} + form label, .flagged-label { display: block; @@ -1705,6 +1720,13 @@ form ul { font-weight: 300; } +.flex-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; +} + .hidden { display: none; } diff --git a/backend/src/main/resources/templates/matchAdmin.html b/backend/src/main/resources/templates/matchAdmin.html new file mode 100644 index 00000000..5e0ad1c3 --- /dev/null +++ b/backend/src/main/resources/templates/matchAdmin.html @@ -0,0 +1,63 @@ + + + + + +
+
+ +
+
+
+ +
+

Match admin

+ +
+ + +

Tournament title

+
+
+ + +
+
+

Mérkőzések

+
+
+ + + + + + + + + + + + + + + + + + + + + + +
Match IDRoundTeam 1Team 2FieldKickoff TimeStatus + + grade + +
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/src/main/resources/templates/matchScore.html b/backend/src/main/resources/templates/matchScore.html new file mode 100644 index 00000000..c1421fab --- /dev/null +++ b/backend/src/main/resources/templates/matchScore.html @@ -0,0 +1,148 @@ + + + + +
+
+ + + + +
+
+
+ +
+
+

+
+

Match score input

+ + + + + +
+ + + +
+
+
+ + + +

Stage title

+
+ + +

Round

+
+ + +

Kickoff Time

+
+ + +

Field

+
+ + +

Match Status

+
+
+
+
+ + +

Home Team

+
+
+ +

Home team goals

+
+
+
+ + +

Away Team

+
+ + +

Away team goals

+
+
+
+
+
+ + + + + + +
+ + + + + undo + Vissza a mérkőzésekhez + +
+
+
+
+
+ + + \ No newline at end of file diff --git a/backend/src/main/resources/templates/overview4.html b/backend/src/main/resources/templates/overview4.html index 5797bce3..06ad249a 100644 --- a/backend/src/main/resources/templates/overview4.html +++ b/backend/src/main/resources/templates/overview4.html @@ -140,26 +140,26 @@
Usage text
return enumElementMap[value] || ""; } - let columData = /*[[${columnData}]]*/ []; - for (let columnId in columData) { - if (columData[columnId].formatter === 'tickCross') { - columData[columnId]['formatterParams'] = { + let columnData = /*[[${columnData}]]*/ []; + for (let columnId in columnData) { + if (columnData[columnId].formatter === 'tickCross') { + columnData[columnId]['formatterParams'] = { allowEmpty: true, allowTruthy: true, tickElement: "done", crossElement: "close", } - } else if (columData[columnId].formatter === 'enumIconsFormatter') { - columData[columnId]['formatter'] = enumIconsFormatter; - } else if (columData[columnId].formatter === 'datetime') { - columData[columnId]['formatter'] = timestampFormatter; - columData[columnId]['minWidth'] = 180; + } else if (columnData[columnId].formatter === 'enumIconsFormatter') { + columnData[columnId]['formatter'] = enumIconsFormatter; + } else if (columnData[columnId].formatter === 'datetime') { + columnData[columnId]['formatter'] = timestampFormatter; + columnData[columnId]['minWidth'] = 180; } else { - columData[columnId]['minWidth'] = 200; + columnData[columnId]['minWidth'] = 200; } } if (buttons.length !== 0) { - columData.push({ + columnData.push({ title: " ", field: "buttons", formatter: "html", @@ -176,7 +176,7 @@
Usage text
autoColumns: false, layout: 'fitColumns', responsiveLayout: 'collapse', - columns: columData, + columns: columnData, height: "500px" }); diff --git a/backend/src/main/resources/templates/seedSettings.html b/backend/src/main/resources/templates/seedSettings.html new file mode 100644 index 00000000..b6b75e9b --- /dev/null +++ b/backend/src/main/resources/templates/seedSettings.html @@ -0,0 +1,128 @@ + + + + + +
+
+ + +
+
+
+
+

View edit

+

View view

+

View type

+
+ + +

+
+ + +

+

+
+
+
+
+ + + +
+ + + + + + undo + VISSZA + + + + +
+
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c122ddb3..fec9dbde 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -45,6 +45,8 @@ import TokenScanPage from './pages/token/tokenScan.page.tsx' import TokenScanResultPage from './pages/token/tokenScanResult.page.tsx' import { l } from './util/language' import { Paths } from './util/paths.ts' +import TournamentPage from "./pages/tournament/tournament.page.tsx"; +import TournamentListPage from "./pages/tournament/tournamentList.page.tsx"; export function App() { return ( @@ -129,6 +131,10 @@ export function App() { } /> } /> + + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/contexts/config/types.ts b/frontend/src/api/contexts/config/types.ts index fdf0956c..6ece4e75 100644 --- a/frontend/src/api/contexts/config/types.ts +++ b/frontend/src/api/contexts/config/types.ts @@ -40,6 +40,7 @@ export interface Components { qrFight: QrFight communities: Communities footer: Footer + tournament: Tournament } export interface App { @@ -351,3 +352,7 @@ export interface Communities { titleResort: string descriptionResort: string } + +export interface Tournament { + title: string +} diff --git a/frontend/src/api/hooks/queryKeys.ts b/frontend/src/api/hooks/queryKeys.ts index 8cb2c082..f48415d7 100644 --- a/frontend/src/api/hooks/queryKeys.ts +++ b/frontend/src/api/hooks/queryKeys.ts @@ -29,6 +29,7 @@ export enum QueryKeys { ORGANIZATION = 'ORGANIZATION', ACCESS_KEY = 'ACCESS_KEY', HOME_NEWS = 'HOME_NEWS', + TOURNAMENTS = 'TOURNAMENTS', DEBTS = 'DEBTS', HOME_GALLERY = 'HOME_GALLERY' } diff --git a/frontend/src/api/hooks/tournament/actions/useTournamentJoinMutation.ts b/frontend/src/api/hooks/tournament/actions/useTournamentJoinMutation.ts new file mode 100644 index 00000000..4dfedf7e --- /dev/null +++ b/frontend/src/api/hooks/tournament/actions/useTournamentJoinMutation.ts @@ -0,0 +1,15 @@ +import axios from 'axios' +import { TournamentResponses } from '../../../../util/views/tournament.view' +import { useMutation } from '@tanstack/react-query' +import { joinPath } from '../../../../util/core-functions.util.ts' + +export const useTournamentJoinMutation = (onSuccess?: () => void, onError?: () => void)=> { + return useMutation({ + mutationFn: async (id: number) => { + const response = await axios.post(joinPath('/api/tournament/register', id)) + return response.data + }, + onSuccess, + onError + }) +} diff --git a/frontend/src/api/hooks/tournament/queries/useTournamentListQuery.ts b/frontend/src/api/hooks/tournament/queries/useTournamentListQuery.ts new file mode 100644 index 00000000..573fc6f1 --- /dev/null +++ b/frontend/src/api/hooks/tournament/queries/useTournamentListQuery.ts @@ -0,0 +1,16 @@ +import { TournamentPreview } from '../../../../util/views/tournament.view.ts' +import { useQuery } from '@tanstack/react-query' +import { QueryKeys } from '../../queryKeys.ts' +import axios from 'axios' +import { ApiPaths } from '../../../../util/paths.ts' + + +export const useTournamentListQuery = () => { + return useQuery({ + queryKey: [QueryKeys.TOURNAMENTS], + queryFn: async () => { + const response = await axios.get(ApiPaths.TOURNAMENTS) + return response.data + } + }) +} diff --git a/frontend/src/api/hooks/tournament/queries/useTournamentQuery.ts b/frontend/src/api/hooks/tournament/queries/useTournamentQuery.ts new file mode 100644 index 00000000..fc5908a3 --- /dev/null +++ b/frontend/src/api/hooks/tournament/queries/useTournamentQuery.ts @@ -0,0 +1,17 @@ +import {useQuery} from "@tanstack/react-query"; +import { OptionalTournamentView } from '../../../../util/views/tournament.view.ts' +import {QueryKeys} from "../../queryKeys.ts"; +import axios from "axios"; +import {joinPath} from "../../../../util/core-functions.util.ts"; +import {ApiPaths} from "../../../../util/paths.ts"; + + +export const useTournamentQuery = (id: number) => { + return useQuery({ + queryKey: [QueryKeys.TOURNAMENTS, id], + queryFn: async () => { + const response = await axios.get(joinPath(ApiPaths.TOURNAMENTS, id)) + return response.data + } + }) +} diff --git a/frontend/src/pages/tournament/components/KnockoutBracket.tsx b/frontend/src/pages/tournament/components/KnockoutBracket.tsx new file mode 100644 index 00000000..a34750cc --- /dev/null +++ b/frontend/src/pages/tournament/components/KnockoutBracket.tsx @@ -0,0 +1,39 @@ +import { MatchTree } from '../util/matchTree.ts' +import { Box, Stack } from '@chakra-ui/react' +import Match from './Match.tsx' + +interface KnockoutBracketProps { + tree: MatchTree +} + +const KnockoutBracket: React.FC = ({ tree }: KnockoutBracketProps) => { + return ( + + {(tree.upperTree || tree.lowerTree) && ( + <> + + {tree.upperTree && } + {tree.lowerTree && } + + + {tree.upperTree && tree.lowerTree && ( + <> + + + + + + )} + {tree.upperTree && !tree.lowerTree && } + {!tree.upperTree && tree.lowerTree && } + + + )} + + + + + ) +} + +export default KnockoutBracket diff --git a/frontend/src/pages/tournament/components/KnockoutStage.tsx b/frontend/src/pages/tournament/components/KnockoutStage.tsx new file mode 100644 index 00000000..0673f01a --- /dev/null +++ b/frontend/src/pages/tournament/components/KnockoutStage.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { TournamentStageView, MatchView } from '../../../util/views/tournament.view.ts' +import { Heading } from '@chakra-ui/react' +import { groupBy, keys } from 'lodash' +import KnockoutBracket from './KnockoutBracket.tsx' +import { MatchTree } from '../util/matchTree.ts' + +interface TournamentBracketProps { + stage: TournamentStageView +} + +const TournamentBracket: React.FC = ({ stage }: TournamentBracketProps) => { + let levels = groupBy(stage.matches, (match: MatchView) => match.level) + let levelCount = keys(levels).length + + const buildTree = (level: number, rootNum: number): MatchTree => { + let root = levels[level][rootNum] + let upperTree = level > 1 && levels[level - 1].length > 2 * rootNum ? buildTree(level - 1, 2 * rootNum) : null + let lowerTree = level > 1 && levels[level - 1].length > 2 * rootNum + 1 ? buildTree(level - 1, 2 * rootNum + 1) : null + return { + root: root, + lowerTree: lowerTree, + upperTree: upperTree + } + } + + let trees: MatchTree[] = [] + if (levelCount < 1) { + return null + } + for (let i = 0; i < levels[levelCount].length; i++) { + trees.push(buildTree(levelCount, i)) + } + + return ( + <> + + {stage.name} + + {trees.map((tree) => ( + + ))} + + ) +} + +export default TournamentBracket diff --git a/frontend/src/pages/tournament/components/Match.tsx b/frontend/src/pages/tournament/components/Match.tsx new file mode 100644 index 00000000..efda25b6 --- /dev/null +++ b/frontend/src/pages/tournament/components/Match.tsx @@ -0,0 +1,74 @@ +import { MatchView, ParticipantView } from '../../../util/views/tournament.view.ts' +import { Box, Flex, Text } from '@chakra-ui/react' +import { stringifyTimeStamp } from '../../../util/core-functions.util.ts' + +interface MatchProps { + match: MatchView +} + +const getScoreColor = (match: MatchView, score1?: number, score2?: number) => { + if (match.status !== 'COMPLETED' || score1 === undefined || score2 === undefined) return 'gray.600' + return score1 > score2 ? 'green.600' : 'red.600' +} + +const Match = ({ match }: MatchProps) => { + const formatKickOffTime = (timestamp?: number) => { + if (!timestamp) return 'TBD' + return stringifyTimeStamp(timestamp) + } + + const getParticipantName = (seed: number, participant?: ParticipantView) => { + if (participant) return participant.teamName + if (seed < 0) return `Winner of Game ${-seed}` + return 'TBD' + } + + if (match.status === 'BYE') { + return + } + + return ( + + + Game {match.gameId} + + + + {getParticipantName(match.homeSeed, match.home)} + + + {match.homeScore ?? '-'} + + + + + {getParticipantName(match.awaySeed, match.away)} + + + {match.awayScore ?? '-'} + + + + + {match.status} + + + {formatKickOffTime(match.kickoffTime)} + + + + {match.location != '' ? match.location : 'Location TBD'} + + + ) +} + +export default Match diff --git a/frontend/src/pages/tournament/components/Tournament.tsx b/frontend/src/pages/tournament/components/Tournament.tsx new file mode 100644 index 00000000..dcc1fe8c --- /dev/null +++ b/frontend/src/pages/tournament/components/Tournament.tsx @@ -0,0 +1,104 @@ +import { TournamentDetailsView, TournamentResponseMessages, TournamentResponses } from '../../../util/views/tournament.view.ts' +import { Box, Button, Flex, Heading, Tab, TabList, TabPanel, TabPanels, Tabs, Text, useToast } from '@chakra-ui/react' +import KnockoutStage from './KnockoutStage.tsx' +import { useState } from 'react' +import { useConfigContext } from '../../../api/contexts/config/ConfigContext.tsx' +import { ComponentUnavailable } from '../../../common-components/ComponentUnavailable.tsx' +import { Helmet } from 'react-helmet-async' +import { CmschPage } from '../../../common-components/layout/CmschPage.tsx' +import { useTournamentJoinMutation } from '../../../api/hooks/tournament/actions/useTournamentJoinMutation.ts' +import { FaSignInAlt } from 'react-icons/fa' + +interface TournamentProps { + tournament: TournamentDetailsView + refetch?: () => void +} + +const Tournament = ({ tournament, refetch = () => {} }: TournamentProps) => { + const toast = useToast() + const { components } = useConfigContext() + const tournamentComponent = components.tournament + const joinMutation = useTournamentJoinMutation() + + const actionResponseCallback = (response: TournamentResponses) => { + if (response == TournamentResponses.OK) { + toast({ status: 'success', title: TournamentResponseMessages[response] }) + refetch() + } else { + toast({ status: 'error', title: TournamentResponseMessages[response] }) + } + } + + const joinTournament = () => { + if (tournament.tournament.joinEnabled) { + joinMutation.mutate(tournament.tournament.id, { + onSuccess: (response: TournamentResponses) => { + actionResponseCallback(response) + if (response === TournamentResponses.OK) { + refetch() + } + }, + onError: () => { + toast({ status: 'error', title: 'Hiba történt a versenyre való jelentkezés során.' }) + } + }) + } + } + + const [tabIndex, setTabIndex] = useState(0) + + const onTabSelected = (i: number) => { + setTabIndex(i) + } + + if (!tournamentComponent) return + + return ( + + + + {tournament.tournament.title} + {tournament.tournament.description} + {tournament.tournament.location} + + {tournament.tournament.joinEnabled && ( + + )} + {tournament.tournament.isJoined && ( + + )} + + + + Résztvevők + {tournament.stages.map((stage) => ( + {stage.name} + ))} + + + + {tournament.tournament.participants.map((participant) => ( + + + {participant.teamName} + + + ))} + + {tournament.stages.map((stage) => ( + + + + ))} + + + + + ) +} + +export default Tournament diff --git a/frontend/src/pages/tournament/tournament.page.tsx b/frontend/src/pages/tournament/tournament.page.tsx new file mode 100644 index 00000000..1ade820e --- /dev/null +++ b/frontend/src/pages/tournament/tournament.page.tsx @@ -0,0 +1,18 @@ +import {useTournamentQuery} from "../../api/hooks/tournament/queries/useTournamentQuery.ts"; +import {useParams} from "react-router"; +import {toInteger} from "lodash"; +import {PageStatus} from "../../common-components/PageStatus.tsx"; +import Tournament from "./components/Tournament.tsx"; + + +const TournamentPage = () => { + const { id } = useParams() + const { data, isLoading, error, refetch } = useTournamentQuery(toInteger(id) || 0) + + if (error || isLoading || !data) return + + if (!data.tournament) return + return +} + +export default TournamentPage diff --git a/frontend/src/pages/tournament/tournamentList.page.tsx b/frontend/src/pages/tournament/tournamentList.page.tsx new file mode 100644 index 00000000..6bc6bb26 --- /dev/null +++ b/frontend/src/pages/tournament/tournamentList.page.tsx @@ -0,0 +1,53 @@ +import { useTournamentListQuery } from '../../api/hooks/tournament/queries/useTournamentListQuery.ts' +import { useConfigContext } from '../../api/contexts/config/ConfigContext.tsx' +import {Box, Heading, LinkOverlay, VStack} from '@chakra-ui/react' +import { TournamentPreview } from '../../util/views/tournament.view.ts' +import { ComponentUnavailable } from '../../common-components/ComponentUnavailable.tsx' +import { PageStatus } from '../../common-components/PageStatus.tsx' +import { CmschPage } from '../../common-components/layout/CmschPage.tsx' +import { Helmet } from 'react-helmet-async' +import {Link} from "react-router"; +import {AbsolutePaths} from "../../util/paths.ts"; + + +const TournamentListPage = () => { + const { isLoading, isError, data } = useTournamentListQuery() + const component = useConfigContext()?.components?.tournament + + if (!component) return + + if (isError || isLoading || !data) return + return ( + + + + + {component.title} + + + {data.length} verseny található. + + + + {(data ?? []).length > 0 ? ( + data.map((tournament: TournamentPreview) => ( + + + + {tournament.title} + + + + {tournament.description} + + + )) + ) : ( + Nincs egyetlen verseny sem. + )} + + + ) +} + +export default TournamentListPage diff --git a/frontend/src/pages/tournament/util/matchTree.ts b/frontend/src/pages/tournament/util/matchTree.ts new file mode 100644 index 00000000..55bfedbf --- /dev/null +++ b/frontend/src/pages/tournament/util/matchTree.ts @@ -0,0 +1,7 @@ +import { MatchView } from '../../../util/views/tournament.view.ts' + +export type MatchTree = { + root: MatchView + lowerTree: MatchTree | null + upperTree: MatchTree | null +} diff --git a/frontend/src/util/paths.ts b/frontend/src/util/paths.ts index e35668a5..bbb9df0b 100644 --- a/frontend/src/util/paths.ts +++ b/frontend/src/util/paths.ts @@ -27,6 +27,7 @@ export enum Paths { TEAM_ADMIN = 'team-admin', ACCESS_KEY = 'access-key', MAP = 'map', + TOURNAMENT = 'tournament', DEBT = 'debt' } @@ -58,7 +59,8 @@ export enum AbsolutePaths { LEADER_BOARD = '/leaderboard', ACCESS_KEY = '/access-key', MAP = '/map', - DEBT = '/debt' + DEBT = '/debt', + TOURNAMENTS = '/tournament', } export enum ApiPaths { @@ -91,6 +93,7 @@ export enum ApiPaths { HOME_GALLERY = '/api/gallery/home', ADD_PUSH_NOTIFICATION_TOKEN = '/api/pushnotification/add-token', DELETE_PUSH_NOTIFICATION_TOKEN = '/api/pushnotification/delete-token', + TOURNAMENTS = '/api/tournament', MY_TEAM = '/api/team/my', TEAM = '/api/team', ALL_TEAMS = '/api/teams', diff --git a/frontend/src/util/views/tournament.view.ts b/frontend/src/util/views/tournament.view.ts new file mode 100644 index 00000000..a8a7b19e --- /dev/null +++ b/frontend/src/util/views/tournament.view.ts @@ -0,0 +1,95 @@ +export type TournamentPreview = { + id: number + title: string + description: string + location: string + status: number +} + +export type TournamentWithParticipantsView = { + id: number + title: string + description: string + location: string + joinEnabled: boolean + isJoined: boolean + participants: ParticipantView[] + status: number +} + +export type ParticipantView = { + teamId: number + teamName: string +} + +export enum TournamentResponses { + OK = 'OK', + JOINING_DISABLED = 'JOINING_DISABLED', + ALREADY_JOINED = 'ALREADY_JOINED', + TOURNAMENT_NOT_FOUND = 'TOURNAMENT_NOT_FOUND', + NOT_JOINABLE = 'NOT_JOINABLE', + INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', + ERROR = 'ERROR' +} + +export const TournamentResponseMessages: Record = { + [TournamentResponses.OK]: 'Sikeresen csatlakoztál a versenyhez.', + [TournamentResponses.JOINING_DISABLED]: 'A versenyhez való csatlakozás jelenleg le van tiltva.', + [TournamentResponses.ALREADY_JOINED]: 'Már csatlakoztál ehhez a versenyhez.', + [TournamentResponses.TOURNAMENT_NOT_FOUND]: 'A verseny nem található.', + [TournamentResponses.NOT_JOINABLE]: 'A versenyhez való csatlakozás nem lehetséges.', + [TournamentResponses.INSUFFICIENT_PERMISSIONS]: 'Nincs elég jogosultságod ehhez a művelethez.', + [TournamentResponses.ERROR]: 'Hiba történt a művelet végrehajtása során.' +} + +export enum StageStatus { + CREATED = 'CREATED', + DRAFT = 'DRAFT', + SET = 'SET', + ONGOING = 'ONGOING', + FINISHED = 'FINISHED', + CANCELLED = 'CANCELLED' +} + +export enum MatchStatus { + NOT_STARTED = 'NOT_STARTED', + IN_PROGRESS = 'IN_PROGRESS', + CANCELLED = 'CANCELLED', + COMPLETED = 'COMPLETED', + BYE = 'BYE' +} + +export type MatchView = { + id: number + gameId: number + kickoffTime?: number + level: number + location: string + homeSeed: number + awaySeed: number + home?: ParticipantView + away?: ParticipantView + homeScore?: number + awayScore?: number + status: MatchStatus +} + +export type TournamentStageView = { + id: number + name: string + level: number + participantCount: number + nextRound: number + status: StageStatus + matches: MatchView[] +} + +export type TournamentDetailsView = { + tournament: TournamentWithParticipantsView + stages: TournamentStageView[] +} + +export type OptionalTournamentView = { + visible: boolean + tournament?: TournamentDetailsView +} diff --git a/helm/cmsch/templates/cmsch-config.yml b/helm/cmsch/templates/cmsch-config.yml index 98923223..0362c53e 100644 --- a/helm/cmsch/templates/cmsch-config.yml +++ b/helm/cmsch/templates/cmsch-config.yml @@ -54,6 +54,7 @@ data: LOAD_TEAM: {{ .Values.load.team | quote }} LOAD_TOKEN: {{ .Values.load.token | quote }} LOAD_EVENT: {{ .Values.load.event | quote }} + LOAD_TOURNAMENT: {{ .Values.load.tournament | quote }} LOAD_PUSHNOTIFICATION: {{ .Values.load.pushnotification | quote }} LOAD_SHEETS: {{ .Values.load.sheets | quote }} LOAD_SERVICE_ACCOUNT: {{ .Values.load.serviceAccount | quote }} diff --git a/helm/cmsch/values.yaml b/helm/cmsch/values.yaml index 02746525..19f56afa 100644 --- a/helm/cmsch/values.yaml +++ b/helm/cmsch/values.yaml @@ -153,6 +153,7 @@ load: team: false token: false event: false + tournament: false pushnotification: false sheets: false serviceAccount: false