From f0447c2141b2f5abe6167daefde10dfc91497129 Mon Sep 17 00:00:00 2001 From: Ravi Prasad Date: Mon, 12 May 2025 21:06:03 -0500 Subject: [PATCH] shot chart create --- config/database.go | 1 + controllers/player_shot_chart_controller.go | 73 +++++++++ docs/docs.go | 163 ++++++++++++++++++++ docs/swagger.json | 163 ++++++++++++++++++++ docs/swagger.yaml | 108 +++++++++++++ import.go | 10 ++ main.go | 3 + models/player_shot_chart.go | 4 +- routes/player_shot_chart_routes.go | 13 ++ services/player_shot_chart_service.go | 76 +++++++++ 10 files changed, 612 insertions(+), 2 deletions(-) create mode 100644 controllers/player_shot_chart_controller.go create mode 100644 routes/player_shot_chart_routes.go create mode 100644 services/player_shot_chart_service.go diff --git a/config/database.go b/config/database.go index d4e4f25..962995e 100644 --- a/config/database.go +++ b/config/database.go @@ -21,6 +21,7 @@ func InitDB() *gorm.DB { // Auto migrate models db.AutoMigrate(&models.PlayerAdvancedStat{}) db.AutoMigrate(&models.PlayerTotalStat{}) + db.AutoMigrate(&models.PlayerShotChart{}) db.AutoMigrate(&models.APIKey{}) diff --git a/controllers/player_shot_chart_controller.go b/controllers/player_shot_chart_controller.go new file mode 100644 index 0000000..8e2c1a9 --- /dev/null +++ b/controllers/player_shot_chart_controller.go @@ -0,0 +1,73 @@ +// NBA_Go/controllers/player_shot_chart_controller.go +package controllers + +import ( + "github.com/gofiber/fiber/v2" + "github.com/nprasad2077/NBA_Go/models" + "github.com/nprasad2077/NBA_Go/services" + "gorm.io/gorm" +) + +// FetchPlayerShotChart godoc +// @Summary Fetch a single player's shot-chart data from external API +// @Description Imports shot-chart data for the given playerId and stores/updates in DB +// @Tags PlayerShotChart +// @Accept json +// @Produce json +// @Param playerId query string true "Player ID (e.g., hardeja01)" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/playershotchart/fetch [get] +func FetchPlayerShotChart(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + pid := c.Query("playerId") + if pid == "" { + return c.Status(400).JSON(fiber.Map{ + "error": "playerId query parameter is required", + }) + } + if err := services.FetchAndStoreShotChartForPlayer(db, pid); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "Shot chart for " + pid + " fetched and saved."}) + } +} + +// GetPlayerShotChart godoc +// @Security ApiKeyAuth +// @Summary Get shot-chart data +// @Description Returns shot-chart points, optionally filtered by playerId and/or season +// @Tags PlayerShotChart +// @Accept json +// @Produce json +// @Param playerId query string false "Player ID (e.g., hardeja01)" +// @Param season query int false "Season (e.g., 2023)" +// @Success 200 {array} models.PlayerShotChart +// @Failure 500 {object} map[string]string +// @Router /api/playershotchart [get] +func GetPlayerShotChart(db *gorm.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + var shots []models.PlayerShotChart + + // Start with the base model + query := db.Model(&models.PlayerShotChart{}) + + // Optional filter: playerId + if pid := c.Query("playerId"); pid != "" { + query = query.Where("player_id = ?", pid) + } + + // Optional filter: season + if s := c.QueryInt("season", 0); s != 0 { + query = query.Where("season = ?", s) + } + + // Execute + if err := query.Find(&shots).Error; err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(shots) + } +} \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 78b4cca..b14458b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -106,6 +106,113 @@ const docTemplate = `{ } } }, + "/api/playershotchart": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns shot-chart points, optionally filtered by playerId and/or season", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PlayerShotChart" + ], + "summary": "Get shot-chart data", + "parameters": [ + { + "type": "string", + "description": "Player ID (e.g., hardeja01)", + "name": "playerId", + "in": "query" + }, + { + "type": "integer", + "description": "Season (e.g., 2023)", + "name": "season", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.PlayerShotChart" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/playershotchart/fetch": { + "get": { + "description": "Imports shot-chart data for the given playerId and stores/updates in DB", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PlayerShotChart" + ], + "summary": "Fetch a single player's shot-chart data from external API", + "parameters": [ + { + "type": "string", + "description": "Player ID (e.g., hardeja01)", + "name": "playerId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/playertotals": { "get": { "security": [ @@ -320,6 +427,62 @@ const docTemplate = `{ "type": "number" } } + }, + "models.PlayerShotChart": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "distanceFt": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "lead": { + "type": "boolean" + }, + "left": { + "type": "integer" + }, + "opponent": { + "type": "string" + }, + "opponentTeamScore": { + "type": "integer" + }, + "playerId": { + "type": "string" + }, + "playerName": { + "type": "string" + }, + "qtr": { + "type": "string" + }, + "result": { + "type": "boolean" + }, + "season": { + "type": "integer" + }, + "shotType": { + "type": "string" + }, + "team": { + "type": "string" + }, + "teamScore": { + "type": "integer" + }, + "timeRemaining": { + "type": "string" + }, + "top": { + "type": "integer" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 68ca2dd..5c35fda 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -103,6 +103,113 @@ } } }, + "/api/playershotchart": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns shot-chart points, optionally filtered by playerId and/or season", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PlayerShotChart" + ], + "summary": "Get shot-chart data", + "parameters": [ + { + "type": "string", + "description": "Player ID (e.g., hardeja01)", + "name": "playerId", + "in": "query" + }, + { + "type": "integer", + "description": "Season (e.g., 2023)", + "name": "season", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.PlayerShotChart" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/playershotchart/fetch": { + "get": { + "description": "Imports shot-chart data for the given playerId and stores/updates in DB", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "PlayerShotChart" + ], + "summary": "Fetch a single player's shot-chart data from external API", + "parameters": [ + { + "type": "string", + "description": "Player ID (e.g., hardeja01)", + "name": "playerId", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, "/api/playertotals": { "get": { "security": [ @@ -317,6 +424,62 @@ "type": "number" } } + }, + "models.PlayerShotChart": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "distanceFt": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "lead": { + "type": "boolean" + }, + "left": { + "type": "integer" + }, + "opponent": { + "type": "string" + }, + "opponentTeamScore": { + "type": "integer" + }, + "playerId": { + "type": "string" + }, + "playerName": { + "type": "string" + }, + "qtr": { + "type": "string" + }, + "result": { + "type": "boolean" + }, + "season": { + "type": "integer" + }, + "shotType": { + "type": "string" + }, + "team": { + "type": "string" + }, + "teamScore": { + "type": "integer" + }, + "timeRemaining": { + "type": "string" + }, + "top": { + "type": "integer" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0bd4364..a500f04 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -81,6 +81,43 @@ definitions: winSharesPer: type: number type: object + models.PlayerShotChart: + properties: + date: + type: string + distanceFt: + type: integer + id: + type: integer + lead: + type: boolean + left: + type: integer + opponent: + type: string + opponentTeamScore: + type: integer + playerId: + type: string + playerName: + type: string + qtr: + type: string + result: + type: boolean + season: + type: integer + shotType: + type: string + team: + type: string + teamScore: + type: integer + timeRemaining: + type: string + top: + type: integer + type: object info: contact: {} description: Stats service with API-key auth @@ -147,6 +184,77 @@ paths: summary: Get player advanced stats tags: - PlayerStats + /api/playershotchart: + get: + consumes: + - application/json + description: Returns shot-chart points, optionally filtered by playerId and/or + season + parameters: + - description: Player ID (e.g., hardeja01) + in: query + name: playerId + type: string + - description: Season (e.g., 2023) + in: query + name: season + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.PlayerShotChart' + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - ApiKeyAuth: [] + summary: Get shot-chart data + tags: + - PlayerShotChart + /api/playershotchart/fetch: + get: + consumes: + - application/json + description: Imports shot-chart data for the given playerId and stores/updates + in DB + parameters: + - description: Player ID (e.g., hardeja01) + in: query + name: playerId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Fetch a single player's shot-chart data from external API + tags: + - PlayerShotChart /api/playertotals: get: consumes: diff --git a/import.go b/import.go index 617e746..2b482bb 100644 --- a/import.go +++ b/import.go @@ -48,3 +48,13 @@ func importPlayerPlayoffs(db *gorm.DB) { time.Sleep(1500 * time.Millisecond) } } + +// importPlayerShotChart fetches shot-charts for every known player +func importPlayerShotChart(db *gorm.DB) { + const firstID = "hardeja01" + log.Printf("ā–¶ļø importing shot chart for player %s…", firstID) + if err := services.FetchAndStoreShotChartForPlayer(db, firstID); err != nil { + log.Printf("shot chart import failed for %s: %v", firstID, err) + } + // you can add more IDs here or just rely on the API endpoint after +} diff --git a/main.go b/main.go index a6e51c4..c18ee10 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,8 @@ func main() { log.Println("šŸŽ‰ Player Totals Import completed successfully") importPlayerPlayoffs(db) log.Println("šŸŽ‰ Player Totals Playoffs Import completed successfully") + importPlayerShotChart(db) + log.Println("šŸŽ‰ Player Shot Chart Import Completed Successfully ") log.Println("šŸŽ‰ Import completed successfully") return } @@ -80,6 +82,7 @@ func main() { app.Use(middleware.APIKeyAuth(db)) routes.RegisterPlayerAdvancedRoutes(app, db) routes.RegisterPlayerTotalRoutes(app, db) + routes.RegisterPlayerShotChartRoutes(app, db) /* ---------- START & SHUTDOWN ---------- */ go func() { diff --git a/models/player_shot_chart.go b/models/player_shot_chart.go index 2023192..066c3d0 100644 --- a/models/player_shot_chart.go +++ b/models/player_shot_chart.go @@ -5,8 +5,8 @@ import "gorm.io/gorm" type PlayerShotChart struct { gorm.Model `swaggerignore:"true"` - ExternalID int `json:"id"` - PlayerID string `gorm:"not null;index:idx_shotchart_player_external,unique" json:"playerId"` + ExternalID int `gorm:"not null;uniqueIndex:idx_shotchart_player_external" json:"id"` + PlayerID string `gorm:"not null;uniqueIndex:idx_shotchart_player_external" json:"playerId"` PlayerName string `json:"playerName"` Top int `json:"top"` Left int `json:"left"` diff --git a/routes/player_shot_chart_routes.go b/routes/player_shot_chart_routes.go new file mode 100644 index 0000000..f91095f --- /dev/null +++ b/routes/player_shot_chart_routes.go @@ -0,0 +1,13 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + "github.com/nprasad2077/NBA_Go/controllers" + "gorm.io/gorm" +) + +func RegisterPlayerShotChartRoutes(app *fiber.App, db *gorm.DB) { + api := app.Group("/api/playershotchart") + api.Get("/fetch", controllers.FetchPlayerShotChart(db)) + api.Get("/", controllers.GetPlayerShotChart(db)) +} \ No newline at end of file diff --git a/services/player_shot_chart_service.go b/services/player_shot_chart_service.go new file mode 100644 index 0000000..d8e653f --- /dev/null +++ b/services/player_shot_chart_service.go @@ -0,0 +1,76 @@ +package services + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/nprasad2077/NBA_Go/models" + "github.com/nprasad2077/NBA_Go/utils" + "github.com/nprasad2077/NBA_Go/utils/metrics" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// FetchAndStoreShotChartForPlayer fetches a single player's shot chart, parses +// each shot's date into a season, and upserts into DB. +func FetchAndStoreShotChartForPlayer(db *gorm.DB, playerId string) error { + metrics.DBOperationsTotal.WithLabelValues("fetch", "player_shot_chart").Inc() + + url := fmt.Sprintf("http://rest.nbaapi.com/api/ShotChartData/playerid/%s", playerId) + body, err := utils.GetJSON(url) + if err != nil { + return err + } + + var shots []models.PlayerShotChart + if err := json.Unmarshal(body, &shots); err != nil { + return err + } + + for _, shot := range shots { + // parse date "Feb 11, 2023" → time.Time + if t, err := time.Parse("Jan 2, 2006", shot.Date); err == nil { + shot.Season = t.Year() + } + + err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "player_id"}, {Name: "external_id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "player_name", "top", "left", "date", "qtr", + "time_remaining", "result", "shot_type", "distance_ft", + "lead", "team_score", "opponent_team_score", "opponent", + "team", "season", + }), + }).Create(&shot).Error + + metrics.DBOperationsTotal.WithLabelValues("store", "player_shot_chart").Inc() + + if err != nil { + log.Printf("Failed to upsert shot chart for %s id=%d: %v", + shot.PlayerID, shot.ExternalID, err) + } + } + return nil +} + +// FetchAndStoreAllPlayerShotCharts loads every distinct playerId from your stats +// tables and invokes the per-player fetch. +func FetchAndStoreAllPlayerShotCharts(db *gorm.DB) error { + var playerIds []string + + // gather from total stats (could also include advanced stats) + db.Model(&models.PlayerTotalStat{}). + Distinct("player_id"). + Pluck("player_id", &playerIds) + + for _, pid := range playerIds { + if err := FetchAndStoreShotChartForPlayer(db, pid); err != nil { + log.Printf("Error importing shot chart for %s: %v", pid, err) + } + // throttle + time.Sleep(1100 * time.Millisecond) + } + return nil +} \ No newline at end of file