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 000000000..32b2f3fa4 --- /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 000000000..155b0b0cd --- /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 000000000..18c1d5dff --- /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 000000000..3a2a8d36b --- /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 000000000..0e8a8b4c4 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentApiController.kt @@ -0,0 +1,66 @@ +package hu.bme.sch.cmsch.component.tournament + +import hu.bme.sch.cmsch.model.RoleType +import hu.bme.sch.cmsch.util.getUserOrNull +import hu.bme.sch.cmsch.util.isAvailableForRole +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, +) { + + @GetMapping("/tournament") + 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}") + 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() + } + + @PutMapping("/tournament/register") + fun registerTeam( + @RequestBody tournamentJoinDto: TournamentJoinDto, + auth: Authentication? + ): TournamentJoinStatus { + val user = auth?.getUserOrNull() + ?: return TournamentJoinStatus.INSUFFICIENT_PERMISSIONS + return tournamentService.register(tournamentJoinDto.id, user) + } + + @PutMapping("/tournament/unregister") + fun unregisterTeam( + @RequestBody tournamentJoinDto: TournamentJoinDto, + auth: Authentication? + ): TournamentCancelStatus { + val user = auth?.getUserOrNull() + ?: return TournamentCancelStatus.INSUFFICIENT_PERMISSIONS + return tournamentService.unregister(tournamentJoinDto.id, user) + } + +} \ No newline at end of file diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentCancelStatus.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentCancelStatus.kt new file mode 100644 index 000000000..16c3ba77d --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentCancelStatus.kt @@ -0,0 +1,10 @@ +package hu.bme.sch.cmsch.component.tournament + +enum class TournamentCancelStatus { + OK, + NOT_PLAYING, + TOURNAMENT_NOT_FOUND, + NOT_CANCELABLE, + INSUFFICIENT_PERMISSIONS, + ERROR +} \ 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 000000000..9fae5b96f --- /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 000000000..da8dc4885 --- /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 000000000..5b5ee14aa --- /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.persistence.autoconfigure.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 000000000..987cb955c --- /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 tools.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 000000000..e40d0aea5 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentDetailedView.kt @@ -0,0 +1,54 @@ +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 joined: Boolean, + val joinCancellable: Boolean, + val joinDeadline: Long, + val participantMaxCount: 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 type: StageType, + val name: String, + val level: Int, + val participantCount: Int, + val participants: List, + val nextRound: Int, + val groupCount: 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 000000000..69dda3225 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentEntity.kt @@ -0,0 +1,130 @@ +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 (valamint lejelentkezni)") + @property:GenerateOverview(columnName = "Joinable", order = 4) + @property:ImportFormat + var joinable: Boolean = false, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class, Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.DATE, defaultValue = "0", order = 6, label = "Jelentkezés határideje") + @property:GenerateOverview(columnName = "Jelentkezés határideje", order = 5, renderer = OverviewType.DATE, useForSearch = false) + @property:ImportFormat + var joinDeadline: Long = 0, + + @Column(nullable = false) + @field:JsonView(value = [ Edit::class ]) + @property:GenerateInput(type = InputType.SWITCH, order = 7, label = "Látható") + @property:GenerateOverview(columnName = "Visible", order = 6) + @property:ImportFormat + var visible: Boolean = false, + + @Column(nullable = false) + @field:JsonView(value = [ Preview::class, FullDetails::class ]) + @property:GenerateInput(type = InputType.NUMBER, order = 8, label = "Résztvevők maximális száma", + note = "Ha -1, akkor nincsen limit") + @property:GenerateOverview(columnName = "Résztvevők maximális száma", order = 7) + @property:ImportFormat + var participantMaxCount: 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(visible = false) + @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 = 8, label = "Résztvevő típus", + source = ["GROUP", "USER"]) + @property:GenerateOverview(columnName = "Résztvevő típus", order = 8) + @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 000000000..119f0f607 --- /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 000000000..1d265a476 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentJoinStatus.kt @@ -0,0 +1,10 @@ +package hu.bme.sch.cmsch.component.tournament + +enum class TournamentJoinStatus { + OK, + 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 000000000..ccb9ea5a0 --- /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 tools.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(): MutableIterable { + return tournamentService.getAggregatedMatchesByTournamentId().toMutableList() + } + }, + 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 000000000..c748d80f3 --- /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 000000000..87d8165d5 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentMatchRepository.kt @@ -0,0 +1,35 @@ +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(): MutableIterable + override fun findById(id: Int): Optional + 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 000000000..5b91bfb7f --- /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 000000000..2ba10e502 --- /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(): MutableList + +} 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 000000000..57339f8ec --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentService.kt @@ -0,0 +1,289 @@ +package hu.bme.sch.cmsch.component.tournament + +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.service.TimeService +import hu.bme.sch.cmsch.util.isAvailableForRole +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.resilience.annotation.Retryable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Isolation +import org.springframework.transaction.annotation.Transactional +import tools.jackson.databind.ObjectMapper +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 matchRepository: TournamentMatchRepository, + private val objectMapper: ObjectMapper, + private val clock: TimeService +){ + @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 = getParticipants(tournament) + + val now = clock.getTimeInSeconds() + + val playerId = when (tournament.participantType) { + OwnershipType.GROUP -> user?.groupId + OwnershipType.USER -> user?.id + } + val joined = participants.any { it.teamId == playerId } + val joinChangable = tournament.joinable && tournament.joinDeadline > now && + ( + (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, + joinChangable && !joined && participants.size < tournament.participantMaxCount, + joined, + joinChangable && joined, + tournament.joinDeadline, + tournament.participantMaxCount, + participants, + tournament.status + ), stages.map { KnockoutStageDetailedView( + it.id, + it.type, + it.name, + it.level, + it.participantCount, + it.participants.split("\n").filter { p -> p.isNotEmpty() }.map { p -> objectMapper.readValue(p, SeededParticipantDto::class.java) }, + it.nextRound, + it.groupCount, + 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") + .filter { it.isNotEmpty() } + .map { objectMapper.readValue(it, ParticipantDto::class.java) } + } + + fun getParticipants(tournament: TournamentEntity): List { + if (tournament.participants.isEmpty()) { + return emptyList() + } + return tournament.participants.split("\n") + .filter { it.isNotEmpty() } + .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 + @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) + fun register(tournamentId: Int, user: CmschUser): TournamentJoinStatus { + val now = clock.getTimeInSeconds() + val tournament = findById(tournamentId) + ?: return TournamentJoinStatus.TOURNAMENT_NOT_FOUND + if (tournament.joinDeadline < now) return TournamentJoinStatus.NOT_JOINABLE + if (!tournament.joinable) return TournamentJoinStatus.NOT_JOINABLE + return when (tournament.participantType) { + OwnershipType.GROUP -> teamRegister(tournament, user) + OwnershipType.USER -> userRegister(tournament, user) + } + } + + + 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 + if (user.role < RoleType.PRIVILEGED) return TournamentJoinStatus.INSUFFICIENT_PERMISSIONS + + val parsed = getParticipants(tournament).toMutableList() + if (parsed.any { it.teamId == groupId }) { + return TournamentJoinStatus.ALREADY_JOINED + } + if (parsed.size >= tournament.participantMaxCount && tournament.participantMaxCount != -1) { + return TournamentJoinStatus.NOT_JOINABLE + } + 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 + @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) + fun userRegister(tournament: TournamentEntity, user: CmschUser): TournamentJoinStatus { + if (user.role < RoleType.BASIC) return TournamentJoinStatus.INSUFFICIENT_PERMISSIONS + val parsed = getParticipants(tournament).toMutableList() + if (parsed.any { it.teamId == user.id }) { + return TournamentJoinStatus.ALREADY_JOINED + } + if (parsed.size >= tournament.participantMaxCount && tournament.participantMaxCount != -1) { + return TournamentJoinStatus.NOT_JOINABLE + } + 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 + } + + @Retryable + @Transactional(readOnly = false, isolation = Isolation.SERIALIZABLE) + fun unregister(tournamentId: Int, user: CmschUser): TournamentCancelStatus { + val now = clock.getTimeInSeconds() + val tournament = findById(tournamentId) + ?: return TournamentCancelStatus.TOURNAMENT_NOT_FOUND + if (tournament.joinDeadline < now) return TournamentCancelStatus.NOT_CANCELABLE + return when (tournament.participantType) { + OwnershipType.GROUP -> teamUnregister(tournament, user) + OwnershipType.USER -> userUnregister(tournament, user) + } + } + + + fun teamUnregister(tournament: TournamentEntity, user: CmschUser): TournamentCancelStatus { + val groupId = user.groupId + ?: return TournamentCancelStatus.INSUFFICIENT_PERMISSIONS + val team = groupRepository.findById(groupId).getOrNull() + ?: return TournamentCancelStatus.INSUFFICIENT_PERMISSIONS + if (user.role < RoleType.PRIVILEGED) return TournamentCancelStatus.INSUFFICIENT_PERMISSIONS + + val parsed = getParticipants(tournament).toMutableList() + if (parsed.none { it.teamId == groupId }) { + return TournamentCancelStatus.NOT_PLAYING + } + parsed.removeIf { it.teamId == groupId } + + tournament.participants = parsed.joinToString("\n") { objectMapper.writeValueAsString(it) } + tournament.participantCount = parsed.size + + tournamentRepository.save(tournament) + return TournamentCancelStatus.OK + } + + fun userUnregister(tournament: TournamentEntity, user: CmschUser): TournamentCancelStatus { + if (user.role < RoleType.BASIC) return TournamentCancelStatus.INSUFFICIENT_PERMISSIONS + val parsed = getParticipants(tournament).toMutableList() + if (parsed.none { it.teamId == user.id }) { + return TournamentCancelStatus.NOT_PLAYING + } + parsed.removeIf { it.teamId == user.id } + + tournament.participants = parsed.joinToString("\n") { objectMapper.writeValueAsString(it) } + tournament.participantCount = parsed.size + + tournamentRepository.save(tournament) + return TournamentCancelStatus.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 000000000..7845d1fec --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageController.kt @@ -0,0 +1,360 @@ +package hu.bme.sch.cmsch.component.tournament + +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.MultipartRequest +import tools.jackson.databind.ObjectMapper +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, + "Szakasz", "Szakaszok", + "A verseny szakaszok kezelése.", + transactionManager, + object : ManualRepository() { + override fun findAll(): MutableIterable { + 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 }.toMutableList() + } + }, + 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 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 csoportkör 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, + model: Model, + auth: Authentication, + multipartRequest: MultipartRequest, + @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 + else -> return "redirect:/admin/control/$view" + }} ?: return "redirect:/admin/control/$view" + val tournamentId = toCreate.getOrNull(1)?.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, multipartRequest) + 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.createMatchesForKnockoutStage(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 000000000..30d2435fa --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageEntity.kt @@ -0,0 +1,137 @@ +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, visible = true, ignore = true) + @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) + @field:JsonView(value = [ FullDetails::class ]) + @property:GenerateInput(type = InputType.NUMBER, min = 1, order = 4, label = "Csoportok száma", defaultValue = "1", note = "Csak csoportkör esetén számít") + @property:ImportFormat + var groupCount: Int = 1, + + @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, +} 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 000000000..1e65e7c60 --- /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(): MutableList + 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 000000000..79f2ef124 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/tournament/TournamentStageService.kt @@ -0,0 +1,279 @@ +package hu.bme.sch.cmsch.component.tournament + +import tools.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.floor +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) { + val parts = tournamentService.getParticipants(stage.tournamentId) + return parts.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 createMatchesForKnockoutStage(stage: TournamentStageEntity) { + require(stage.type == StageType.KNOCKOUT){ + "Stage type must be KNOCKOUT to create tree." + } + val prevMatches = matchRepository.findAllByStageId(stage.id) + if (prevMatches.isNotEmpty()) { + throw IllegalStateException("Matches already exist for stage ${stage.id}. Cannot create matches again.") + } + require(stage.participantCount > 1) { + "Stage must have at least two participants to create matches." + } + 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 createRoundMatchesForGroupStageGroup(stage: TournamentStageEntity, group: Int) { + require(stage.type == StageType.GROUP) { + "Stage type must be GROUP to create round matches." + } + require(stage.groupCount < group) { + "Group number $group is out of bounds for stage with ${stage.groupCount} groups." + } + val matches = mutableListOf() + val groupSize = let { + val min = floor(stage.participantCount.toDouble() / stage.groupCount).toInt() + if (stage.participantCount % stage.groupCount < group) { + min + 1 + } else { + min + } + } + + val prevMatches = matchRepository.findAllByStageId(stage.id) + + + } + + @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).toMutableList() + 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 000000000..89fff3ee7 --- /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 2f3c87485..9d0bf9788 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 @@ -42,6 +42,7 @@ data class ComponentLoadConfig @ConstructorBinding constructor( var task: Boolean, var team: Boolean, var token: Boolean, + var tournament: 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 25a964d2f..cfa62a9e8 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 tools.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 @@ -83,7 +85,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 @@ -131,6 +136,12 @@ class TestConfig( addForms(form, response) } } + + tournamentRepository.ifPresent { tournament -> + stageRepository.ifPresent { stage -> + addTournaments(tournament, stage) + } + } } @Scheduled(fixedDelay = 3000L) @@ -147,7 +158,7 @@ class TestConfig( "Teszt Form", "test-form", "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", @@ -459,12 +470,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 )) @@ -1128,4 +1176,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/service/PermissionsService.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/service/PermissionsService.kt index 97cba9163..3124800bc 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 32c7c2807..28a01468b 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 ba490bb57..c6a8fdfb1 100644 --- a/backend/src/main/resources/config/application.properties +++ b/backend/src/main/resources/config/application.properties @@ -99,6 +99,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 @@ -130,7 +131,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 bdecc1726..cef486282 100644 --- a/backend/src/main/resources/static/style4.css +++ b/backend/src/main/resources/static/style4.css @@ -920,6 +920,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 { @@ -989,6 +995,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; @@ -1713,6 +1728,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 000000000..5e0ad1c3a --- /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 000000000..c1421fab0 --- /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/seedSettings.html b/backend/src/main/resources/templates/seedSettings.html new file mode 100644 index 000000000..b6b75e9b8 --- /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 e02c5983e..8c61e7b77 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,6 @@ +import { TitleProvider } from '@/util/TitleProvider.tsx' +import { l } from '@/util/language.ts' +import { Paths } from '@/util/paths.ts' import { Suspense } from 'react' import { Route, Routes } from 'react-router' import { useConfigContext } from './api/contexts/config/ConfigContext.tsx' @@ -51,9 +54,8 @@ import TeamListPage from './pages/teams/teamList.page.tsx' import TokenListPage from './pages/token/tokenList.page.tsx' 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 { TitleProvider } from './util/TitleProvider.tsx' +import TournamentPage from './pages/tournament/tournament.page.tsx' +import TournamentListPage from './pages/tournament/tournamentList.page.tsx' export function App() { const appName = useConfigContext()?.components?.app?.siteName || 'CMSch' @@ -150,6 +152,10 @@ export function App() { } /> } /> + + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/contexts/config/types.ts b/frontend/src/api/contexts/config/types.ts index 939bcd915..634a0637d 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 { @@ -392,3 +393,7 @@ export interface Communities { searchEnabledResort: boolean tinderEnabled: boolean } + +export interface Tournament { + title: string +} diff --git a/frontend/src/api/hooks/queryKeys.ts b/frontend/src/api/hooks/queryKeys.ts index b6ad6165c..7bf504de1 100644 --- a/frontend/src/api/hooks/queryKeys.ts +++ b/frontend/src/api/hooks/queryKeys.ts @@ -30,6 +30,7 @@ export const QueryKeys = { TINDER_QUESTION: 'TINDER_QUESTION', TINDER_ANSWERS: 'TINDER_ANSWERS', TINDER_INTERACTION: 'TINDER_INTERACTION', + TOURNAMENT: 'TOURNAMENT', ACCESS_KEY: 'ACCESS_KEY', HOME_NEWS: 'HOME_NEWS', DEBTS: 'DEBTS', diff --git a/frontend/src/api/hooks/tournament/actions/useTournamentCancelMutation.ts b/frontend/src/api/hooks/tournament/actions/useTournamentCancelMutation.ts new file mode 100644 index 000000000..d47857dcd --- /dev/null +++ b/frontend/src/api/hooks/tournament/actions/useTournamentCancelMutation.ts @@ -0,0 +1,14 @@ +import { TournamentCancelResponses } from '@/util/views/tournament.view.ts' +import { useMutation } from '@tanstack/react-query' +import axios from 'axios' + +export const useTournamentCancelMutation = (onSuccess?: () => void, onError?: () => void) => { + return useMutation({ + mutationFn: async (id: number) => { + const response = await axios.put('/api/tournament/unregister', { id }) + return response.data + }, + onSuccess, + onError + }) +} 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 000000000..f40b237b5 --- /dev/null +++ b/frontend/src/api/hooks/tournament/actions/useTournamentJoinMutation.ts @@ -0,0 +1,14 @@ +import { TournamentJoinResponses } from '@/util/views/tournament.view.ts' +import { useMutation } from '@tanstack/react-query' +import axios from 'axios' + +export const useTournamentJoinMutation = (onSuccess?: () => void, onError?: () => void) => { + return useMutation({ + mutationFn: async (id: number) => { + const response = await axios.put('/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 000000000..a417bf9ac --- /dev/null +++ b/frontend/src/api/hooks/tournament/queries/useTournamentListQuery.ts @@ -0,0 +1,15 @@ +import { ApiPaths } from '@/util/paths.ts' +import type { TournamentPreview } from '@/util/views/tournament.view.ts' +import { useQuery } from '@tanstack/react-query' +import axios from 'axios' +import { QueryKeys } from '../../queryKeys.ts' + +export const useTournamentListQuery = () => { + return useQuery({ + queryKey: [QueryKeys.TOURNAMENT], + 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 000000000..8166f059a --- /dev/null +++ b/frontend/src/api/hooks/tournament/queries/useTournamentQuery.ts @@ -0,0 +1,16 @@ +import { joinPath } from '@/util/core-functions.util.ts' +import { ApiPaths } from '@/util/paths.ts' +import type { OptionalTournamentView } from '@/util/views/tournament.view.ts' +import { useQuery } from '@tanstack/react-query' +import axios from 'axios' +import { QueryKeys } from '../../queryKeys.ts' + +export const useTournamentQuery = (id: number) => { + return useQuery({ + queryKey: [QueryKeys.TOURNAMENT, id], + queryFn: async () => { + const response = await axios.get(joinPath(ApiPaths.TOURNAMENTS, id)) + return response.data + } + }) +} diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 0d1216952..a7e77f9b2 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -25,8 +25,9 @@ const TabsTrigger = React.forwardRef< className={cn( 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ' + 'ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ' + - 'focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background ' + - 'data-[state=active]:text-foreground data-[state=active]:shadow-xs', + 'focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent ' + + 'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs ' + + 'data-[state=active]:font-bold data-[state=active]:border-foreground', className )} {...props} diff --git a/frontend/src/pages/tournament/components/GroupStage.tsx b/frontend/src/pages/tournament/components/GroupStage.tsx new file mode 100644 index 000000000..d6d3c3143 --- /dev/null +++ b/frontend/src/pages/tournament/components/GroupStage.tsx @@ -0,0 +1,15 @@ +import type { TournamentStageView } from '@/util/views/tournament.view.ts' + +interface GroupStageProps { + stage: TournamentStageView +} + +const GroupStage: React.FC = ({ stage }: GroupStageProps) => { + const groups = Array.from({ length: stage.groupCount }, (_, i) => i).map((groupIndex) => { + return stage.participants.filter((participant) => participant.seed % stage.groupCount === groupIndex) + }) + + return
GroupStage
+} + +export default GroupStage diff --git a/frontend/src/pages/tournament/components/KnockoutBracket.tsx b/frontend/src/pages/tournament/components/KnockoutBracket.tsx new file mode 100644 index 000000000..23d766c8c --- /dev/null +++ b/frontend/src/pages/tournament/components/KnockoutBracket.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import type { MatchTree } from '../util/matchTree' +import Match from './Match.tsx' + +interface KnockoutBracketProps { + tree: MatchTree +} + +const lineColor = '#E2E8F0' // approximate Chakra gray.300 + +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 000000000..f140bf4e0 --- /dev/null +++ b/frontend/src/pages/tournament/components/KnockoutStage.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import type { MatchTree } from '../util/matchTree' +import KnockoutBracket from './KnockoutBracket.tsx' + +import { groupBy, keys } from 'lodash' + +type TournamentStageView = import('../../../util/views/tournament.view').TournamentStageView +type MatchView = import('../../../util/views/tournament.view').MatchView + +interface TournamentBracketProps { + stage: TournamentStageView +} + +const TournamentBracket: React.FC = ({ stage }: TournamentBracketProps) => { + const levels = groupBy(stage.matches, (match: MatchView) => match.level) + const levelCount = keys(levels).length + + const buildTree = (level: number, rootNum: number): MatchTree => { + const root = levels[level][rootNum]! + const upperTree = level > 1 && (levels[level - 1]?.length ?? 0) > 2 * rootNum ? buildTree(level - 1, 2 * rootNum) : null + const lowerTree = level > 1 && (levels[level - 1]?.length ?? 0) > 2 * rootNum + 1 ? buildTree(level - 1, 2 * rootNum + 1) : null + return { + root: root, + lowerTree: lowerTree, + upperTree: upperTree + } + } + + const trees: MatchTree[] = [] + if (levelCount < 1) { + return null + } + for (let i = 0; i < (levels[levelCount]?.length ?? 0); 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 000000000..11d9f0697 --- /dev/null +++ b/frontend/src/pages/tournament/components/Match.tsx @@ -0,0 +1,79 @@ +import { stringifyTimeStamp } from '@/util/core-functions.util.ts' +import type { MatchView, ParticipantView } from '@/util/views/tournament.view.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 000000000..c6334a4f0 --- /dev/null +++ b/frontend/src/pages/tournament/components/Tournament.tsx @@ -0,0 +1,153 @@ +import { useConfigContext } from '@/api/contexts/config/ConfigContext.tsx' +import { useTournamentCancelMutation } from '@/api/hooks/tournament/actions/useTournamentCancelMutation.ts' +import { useTournamentJoinMutation } from '@/api/hooks/tournament/actions/useTournamentJoinMutation.ts' +import { ComponentUnavailable } from '@/common-components/ComponentUnavailable.tsx' +import { CustomTabButton } from '@/common-components/CustomTabButton.tsx' +import { CmschPage } from '@/common-components/layout/CmschPage.tsx' +import { Button } from '@/components/ui/button.tsx' +import { Separator } from '@/components/ui/separator.tsx' +import { Tabs, TabsContent, TabsList } from '@/components/ui/tabs.tsx' +import { useToast } from '@/hooks/use-toast.ts' +import GroupStage from '@/pages/tournament/components/GroupStage.tsx' +import KnockoutStage from '@/pages/tournament/components/KnockoutStage.tsx' +import { stringifyTimeStamp } from '@/util/core-functions.util.ts' +import type { TournamentDetailsView } from '@/util/views/tournament.view.ts' +import { + TournamentCancelResponseMessages, + TournamentCancelResponses, + TournamentJoinResponseMessages, + TournamentJoinResponses +} from '@/util/views/tournament.view.ts' +import { Check, FileText, LandPlot, Undo2, X } from 'lucide-react' + +interface TournamentProps { + tournament: TournamentDetailsView + refetch?: () => void +} + +const Tournament = ({ tournament, refetch = () => {} }: TournamentProps) => { + const config = useConfigContext() + const tournamentComponent = config?.components?.tournament + const joinMutation = useTournamentJoinMutation() + const cancelMutation = useTournamentCancelMutation() + const { toast } = useToast() + + const joinActionResponseCallback = (response: TournamentJoinResponses) => { + if (response == TournamentJoinResponses.OK) { + toast({ variant: 'default', title: TournamentJoinResponseMessages[response] }) + refetch() + } else { + toast({ variant: 'destructive', title: TournamentJoinResponseMessages[response] }) + } + } + const cancelActionResponseCallback = (response: TournamentCancelResponses) => { + if (response == TournamentCancelResponses.OK) { + toast({ variant: 'default', title: TournamentCancelResponseMessages[response] }) + refetch() + } else { + toast({ variant: 'destructive', title: TournamentCancelResponseMessages[response] }) + } + } + + const joinTournament = () => { + if (tournament.tournament.joinEnabled) { + joinMutation.mutate(tournament.tournament.id, { + onSuccess: (response: TournamentJoinResponses) => { + joinActionResponseCallback(response) + if (response === TournamentJoinResponses.OK) { + refetch() + } + }, + onError: () => { + toast({ variant: 'destructive', title: 'Hiba történt a versenyre való jelentkezés során.' }) + } + }) + } + } + const cancelJoinTournament = () => { + if (tournament.tournament.joinCancellable) { + cancelMutation.mutate(tournament.tournament.id, { + onSuccess: (response: TournamentCancelResponses) => { + cancelActionResponseCallback(response) + if (response === TournamentCancelResponses.OK) { + refetch() + } + }, + onError: () => { + toast({ variant: 'destructive', title: 'Hiba történt a versenyre való jelentkezés visszavonása során.' }) + } + }) + } + } + + if (!tournamentComponent) return + + return ( + +
+

{tournament.tournament.title}

+

{tournament.tournament.description}

+

+ {tournament.tournament.location} +

+ {tournament.tournament.joined ? ( +
+

+ Jelentkezve +

+

Határidő: {stringifyTimeStamp(tournament.tournament.joinDeadline)}

+
+ ) : ( +
+

+ Nem jelentkezve +

+

Határidő: {stringifyTimeStamp(tournament.tournament.joinDeadline)}

+
+ )} +
+ {tournament.tournament.joinCancellable && ( + + )} + {tournament.tournament.joinEnabled && ( + + )} +
+ + + + + + Résztvevők + {tournament.stages.map((stage, index) => ( + {stage.name} + ))} + + + +
+ {tournament.tournament.participants.map((participant) => ( +
+

{participant.teamName}

+
+ ))} +
+
+ + {tournament.stages.map((stage, index) => ( + + {stage.type === 'KNOCKOUT' && } + {stage.type === '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 000000000..4744de262 --- /dev/null +++ b/frontend/src/pages/tournament/tournament.page.tsx @@ -0,0 +1,17 @@ +import { useTournamentQuery } from '@/api/hooks/tournament/queries/useTournamentQuery.ts' +import { PageStatus } from '@/common-components/PageStatus.tsx' +import { toInteger } from 'lodash' +import { useParams } from 'react-router' +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 000000000..6824afbee --- /dev/null +++ b/frontend/src/pages/tournament/tournamentList.page.tsx @@ -0,0 +1,41 @@ +import { useConfigContext } from '@/api/contexts/config/ConfigContext.tsx' +import { useTournamentListQuery } from '@/api/hooks/tournament/queries/useTournamentListQuery.ts' +import { ComponentUnavailable } from '@/common-components/ComponentUnavailable' +import { PageStatus } from '@/common-components/PageStatus' +import { CmschPage } from '@/common-components/layout/CmschPage' +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) => ( + + )) + ) : ( +
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 000000000..57a4c6d03 --- /dev/null +++ b/frontend/src/pages/tournament/util/matchTree.ts @@ -0,0 +1,7 @@ +import type { 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 edc90d562..5848a59d8 100644 --- a/frontend/src/util/paths.ts +++ b/frontend/src/util/paths.ts @@ -26,6 +26,7 @@ export const Paths = { EDIT_TEAM: 'edit-team', MY_TEAM: 'my-team', TEAM_ADMIN: 'team-admin', + TOURNAMENT: 'tournament', ACCESS_KEY: 'access-key', MAP: 'map', DEBT: 'debt', @@ -59,6 +60,7 @@ export const AbsolutePaths = { EDIT_TEAM: '/edit-team', MY_TEAM: '/my-team', TEAM_ADMIN: '/team-admin', + TOURNAMENTS: '/tournament', QR_FIGHT: '/qr-fight', LEADER_BOARD: '/leaderboard', ACCESS_KEY: '/access-key', @@ -107,6 +109,7 @@ export const ApiPaths = { MY_TEAM: '/api/team/my', TEAM: '/api/team', ALL_TEAMS: '/api/teams', + TOURNAMENTS: '/api/tournament', DEBTS: '/api/debts', LOGIN: '/api/login', REGISTER: '/api/register', diff --git a/frontend/src/util/views/tournament.view.ts b/frontend/src/util/views/tournament.view.ts new file mode 100644 index 000000000..f73b44a72 --- /dev/null +++ b/frontend/src/util/views/tournament.view.ts @@ -0,0 +1,131 @@ +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 + joined: boolean + joinCancellable: boolean + joinDeadline: number + participantMaxCount: number + participants: ParticipantView[] + status: number +} + +export type ParticipantView = { + teamId: number + teamName: string +} + +export type SeededParticipantView = ParticipantView & { + seed: number +} + +export const TournamentJoinResponses = { + OK: 'OK', + ALREADY_JOINED: 'ALREADY_JOINED', + TOURNAMENT_NOT_FOUND: 'TOURNAMENT_NOT_FOUND', + NOT_JOINABLE: 'NOT_JOINABLE', + INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS', + ERROR: 'ERROR' +} +export type TournamentJoinResponses = (typeof TournamentJoinResponses)[keyof typeof TournamentJoinResponses] + +export const TournamentJoinResponseMessages: Record = { + [TournamentJoinResponses.OK]: 'Sikeresen csatlakoztál a versenyhez.', + [TournamentJoinResponses.ALREADY_JOINED]: 'Már csatlakoztál ehhez a versenyhez.', + [TournamentJoinResponses.TOURNAMENT_NOT_FOUND]: 'A verseny nem található.', + [TournamentJoinResponses.NOT_JOINABLE]: 'A versenyhez nem lehet csatlakozni: lejárt a határidő, le van tiltva vagy betelt.', + [TournamentJoinResponses.INSUFFICIENT_PERMISSIONS]: 'Nincs elég jogosultságod ehhez a művelethez.', + [TournamentJoinResponses.ERROR]: 'Hiba történt a művelet végrehajtása során.' +} + +export const TournamentCancelResponses = { + OK: 'OK', + NOT_PLAYING: 'NOT_PLAYING', + TOURNAMENT_NOT_FOUND: 'TOURNAMENT_NOT_FOUND', + NOT_CANCELABLE: 'NOT_CANCELABLE', + INSUFFICIENT_PERMISSIONS: 'INSUFFICIENT_PERMISSIONS', + ERROR: 'ERROR' +} +export type TournamentCancelResponses = (typeof TournamentCancelResponses)[keyof typeof TournamentCancelResponses] + +export const TournamentCancelResponseMessages: Record = { + [TournamentCancelResponses.OK]: 'Sikeresen visszavontad a jelentkezésed a versenyről.', + [TournamentCancelResponses.NOT_PLAYING]: 'Nem vagy résztvevője ennek a versenynek.', + [TournamentCancelResponses.TOURNAMENT_NOT_FOUND]: 'A verseny nem található.', + [TournamentCancelResponses.NOT_CANCELABLE]: 'A jelentkezésedet nem lehet visszavonni: lejárt a határidő.', + [TournamentCancelResponses.INSUFFICIENT_PERMISSIONS]: 'Nincs elég jogosultságod ehhez a művelethez.', + [TournamentCancelResponses.ERROR]: 'Hiba történt a művelet végrehajtása során.' +} + +export const StageStatus = { + CREATED: 'CREATED', + DRAFT: 'DRAFT', + SET: 'SET', + ONGOING: 'ONGOING', + FINISHED: 'FINISHED', + CANCELLED: 'CANCELLED' +} +export type StageStatus = (typeof StageStatus)[keyof typeof StageStatus] + +export const MatchStatus = { + NOT_STARTED: 'NOT_STARTED', + IN_PROGRESS: 'IN_PROGRESS', + CANCELLED: 'CANCELLED', + COMPLETED: 'COMPLETED', + BYE: 'BYE' +} +export type MatchStatus = (typeof MatchStatus)[keyof typeof MatchStatus] + +export const StageType = { + KNOCKOUT: 'KNOCKOUT', + STAGE: 'STAGE' +} +export type StageType = (typeof StageType)[keyof typeof StageType] + +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 + type: StageType + name: string + level: number + participantCount: number + participants: SeededParticipantView[] + nextRound: number + groupCount: 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 bfe9f5aab..3df399389 100644 --- a/helm/cmsch/templates/cmsch-config.yml +++ b/helm/cmsch/templates/cmsch-config.yml @@ -57,6 +57,7 @@ data: LOAD_TASK: {{ .Values.load.task | quote }} LOAD_TEAM: {{ .Values.load.team | quote }} LOAD_TOKEN: {{ .Values.load.token | quote }} + LOAD_TOURNAMENT: {{ .Values.load.tournament | quote }} OWNER_CHALLENGE: {{ .Values.owner.challenge | quote }} OWNER_RACE: {{ .Values.owner.race | quote }} diff --git a/helm/cmsch/values.yaml b/helm/cmsch/values.yaml index caf6d90dc..c8856ea53 100644 --- a/helm/cmsch/values.yaml +++ b/helm/cmsch/values.yaml @@ -156,6 +156,7 @@ load: task: false team: false token: false + tournament: false owner: challenge: USER