added finals log uploads
This commit is contained in:
502
internal/handlers/series_result.go
Normal file
502
internal/handlers/series_result.go
Normal file
@@ -0,0 +1,502 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||
"git.haelnorr.com/h/oslstats/internal/view/seasonsview"
|
||||
"git.haelnorr.com/h/oslstats/pkg/slapshotapi"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// SeriesUploadResultPage renders the upload form for series match logs
|
||||
func SeriesUploadResultPage(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
var series *db.PlayoffSeries
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||
}
|
||||
if series == nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check for existing pending results
|
||||
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
|
||||
}
|
||||
if hasPending {
|
||||
throw.BadRequest(s, w, r, "Pending results already exist for this series. Discard them first to re-upload.", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(seasonsview.SeriesUploadResultPage(series), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
// SeriesUploadResults handles POST /series/{series_id}/results/upload
|
||||
// Parses match logs for all games, creates fixtures + results.
|
||||
func SeriesUploadResults(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form
|
||||
err = r.ParseMultipartForm(maxUploadSize * 5) // up to 5 games worth
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Upload Failed", "Could not parse uploaded files.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
gameCountStr := r.FormValue("game_count")
|
||||
gameCount, err := strconv.Atoi(gameCountStr)
|
||||
if err != nil || gameCount < 1 {
|
||||
notify.Warn(s, w, r, "Invalid Input", "Please select a valid number of games.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse all game logs
|
||||
type gameLogs struct {
|
||||
Logs [3]*slapshotapi.MatchLog
|
||||
}
|
||||
allGameLogs := make([]*gameLogs, gameCount)
|
||||
|
||||
for g := 1; g <= gameCount; g++ {
|
||||
gl := &gameLogs{}
|
||||
for p := 1; p <= 3; p++ {
|
||||
fieldName := "game_" + strconv.Itoa(g) + "_period_" + strconv.Itoa(p)
|
||||
file, _, err := r.FormFile(fieldName)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Missing File",
|
||||
"All 3 period files are required for Game "+strconv.Itoa(g)+". Missing period "+strconv.Itoa(p)+".", nil)
|
||||
return
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Read Error", "Could not read file: "+fieldName, nil)
|
||||
return
|
||||
}
|
||||
|
||||
log, err := slapshotapi.ParseMatchLog(data)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Parse Error",
|
||||
"Could not parse Game "+strconv.Itoa(g)+" Period "+strconv.Itoa(p)+": "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
gl.Logs[p-1] = log
|
||||
}
|
||||
allGameLogs[g-1] = gl
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||
}
|
||||
if series == nil {
|
||||
respond.NotFound(w, errors.New("series not found"))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Validate game count
|
||||
maxGames := series.MatchesToWin*2 - 1
|
||||
if gameCount < series.MatchesToWin || gameCount > maxGames {
|
||||
notify.Warn(s, w, r, "Invalid Game Count",
|
||||
"Game count must be between "+strconv.Itoa(series.MatchesToWin)+" and "+strconv.Itoa(maxGames)+".", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check for existing pending results
|
||||
hasPending, err := db.HasPendingSeriesResults(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.HasPendingSeriesResults")
|
||||
}
|
||||
if hasPending {
|
||||
notify.Warn(s, w, r, "Results Exist", "Pending results already exist. Discard them first.", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
audit := db.NewAuditFromRequest(r)
|
||||
user := db.CurrentUser(ctx)
|
||||
|
||||
// Process each game
|
||||
team1Wins := 0
|
||||
team2Wins := 0
|
||||
|
||||
for g := 0; g < gameCount; g++ {
|
||||
gl := allGameLogs[g]
|
||||
logs := []*slapshotapi.MatchLog{gl.Logs[0], gl.Logs[1], gl.Logs[2]}
|
||||
matchNumber := g + 1
|
||||
|
||||
// Check if series is already decided
|
||||
if team1Wins >= series.MatchesToWin || team2Wins >= series.MatchesToWin {
|
||||
notify.Warn(s, w, r, "Too Many Games",
|
||||
"The series was already decided before Game "+strconv.Itoa(matchNumber)+". Reduce the game count.", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Detect tampering
|
||||
tamperingDetected, tamperingReason, err := slapshotapi.DetectTampering(logs)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Validation Error",
|
||||
"Game "+strconv.Itoa(matchNumber)+" tampering check failed: "+err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Create fixture for this game
|
||||
fixture, _, err := db.CreatePlayoffGameFixture(ctx, tx, series, matchNumber, audit)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.CreatePlayoffGameFixture")
|
||||
}
|
||||
|
||||
// Collect game_user_ids
|
||||
gameUserIDSet := map[string]bool{}
|
||||
for _, log := range logs {
|
||||
for _, p := range log.Players {
|
||||
gameUserIDSet[p.GameUserID] = true
|
||||
}
|
||||
}
|
||||
gameUserIDs := make([]string, 0, len(gameUserIDSet))
|
||||
for id := range gameUserIDSet {
|
||||
gameUserIDs = append(gameUserIDs, id)
|
||||
}
|
||||
|
||||
// Map players
|
||||
playerLookup, err := MapGameUserIDsToPlayers(ctx, tx, gameUserIDs, fixture.SeasonID, fixture.LeagueID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "MapGameUserIDsToPlayers")
|
||||
}
|
||||
|
||||
// Determine orientation
|
||||
allPlayers := logs[2].Players
|
||||
fixtureHomeIsLogsHome, _, err := DetermineTeamOrientation(ctx, tx, fixture, allPlayers, playerLookup)
|
||||
if err != nil {
|
||||
notify.Warn(s, w, r, "Orientation Error",
|
||||
"Game "+strconv.Itoa(matchNumber)+": Could not determine team orientation: "+err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Build result
|
||||
finalLog := logs[2]
|
||||
winner := finalLog.Winner
|
||||
homeScore := finalLog.Score.Home
|
||||
awayScore := finalLog.Score.Away
|
||||
if !fixtureHomeIsLogsHome {
|
||||
switch winner {
|
||||
case "home":
|
||||
winner = "away"
|
||||
case "away":
|
||||
winner = "home"
|
||||
}
|
||||
homeScore, awayScore = awayScore, homeScore
|
||||
}
|
||||
|
||||
periodsEnabled := finalLog.PeriodsEnabled == "True"
|
||||
customMercyRule, _ := strconv.Atoi(finalLog.CustomMercyRule)
|
||||
matchLength, _ := strconv.Atoi(finalLog.MatchLength)
|
||||
|
||||
var tamperingReasonPtr *string
|
||||
if tamperingDetected {
|
||||
tamperingReasonPtr = &tamperingReason
|
||||
}
|
||||
|
||||
result := &db.FixtureResult{
|
||||
FixtureID: fixture.ID,
|
||||
Winner: winner,
|
||||
HomeScore: homeScore,
|
||||
AwayScore: awayScore,
|
||||
MatchType: finalLog.Type,
|
||||
Arena: finalLog.Arena,
|
||||
EndReason: finalLog.EndReason,
|
||||
PeriodsEnabled: periodsEnabled,
|
||||
CustomMercyRule: customMercyRule,
|
||||
MatchLength: matchLength,
|
||||
UploadedByUserID: user.ID,
|
||||
Finalized: false,
|
||||
TamperingDetected: tamperingDetected,
|
||||
TamperingReason: tamperingReasonPtr,
|
||||
}
|
||||
|
||||
// Build player stats
|
||||
playerStats := []*db.FixtureResultPlayerStats{}
|
||||
for periodIdx, log := range logs {
|
||||
periodNum := periodIdx + 1
|
||||
for _, p := range log.Players {
|
||||
team := p.Team
|
||||
if !fixtureHomeIsLogsHome {
|
||||
if team == "home" {
|
||||
team = "away"
|
||||
} else {
|
||||
team = "home"
|
||||
}
|
||||
}
|
||||
|
||||
var playerID *int
|
||||
var teamID *int
|
||||
if lookup, ok := playerLookup[p.GameUserID]; ok && lookup.Found {
|
||||
playerID = &lookup.Player.ID
|
||||
if !lookup.Unmapped {
|
||||
teamID = &lookup.TeamID
|
||||
}
|
||||
}
|
||||
|
||||
stat := &db.FixtureResultPlayerStats{
|
||||
PeriodNum: periodNum,
|
||||
PlayerID: playerID,
|
||||
PlayerGameUserID: p.GameUserID,
|
||||
PlayerUsername: p.Username,
|
||||
TeamID: teamID,
|
||||
Team: team,
|
||||
Goals: FloatToIntPtr(p.Stats.Goals),
|
||||
Assists: FloatToIntPtr(p.Stats.Assists),
|
||||
PrimaryAssists: FloatToIntPtr(p.Stats.PrimaryAssists),
|
||||
SecondaryAssists: FloatToIntPtr(p.Stats.SecondaryAssists),
|
||||
Saves: FloatToIntPtr(p.Stats.Saves),
|
||||
Blocks: FloatToIntPtr(p.Stats.Blocks),
|
||||
Shots: FloatToIntPtr(p.Stats.Shots),
|
||||
Turnovers: FloatToIntPtr(p.Stats.Turnovers),
|
||||
Takeaways: FloatToIntPtr(p.Stats.Takeaways),
|
||||
Passes: FloatToIntPtr(p.Stats.Passes),
|
||||
PossessionTimeSec: FloatToIntPtr(p.Stats.PossessionTime),
|
||||
FaceoffsWon: FloatToIntPtr(p.Stats.FaceoffsWon),
|
||||
FaceoffsLost: FloatToIntPtr(p.Stats.FaceoffsLost),
|
||||
PostHits: FloatToIntPtr(p.Stats.PostHits),
|
||||
OvertimeGoals: FloatToIntPtr(p.Stats.OvertimeGoals),
|
||||
GameWinningGoals: FloatToIntPtr(p.Stats.GameWinningGoals),
|
||||
Score: FloatToIntPtr(p.Stats.Score),
|
||||
ContributedGoals: FloatToIntPtr(p.Stats.ContributedGoals),
|
||||
ConcededGoals: FloatToIntPtr(p.Stats.ConcededGoals),
|
||||
GamesPlayed: FloatToIntPtr(p.Stats.GamesPlayed),
|
||||
Wins: FloatToIntPtr(p.Stats.Wins),
|
||||
Losses: FloatToIntPtr(p.Stats.Losses),
|
||||
OvertimeWins: FloatToIntPtr(p.Stats.OvertimeWins),
|
||||
OvertimeLosses: FloatToIntPtr(p.Stats.OvertimeLosses),
|
||||
Ties: FloatToIntPtr(p.Stats.Ties),
|
||||
Shutouts: FloatToIntPtr(p.Stats.Shutouts),
|
||||
ShutoutsAgainst: FloatToIntPtr(p.Stats.ShutsAgainst),
|
||||
HasMercyRuled: FloatToIntPtr(p.Stats.HasMercyRuled),
|
||||
WasMercyRuled: FloatToIntPtr(p.Stats.WasMercyRuled),
|
||||
PeriodsPlayed: FloatToIntPtr(p.Stats.PeriodsPlayed),
|
||||
}
|
||||
playerStats = append(playerStats, stat)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark free agents
|
||||
for _, ps := range playerStats {
|
||||
if ps.PlayerID == nil {
|
||||
continue
|
||||
}
|
||||
isFA, err := db.IsFreeAgentRegistered(ctx, tx, fixture.SeasonID, fixture.LeagueID, *ps.PlayerID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.IsFreeAgentRegistered")
|
||||
}
|
||||
if isFA {
|
||||
ps.IsFreeAgent = true
|
||||
}
|
||||
}
|
||||
|
||||
// Insert result
|
||||
_, err = db.InsertFixtureResult(ctx, tx, result, playerStats, audit)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.InsertFixtureResult")
|
||||
}
|
||||
|
||||
// Track wins: home = team1, away = team2
|
||||
if winner == "home" {
|
||||
team1Wins++
|
||||
} else {
|
||||
team2Wins++
|
||||
}
|
||||
}
|
||||
|
||||
// Validate that the series result is valid
|
||||
if team1Wins < series.MatchesToWin && team2Wins < series.MatchesToWin {
|
||||
notify.Warn(s, w, r, "Incomplete Series",
|
||||
"Neither team has enough wins to decide the series. More games are needed.", nil)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
respond.HXRedirect(w, "/series/%d/results/review", seriesID)
|
||||
})
|
||||
}
|
||||
|
||||
// SeriesReviewResults handles GET /series/{series_id}/results/review
|
||||
func SeriesReviewResults(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
var series *db.PlayoffSeries
|
||||
var gameResults []*seasonsview.SeriesGameResult
|
||||
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
series, err = db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||
}
|
||||
if series == nil {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Build game results from matches
|
||||
for _, match := range series.Matches {
|
||||
if match.FixtureID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := db.GetPendingFixtureResult(ctx, tx, *match.FixtureID)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetPendingFixtureResult")
|
||||
}
|
||||
if result == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
gr := &seasonsview.SeriesGameResult{
|
||||
GameNumber: match.MatchNumber,
|
||||
Result: result,
|
||||
}
|
||||
|
||||
// Build unmapped players and FA warnings
|
||||
for _, ps := range result.PlayerStats {
|
||||
if ps.PeriodNum != 3 {
|
||||
continue
|
||||
}
|
||||
if ps.PlayerID == nil {
|
||||
gr.UnmappedPlayers = append(gr.UnmappedPlayers,
|
||||
ps.PlayerGameUserID+" ("+ps.PlayerUsername+")")
|
||||
} else if ps.IsFreeAgent {
|
||||
gr.FreeAgentWarnings = append(gr.FreeAgentWarnings, seasonsview.FreeAgentWarning{
|
||||
Name: ps.PlayerUsername,
|
||||
Reason: "free agent in playoff match",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
gameResults = append(gameResults, gr)
|
||||
}
|
||||
|
||||
if len(gameResults) == 0 {
|
||||
throw.NotFound(s, w, r, r.URL.Path)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
renderSafely(seasonsview.SeriesReviewResultPage(series, gameResults), s, r, w)
|
||||
})
|
||||
}
|
||||
|
||||
// SeriesFinalizeResults handles POST /series/{series_id}/results/finalize
|
||||
func SeriesFinalizeResults(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
_, err := db.FinalizeSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
notify.Warn(s, w, r, "Cannot Finalize", err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.FinalizeSeriesResults")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notify.SuccessWithDelay(s, w, r, "Series Finalized", "All game results have been finalized and the series is complete.", nil)
|
||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||
})
|
||||
}
|
||||
|
||||
// SeriesDiscardResults handles POST /series/{series_id}/results/discard
|
||||
func SeriesDiscardResults(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
err := db.DeleteSeriesResults(ctx, tx, seriesID, db.NewAuditFromRequest(r))
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
notify.Warn(s, w, r, "Cannot Discard", err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.DeleteSeriesResults")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notify.Success(s, w, r, "Results Discarded", "All uploaded results have been discarded. You can upload new logs.", nil)
|
||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user