diff --git a/go-bracket/Dockerfile b/go-bracket/Dockerfile new file mode 100644 index 0000000..99616ad --- /dev/null +++ b/go-bracket/Dockerfile @@ -0,0 +1,38 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Install dependencies +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates sqlite +WORKDIR /root/ + +# Copy the binary from builder stage +COPY --from=builder /app/main . + +# Create directory for SQLite database +RUN mkdir -p /data + +# Expose port +EXPOSE 8080 + +# Set environment variables +ENV DB_DRIVER=sqlite +ENV DB_NAME=/data/bracket.db +ENV GIN_MODE=release + +# Run the application +CMD ["./main"] + diff --git a/go-bracket/README.md b/go-bracket/README.md new file mode 100644 index 0000000..ef37dcd --- /dev/null +++ b/go-bracket/README.md @@ -0,0 +1,208 @@ +# Go Bracket - NCAA Tournament Bracket API + +A modern Go implementation of the NCAA Basketball Tournament Bracket system, featuring a REST API built with Gin and GORM. + +## Features + +- **RESTful API** with JWT authentication +- **Multi-database support** (PostgreSQL, SQLite) +- **Tournament bracket management** with automatic scoring +- **User registration and authentication** +- **Admin interface** for managing tournament results +- **Real-time leaderboard** +- **Docker support** for easy deployment + +## Architecture + +``` +go-bracket/ +├── cmd/server/ # Application entry point +├── internal/ +│ ├── models/ # Database models +│ ├── handlers/ # HTTP handlers +│ ├── middleware/ # HTTP middleware +│ └── database/ # Database configuration +├── pkg/ +│ ├── auth/ # Authentication utilities +│ └── tournament/ # Tournament business logic +├── configs/ # Configuration files +├── migrations/ # Database migrations +└── docker/ # Docker configuration +``` + +## Quick Start + +### Using Docker Compose (Recommended) + +```bash +# Start with PostgreSQL +docker-compose up -d + +# Or start with SQLite +docker-compose --profile sqlite up -d bracket-sqlite +``` + +### Manual Setup + +1. **Install dependencies:** +```bash +go mod download +``` + +2. **Set environment variables:** +```bash +export DB_DRIVER=sqlite +export DB_NAME=bracket.db +``` + +3. **Run the application:** +```bash +go run cmd/server/main.go +``` + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/register` - Register new user +- `POST /api/v1/auth/login` - Login user +- `GET /api/v1/auth/profile` - Get user profile (protected) + +### Tournament +- `GET /api/v1/bracket` - Get tournament bracket +- `GET /api/v1/teams` - Get all teams +- `GET /api/v1/regions` - Get all regions +- `GET /api/v1/leaderboard` - Get current leaderboard + +### Player Actions (Protected) +- `GET /api/v1/bracket/player/:id` - Get player's bracket +- `POST /api/v1/bracket/pick` - Make a pick + +### Admin Actions (Admin Only) +- `PUT /api/v1/admin/bracket/game/:id/result` - Update game result + +## API Usage Examples + +### Register a new user +```bash +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "login": "john_doe", + "password": "password123", + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com" + }' +``` + +### Login +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "login": "john_doe", + "password": "password123" + }' +``` + +### Make a pick (requires authentication) +```bash +curl -X POST http://localhost:8080/api/v1/bracket/pick \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{ + "game_id": 1, + "team_id": 5 + }' +``` + +### Get leaderboard +```bash +curl http://localhost:8080/api/v1/leaderboard +``` + +## Database Models + +The application uses the following core models: + +- **Region**: Tournament regions (East, Midwest, South, West) +- **Team**: Tournament teams with seeding +- **Player**: User accounts with authentication +- **Game**: Tournament games with results +- **Pick**: Player predictions for games +- **Tournament**: Tournament metadata + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DB_DRIVER` | `sqlite` | Database driver (sqlite, postgres) | +| `DB_HOST` | `localhost` | Database host | +| `DB_PORT` | `5432` | Database port | +| `DB_USER` | `bracket` | Database user | +| `DB_PASSWORD` | `` | Database password | +| `DB_NAME` | `bracket.db` | Database name | +| `DB_SSLMODE` | `disable` | SSL mode for PostgreSQL | +| `PORT` | `8080` | Server port | + +## Development + +### Running tests +```bash +go test ./... +``` + +### Building for production +```bash +CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -o bracket ./cmd/server +``` + +## Deployment + +The application can be deployed using: + +1. **Docker** - Use the provided Dockerfile +2. **Docker Compose** - For development with PostgreSQL +3. **Binary** - Compile and run the binary directly +4. **Cloud platforms** - Deploy to AWS, GCP, Azure, etc. + +## Tournament Logic + +The bracket system supports: + +- **64-team single elimination tournament** +- **4 regions with 16 teams each** +- **6 rounds**: First Round → Second Round → Sweet 16 → Elite 8 → Final Four → Championship +- **Progressive scoring**: Later rounds worth more points +- **Real-time score calculation** +- **Admin controls** for updating game results + +## Security Features + +- **JWT-based authentication** +- **Password hashing** with bcrypt +- **CORS support** +- **Admin role separation** +- **Input validation** + +## Performance Considerations + +- **Database indexing** on foreign keys +- **Efficient queries** with GORM preloading +- **Stateless design** for horizontal scaling +- **Connection pooling** for database connections + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests +5. Submit a pull request + +## License + +This project maintains the same license as the original Perl implementation. + diff --git a/go-bracket/cmd/server/main.go b/go-bracket/cmd/server/main.go new file mode 100644 index 0000000..7f8fbf3 --- /dev/null +++ b/go-bracket/cmd/server/main.go @@ -0,0 +1,87 @@ +package main + +import ( + "log" + "os" + + "bracket/internal/database" + "bracket/internal/handlers" + "bracket/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func main() { + // Initialize database + dbConfig := database.Config{ + Driver: getEnv("DB_DRIVER", "sqlite"), + Host: getEnv("DB_HOST", "localhost"), + Port: getEnv("DB_PORT", "5432"), + User: getEnv("DB_USER", "bracket"), + Password: getEnv("DB_PASSWORD", ""), + DBName: getEnv("DB_NAME", "bracket.db"), + SSLMode: getEnv("DB_SSLMODE", "disable"), + } + + if err := database.Initialize(dbConfig); err != nil { + log.Fatal("Failed to initialize database:", err) + } + + // Initialize Gin router + r := gin.Default() + + // Add middleware + r.Use(middleware.CORSMiddleware()) + + // Initialize handlers + authHandler := handlers.NewAuthHandler() + bracketHandler := handlers.NewBracketHandler() + + // Public routes + public := r.Group("/api/v1") + { + public.POST("/auth/login", authHandler.Login) + public.POST("/auth/register", authHandler.Register) + public.GET("/bracket", bracketHandler.GetBracket) + public.GET("/teams", bracketHandler.GetTeams) + public.GET("/regions", bracketHandler.GetRegions) + public.GET("/leaderboard", bracketHandler.GetLeaderboard) + } + + // Protected routes (require authentication) + protected := r.Group("/api/v1") + protected.Use(middleware.AuthMiddleware()) + { + protected.GET("/auth/profile", authHandler.GetProfile) + protected.GET("/bracket/player/:player_id", bracketHandler.GetPlayerBracket) + protected.POST("/bracket/pick", bracketHandler.CreatePick) + } + + // Admin routes + admin := r.Group("/api/v1/admin") + admin.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware()) + { + admin.PUT("/bracket/game/:game_id/result", bracketHandler.UpdateGameResult) + } + + // Health check + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + // Start server + port := getEnv("PORT", "8080") + log.Printf("Starting server on port %s", port) + if err := r.Run(":" + port); err != nil { + log.Fatal("Failed to start server:", err) + } +} + +// getEnv gets an environment variable with a default value +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + diff --git a/go-bracket/docker-compose.yml b/go-bracket/docker-compose.yml new file mode 100644 index 0000000..2180651 --- /dev/null +++ b/go-bracket/docker-compose.yml @@ -0,0 +1,47 @@ +version: '3.8' + +services: + bracket-api: + build: . + ports: + - "8080:8080" + environment: + - DB_DRIVER=postgres + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USER=bracket + - DB_PASSWORD=bracket123 + - DB_NAME=bracket + - DB_SSLMODE=disable + depends_on: + - postgres + volumes: + - ./data:/data + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_USER=bracket + - POSTGRES_PASSWORD=bracket123 + - POSTGRES_DB=bracket + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + # SQLite version (alternative) + bracket-sqlite: + build: . + ports: + - "8081:8080" + environment: + - DB_DRIVER=sqlite + - DB_NAME=/data/bracket.db + volumes: + - ./data:/data + profiles: + - sqlite + +volumes: + postgres_data: + diff --git a/go-bracket/go.mod b/go-bracket/go.mod new file mode 100644 index 0000000..1c0ae6f --- /dev/null +++ b/go-bracket/go.mod @@ -0,0 +1,48 @@ +module bracket + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/golang-jwt/jwt/v5 v5.0.0 + github.com/golang-migrate/migrate/v4 v4.16.2 + golang.org/x/crypto v0.14.0 + gorm.io/driver/postgres v1.5.4 + gorm.io/driver/sqlite v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect +) + diff --git a/go-bracket/internal/database/database.go b/go-bracket/internal/database/database.go new file mode 100644 index 0000000..4908ace --- /dev/null +++ b/go-bracket/internal/database/database.go @@ -0,0 +1,107 @@ +package database + +import ( + "fmt" + "log" + + "bracket/internal/models" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type Config struct { + Driver string + Host string + Port string + User string + Password string + DBName string + SSLMode string +} + +var DB *gorm.DB + +// Initialize sets up the database connection +func Initialize(config Config) error { + var err error + var dialector gorm.Dialector + + switch config.Driver { + case "postgres": + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s", + config.Host, config.User, config.Password, config.DBName, config.Port, config.SSLMode) + dialector = postgres.Open(dsn) + case "sqlite": + dialector = sqlite.Open(config.DBName) + default: + return fmt.Errorf("unsupported database driver: %s", config.Driver) + } + + DB, err = gorm.Open(dialector, &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + }) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + // Run migrations + if err := models.AutoMigrate(DB); err != nil { + return fmt.Errorf("failed to run migrations: %w", err) + } + + // Seed initial data + if err := seedInitialData(); err != nil { + return fmt.Errorf("failed to seed initial data: %w", err) + } + + log.Println("Database initialized successfully") + return nil +} + +// seedInitialData populates the database with initial tournament structure +func seedInitialData() error { + // Check if regions already exist + var count int64 + DB.Model(&models.Region{}).Count(&count) + if count > 0 { + return nil // Already seeded + } + + // Create regions + regions := []models.Region{ + {ID: 1, Name: "East"}, + {ID: 2, Name: "Midwest"}, + {ID: 3, Name: "South"}, + {ID: 4, Name: "West"}, + } + + for _, region := range regions { + if err := DB.Create(®ion).Error; err != nil { + return fmt.Errorf("failed to create region %s: %w", region.Name, err) + } + } + + // Create sample tournament + tournament := models.Tournament{ + Year: 2024, + Name: "NCAA Men's Basketball Tournament 2024", + IsActive: true, + PicksLocked: false, + } + + if err := DB.Create(&tournament).Error; err != nil { + return fmt.Errorf("failed to create tournament: %w", err) + } + + log.Println("Initial data seeded successfully") + return nil +} + +// GetDB returns the database instance +func GetDB() *gorm.DB { + return DB +} + diff --git a/go-bracket/internal/handlers/auth.go b/go-bracket/internal/handlers/auth.go new file mode 100644 index 0000000..56da432 --- /dev/null +++ b/go-bracket/internal/handlers/auth.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "net/http" + + "bracket/internal/database" + "bracket/internal/models" + "bracket/pkg/auth" + + "github.com/gin-gonic/gin" +) + +type AuthHandler struct{} + +func NewAuthHandler() *AuthHandler { + return &AuthHandler{} +} + +type LoginRequest struct { + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type RegisterRequest struct { + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` + FirstName string `json:"first_name" binding:"required"` + LastName string `json:"last_name" binding:"required"` + Email string `json:"email" binding:"required,email"` +} + +type AuthResponse struct { + Token string `json:"token"` + Player models.Player `json:"player"` +} + +// Login authenticates a user and returns a JWT token +func (ah *AuthHandler) Login(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + var player models.Player + + // Find player by login + if err := db.Where("login = ? AND active = ?", req.Login, true).First(&player).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + // Check password + if !auth.CheckPassword(req.Password, player.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + // Generate token + token, err := auth.GenerateToken(&player) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + c.JSON(http.StatusOK, AuthResponse{ + Token: token, + Player: player, + }) +} + +// Register creates a new user account +func (ah *AuthHandler) Register(c *gin.Context) { + var req RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + + // Check if login or email already exists + var existingPlayer models.Player + if err := db.Where("login = ? OR email = ?", req.Login, req.Email).First(&existingPlayer).Error; err == nil { + c.JSON(http.StatusConflict, gin.H{"error": "Login or email already exists"}) + return + } + + // Hash password + hashedPassword, err := auth.HashPassword(req.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + // Create new player + player := models.Player{ + Login: req.Login, + Password: hashedPassword, + FirstName: req.FirstName, + LastName: req.LastName, + Email: req.Email, + IsAdmin: false, + Active: true, + } + + if err := db.Create(&player).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create account"}) + return + } + + // Generate token + token, err := auth.GenerateToken(&player) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + c.JSON(http.StatusCreated, AuthResponse{ + Token: token, + Player: player, + }) +} + +// GetProfile returns the current user's profile +func (ah *AuthHandler) GetProfile(c *gin.Context) { + playerID, exists := c.Get("player_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + db := database.GetDB() + var player models.Player + + if err := db.First(&player, playerID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Player not found"}) + return + } + + c.JSON(http.StatusOK, player) +} + diff --git a/go-bracket/internal/handlers/bracket.go b/go-bracket/internal/handlers/bracket.go new file mode 100644 index 0000000..d19ca32 --- /dev/null +++ b/go-bracket/internal/handlers/bracket.go @@ -0,0 +1,232 @@ +package handlers + +import ( + "net/http" + "strconv" + + "bracket/internal/database" + "bracket/internal/models" + "bracket/pkg/tournament" + + "github.com/gin-gonic/gin" +) + +type BracketHandler struct { + bracketService *tournament.BracketService +} + +func NewBracketHandler() *BracketHandler { + return &BracketHandler{ + bracketService: tournament.NewBracketService(), + } +} + +type CreatePickRequest struct { + GameID uint `json:"game_id" binding:"required"` + TeamID uint `json:"team_id" binding:"required"` +} + +type UpdateGameResultRequest struct { + WinnerID uint `json:"winner_id" binding:"required"` +} + +// GetBracket returns the current tournament bracket +func (bh *BracketHandler) GetBracket(c *gin.Context) { + status, err := bh.bracketService.GetBracketStatus() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, status) +} + +// GetPlayerBracket returns a player's picks/bracket +func (bh *BracketHandler) GetPlayerBracket(c *gin.Context) { + playerIDStr := c.Param("player_id") + playerID, err := strconv.ParseUint(playerIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid player ID"}) + return + } + + db := database.GetDB() + var picks []models.Pick + + if err := db.Preload("Game").Preload("Pick").Where("player_id = ?", uint(playerID)).Find(&picks).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get player bracket"}) + return + } + + // Calculate player's current score + score, err := bh.bracketService.CalculatePlayerScore(uint(playerID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to calculate score"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "picks": picks, + "score": score, + }) +} + +// CreatePick allows a player to make a pick for a game +func (bh *BracketHandler) CreatePick(c *gin.Context) { + playerID, exists := c.Get("player_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + var req CreatePickRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := database.GetDB() + + // Check if tournament picks are locked + var tournament models.Tournament + if err := db.Where("is_active = ?", true).First(&tournament).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "No active tournament"}) + return + } + + if tournament.PicksLocked { + c.JSON(http.StatusForbidden, gin.H{"error": "Picks are locked for this tournament"}) + return + } + + // Verify the game exists and the team is valid for that game + var game models.Game + if err := db.First(&game, req.GameID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Game not found"}) + return + } + + // Check if team is valid for this game + if game.Team1ID != nil && game.Team2ID != nil { + if req.TeamID != *game.Team1ID && req.TeamID != *game.Team2ID { + c.JSON(http.StatusBadRequest, gin.H{"error": "Team is not playing in this game"}) + return + } + } + + // Check if pick already exists, update if so + var existingPick models.Pick + if err := db.Where("player_id = ? AND game_id = ?", playerID, req.GameID).First(&existingPick).Error; err == nil { + // Update existing pick + existingPick.PickID = req.TeamID + if err := db.Save(&existingPick).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update pick"}) + return + } + c.JSON(http.StatusOK, existingPick) + return + } + + // Create new pick + pick := models.Pick{ + PlayerID: playerID.(uint), + GameID: req.GameID, + PickID: req.TeamID, + } + + if err := db.Create(&pick).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create pick"}) + return + } + + c.JSON(http.StatusCreated, pick) +} + +// UpdateGameResult updates the result of a game (admin only) +func (bh *BracketHandler) UpdateGameResult(c *gin.Context) { + // Check if user is admin + isAdmin, exists := c.Get("is_admin") + if !exists || !isAdmin.(bool) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + gameIDStr := c.Param("game_id") + gameID, err := strconv.ParseUint(gameIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid game ID"}) + return + } + + var req UpdateGameResultRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := bh.bracketService.UpdateGameResult(uint(gameID), req.WinnerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Game result updated successfully"}) +} + +// GetLeaderboard returns the current tournament leaderboard +func (bh *BracketHandler) GetLeaderboard(c *gin.Context) { + db := database.GetDB() + + var players []models.Player + if err := db.Where("active = ?", true).Find(&players).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get players"}) + return + } + + type LeaderboardEntry struct { + Player models.Player `json:"player"` + Score uint `json:"score"` + } + + var leaderboard []LeaderboardEntry + + for _, player := range players { + score, err := bh.bracketService.CalculatePlayerScore(player.ID) + if err != nil { + continue // Skip players with calculation errors + } + + leaderboard = append(leaderboard, LeaderboardEntry{ + Player: player, + Score: score, + }) + } + + c.JSON(http.StatusOK, leaderboard) +} + +// GetTeams returns all tournament teams +func (bh *BracketHandler) GetTeams(c *gin.Context) { + db := database.GetDB() + + var teams []models.Team + if err := db.Preload("Region").Find(&teams).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get teams"}) + return + } + + c.JSON(http.StatusOK, teams) +} + +// GetRegions returns all tournament regions +func (bh *BracketHandler) GetRegions(c *gin.Context) { + db := database.GetDB() + + var regions []models.Region + if err := db.Find(®ions).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get regions"}) + return + } + + c.JSON(http.StatusOK, regions) +} + diff --git a/go-bracket/internal/middleware/auth.go b/go-bracket/internal/middleware/auth.go new file mode 100644 index 0000000..58fe97c --- /dev/null +++ b/go-bracket/internal/middleware/auth.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "net/http" + "strings" + + "bracket/pkg/auth" + + "github.com/gin-gonic/gin" +) + +// AuthMiddleware validates JWT tokens +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + c.Abort() + return + } + + // Extract token from "Bearer " + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"}) + c.Abort() + return + } + + token := tokenParts[1] + claims, err := auth.ValidateToken(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"}) + c.Abort() + return + } + + // Set user information in context + c.Set("player_id", claims.PlayerID) + c.Set("login", claims.Login) + c.Set("is_admin", claims.IsAdmin) + + c.Next() + } +} + +// AdminMiddleware ensures the user is an admin +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + isAdmin, exists := c.Get("is_admin") + if !exists || !isAdmin.(bool) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + c.Abort() + return + } + + c.Next() + } +} + +// CORSMiddleware handles CORS headers +func CORSMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + diff --git a/go-bracket/internal/models/models.go b/go-bracket/internal/models/models.go new file mode 100644 index 0000000..7dfc103 --- /dev/null +++ b/go-bracket/internal/models/models.go @@ -0,0 +1,124 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Region represents the four tournament regions +type Region struct { + ID uint `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"unique;not null"` +} + +// Team represents a tournament team +type Team struct { + ID uint `json:"id" gorm:"primaryKey"` + Seed uint8 `json:"seed" gorm:"not null"` + Name string `json:"name" gorm:"unique;not null"` + RegionID uint `json:"region_id" gorm:"not null"` + URL string `json:"url"` + Region Region `json:"region" gorm:"foreignKey:RegionID"` +} + +// Player represents a user/player in the system +type Player struct { + ID uint `json:"id" gorm:"primaryKey"` + Login string `json:"login" gorm:"unique;not null"` + Password string `json:"-" gorm:"not null"` // Hidden from JSON + FirstName string `json:"first_name" gorm:"not null"` + LastName string `json:"last_name" gorm:"not null"` + Email string `json:"email" gorm:"unique;not null"` + IsAdmin bool `json:"is_admin" gorm:"default:false"` + Active bool `json:"active" gorm:"default:true"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Game represents a tournament game +type Game struct { + ID uint `json:"id" gorm:"primaryKey"` + Round uint8 `json:"round" gorm:"not null"` + RegionID *uint `json:"region_id"` // Null for Final Four and Championship + Team1ID *uint `json:"team1_id"` + Team2ID *uint `json:"team2_id"` + WinnerID *uint `json:"winner_id"` + + // Relationships + Region *Region `json:"region,omitempty" gorm:"foreignKey:RegionID"` + Team1 *Team `json:"team1,omitempty" gorm:"foreignKey:Team1ID"` + Team2 *Team `json:"team2,omitempty" gorm:"foreignKey:Team2ID"` + Winner *Team `json:"winner,omitempty" gorm:"foreignKey:WinnerID"` + + // Scoring weight for this game (higher rounds worth more) + PointValue uint `json:"point_value" gorm:"default:1"` +} + +// Pick represents a player's prediction for a game +type Pick struct { + ID uint `json:"id" gorm:"primaryKey"` + PlayerID uint `json:"player_id" gorm:"not null"` + GameID uint `json:"game_id" gorm:"not null"` + PickID uint `json:"pick_id" gorm:"not null"` // Team ID they picked to win + + // Relationships + Player Player `json:"player" gorm:"foreignKey:PlayerID"` + Game Game `json:"game" gorm:"foreignKey:GameID"` + Pick Team `json:"pick" gorm:"foreignKey:PickID"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RegionScore tracks player scores by region +type RegionScore struct { + ID uint `json:"id" gorm:"primaryKey"` + PlayerID uint `json:"player_id" gorm:"not null"` + RegionID uint `json:"region_id" gorm:"not null"` + Points uint `json:"points" gorm:"default:0"` + + // Relationships + Player Player `json:"player" gorm:"foreignKey:PlayerID"` + Region Region `json:"region" gorm:"foreignKey:RegionID"` +} + +// Session represents user sessions +type Session struct { + ID string `json:"id" gorm:"primaryKey"` + PlayerID uint `json:"player_id" gorm:"not null"` + Data string `json:"data"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + + // Relationships + Player Player `json:"player" gorm:"foreignKey:PlayerID"` +} + +// Tournament represents tournament metadata +type Tournament struct { + ID uint `json:"id" gorm:"primaryKey"` + Year uint `json:"year" gorm:"unique;not null"` + Name string `json:"name" gorm:"not null"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + IsActive bool `json:"is_active" gorm:"default:false"` + PicksLocked bool `json:"picks_locked" gorm:"default:false"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AutoMigrate runs database migrations +func AutoMigrate(db *gorm.DB) error { + return db.AutoMigrate( + &Region{}, + &Team{}, + &Player{}, + &Game{}, + &Pick{}, + &RegionScore{}, + &Session{}, + &Tournament{}, + ) +} + diff --git a/go-bracket/pkg/auth/auth.go b/go-bracket/pkg/auth/auth.go new file mode 100644 index 0000000..8906d23 --- /dev/null +++ b/go-bracket/pkg/auth/auth.go @@ -0,0 +1,68 @@ +package auth + +import ( + "errors" + "time" + + "bracket/internal/models" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var jwtSecret = []byte("your-secret-key") // In production, use environment variable + +type Claims struct { + PlayerID uint `json:"player_id"` + Login string `json:"login"` + IsAdmin bool `json:"is_admin"` + jwt.RegisteredClaims +} + +// HashPassword creates a bcrypt hash of the password +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + return string(bytes), err +} + +// CheckPassword compares a password with its hash +func CheckPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// GenerateToken creates a JWT token for a player +func GenerateToken(player *models.Player) (string, error) { + expirationTime := time.Now().Add(24 * time.Hour) + claims := &Claims{ + PlayerID: player.ID, + Login: player.Login, + IsAdmin: player.IsAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// ValidateToken parses and validates a JWT token +func ValidateToken(tokenString string) (*Claims, error) { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, errors.New("invalid token") + } + + return claims, nil +} + diff --git a/go-bracket/pkg/tournament/bracket.go b/go-bracket/pkg/tournament/bracket.go new file mode 100644 index 0000000..4ed135a --- /dev/null +++ b/go-bracket/pkg/tournament/bracket.go @@ -0,0 +1,222 @@ +package tournament + +import ( + "errors" + "fmt" + + "bracket/internal/database" + "bracket/internal/models" +) + +// BracketService handles tournament bracket logic +type BracketService struct{} + +// NewBracketService creates a new bracket service +func NewBracketService() *BracketService { + return &BracketService{} +} + +// CreateBracket initializes a tournament bracket with games +func (bs *BracketService) CreateBracket(tournamentID uint) error { + db := database.GetDB() + + // Get all teams + var teams []models.Team + if err := db.Preload("Region").Find(&teams).Error; err != nil { + return fmt.Errorf("failed to get teams: %w", err) + } + + if len(teams) != 64 { + return errors.New("tournament requires exactly 64 teams") + } + + // Group teams by region + regionTeams := make(map[uint][]models.Team) + for _, team := range teams { + regionTeams[team.RegionID] = append(regionTeams[team.RegionID], team) + } + + // Create regional games (rounds 1-4) + gameID := uint(1) + for regionID, teams := range regionTeams { + if len(teams) != 16 { + return fmt.Errorf("region %d must have exactly 16 teams", regionID) + } + + // Sort teams by seed for proper bracket pairing + // In a real implementation, you'd sort by seed + gameID = bs.createRegionalGames(teams, regionID, gameID) + } + + // Create Final Four games (semi-finals and championship) + bs.createFinalFourGames(gameID) + + return nil +} + +// createRegionalGames creates games for a single region +func (bs *BracketService) createRegionalGames(teams []models.Team, regionID uint, startGameID uint) uint { + db := database.GetDB() + gameID := startGameID + + // Round 1: 16 teams -> 8 games + round1Games := make([]models.Game, 8) + for i := 0; i < 8; i++ { + game := models.Game{ + ID: gameID, + Round: 1, + RegionID: ®ionID, + Team1ID: &teams[i*2].ID, + Team2ID: &teams[i*2+1].ID, + PointValue: 1, + } + round1Games[i] = game + db.Create(&game) + gameID++ + } + + // Round 2: 8 winners -> 4 games + for i := 0; i < 4; i++ { + game := models.Game{ + ID: gameID, + Round: 2, + RegionID: ®ionID, + PointValue: 2, + } + db.Create(&game) + gameID++ + } + + // Round 3 (Sweet 16): 4 winners -> 2 games + for i := 0; i < 2; i++ { + game := models.Game{ + ID: gameID, + Round: 3, + RegionID: ®ionID, + PointValue: 4, + } + db.Create(&game) + gameID++ + } + + // Round 4 (Elite 8): 2 winners -> 1 game (Regional Championship) + game := models.Game{ + ID: gameID, + Round: 4, + RegionID: ®ionID, + PointValue: 8, + } + db.Create(&game) + gameID++ + + return gameID +} + +// createFinalFourGames creates the Final Four and Championship games +func (bs *BracketService) createFinalFourGames(startGameID uint) { + db := database.GetDB() + + // Final Four (2 games) + for i := 0; i < 2; i++ { + game := models.Game{ + ID: startGameID + uint(i), + Round: 5, + RegionID: nil, // No region for Final Four + PointValue: 16, + } + db.Create(&game) + } + + // Championship game + championship := models.Game{ + ID: startGameID + 2, + Round: 6, + RegionID: nil, + PointValue: 32, + } + db.Create(&championship) +} + +// CalculatePlayerScore calculates a player's total score +func (bs *BracketService) CalculatePlayerScore(playerID uint) (uint, error) { + db := database.GetDB() + + var totalScore uint + var picks []models.Pick + + // Get all picks for the player + if err := db.Preload("Game").Preload("Pick").Where("player_id = ?", playerID).Find(&picks).Error; err != nil { + return 0, fmt.Errorf("failed to get player picks: %w", err) + } + + // Calculate score for each correct pick + for _, pick := range picks { + if pick.Game.WinnerID != nil && *pick.Game.WinnerID == pick.PickID { + totalScore += pick.Game.PointValue + } + } + + return totalScore, nil +} + +// GetBracketStatus returns the current state of the tournament bracket +func (bs *BracketService) GetBracketStatus() (*BracketStatus, error) { + db := database.GetDB() + + var games []models.Game + if err := db.Preload("Team1").Preload("Team2").Preload("Winner").Preload("Region").Find(&games).Error; err != nil { + return nil, fmt.Errorf("failed to get games: %w", err) + } + + status := &BracketStatus{ + Games: games, + TotalGames: len(games), + CompletedGames: 0, + } + + for _, game := range games { + if game.WinnerID != nil { + status.CompletedGames++ + } + } + + return status, nil +} + +// BracketStatus represents the current tournament status +type BracketStatus struct { + Games []models.Game `json:"games"` + TotalGames int `json:"total_games"` + CompletedGames int `json:"completed_games"` +} + +// UpdateGameResult updates the winner of a game +func (bs *BracketService) UpdateGameResult(gameID, winnerID uint) error { + db := database.GetDB() + + // Verify the winner is one of the teams in the game + var game models.Game + if err := db.First(&game, gameID).Error; err != nil { + return fmt.Errorf("game not found: %w", err) + } + + if game.Team1ID == nil || game.Team2ID == nil { + return errors.New("game teams not set") + } + + if winnerID != *game.Team1ID && winnerID != *game.Team2ID { + return errors.New("winner must be one of the teams in the game") + } + + // Update the game result + game.WinnerID = &winnerID + if err := db.Save(&game).Error; err != nil { + return fmt.Errorf("failed to update game result: %w", err) + } + + // TODO: Advance winner to next round game + // This would involve finding the next round game and setting the team + + return nil +} + diff --git a/python-bracket/.env.example b/python-bracket/.env.example new file mode 100644 index 0000000..db8bd39 --- /dev/null +++ b/python-bracket/.env.example @@ -0,0 +1,27 @@ +# Application +APP_NAME=Python Bracket API +DEBUG=false +VERSION=1.0.0 + +# Server +HOST=0.0.0.0 +PORT=8000 + +# Database +DATABASE_URL=sqlite:///./bracket.db +# DATABASE_URL=postgresql://user:password@localhost:5432/bracket + +# Security +SECRET_KEY=your-secret-key-change-in-production-make-it-long-and-random +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=1440 + +# CORS +ALLOWED_ORIGINS=["*"] +ALLOWED_METHODS=["*"] +ALLOWED_HEADERS=["*"] + +# Pagination +DEFAULT_PAGE_SIZE=20 +MAX_PAGE_SIZE=100 + diff --git a/python-bracket/Dockerfile b/python-bracket/Dockerfile new file mode 100644 index 0000000..fc64d35 --- /dev/null +++ b/python-bracket/Dockerfile @@ -0,0 +1,38 @@ +# Python Bracket API Dockerfile +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY app/ ./app/ + +# Create directory for SQLite database +RUN mkdir -p /data + +# Expose port +EXPOSE 8000 + +# Set environment variables +ENV PYTHONPATH=/app +ENV DATABASE_URL=sqlite:///data/bracket.db + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/health')" + +# Run the application +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] + diff --git a/python-bracket/README.md b/python-bracket/README.md new file mode 100644 index 0000000..bf89be8 --- /dev/null +++ b/python-bracket/README.md @@ -0,0 +1,298 @@ +# Python Bracket - NCAA Tournament Bracket API + +A modern Python implementation of the NCAA Basketball Tournament Bracket system, built with FastAPI and SQLAlchemy for rapid development and excellent developer experience. + +## Features + +- **FastAPI framework** with automatic OpenAPI documentation +- **SQLAlchemy 2.0** with async support and type hints +- **Pydantic v2** for data validation and serialization +- **JWT authentication** with bcrypt password hashing +- **Multi-database support** (PostgreSQL, SQLite) +- **Async/await** throughout for high performance +- **Type hints** everywhere for better IDE support +- **Automatic API documentation** with Swagger UI +- **Docker support** with multi-stage builds +- **Comprehensive testing** with pytest + +## Architecture + +``` +python-bracket/ +├── app/ +│ ├── models/ # SQLAlchemy models +│ ├── routers/ # API route handlers +│ ├── core/ # Core configuration & database +│ ├── auth/ # Authentication utilities +│ ├── tournament/ # Tournament business logic +│ └── main.py # FastAPI application +├── tests/ # Test suite +└── docker/ # Docker configuration +``` + +## Quick Start + +### Using Docker Compose (Recommended) + +```bash +# Start with PostgreSQL +docker-compose up -d + +# Or start with SQLite +docker-compose --profile sqlite up -d python-bracket-sqlite +``` + +### Manual Setup + +1. **Install Python 3.11+** (if not already installed) + +2. **Create virtual environment:** +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. **Install dependencies:** +```bash +pip install -r requirements.txt +``` + +4. **Set environment variables:** +```bash +cp .env.example .env +# Edit .env with your settings +``` + +5. **Run the application:** +```bash +python -m uvicorn app.main:app --reload +``` + +The API will be available at `http://localhost:8000` with automatic documentation at `http://localhost:8000/docs`. + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/register` - Register new user +- `POST /api/v1/auth/login` - Login user +- `GET /api/v1/auth/profile` - Get user profile (protected) + +### Tournament +- `GET /api/v1/bracket/` - Get tournament bracket +- `GET /api/v1/bracket/teams` - Get all teams +- `GET /api/v1/bracket/regions` - Get all regions +- `GET /api/v1/bracket/leaderboard` - Get current leaderboard + +### Player Actions (Protected) +- `GET /api/v1/bracket/player/{id}` - Get player's bracket +- `POST /api/v1/bracket/pick` - Make a pick + +### Admin Actions (Admin Only) +- `PUT /api/v1/bracket/admin/game/{id}/result` - Update game result + +## API Usage Examples + +### Register a new user +```bash +curl -X POST http://localhost:8000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "login": "john_doe", + "password": "password123", + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com" + }' +``` + +### Login +```bash +curl -X POST http://localhost:8000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "login": "john_doe", + "password": "password123" + }' +``` + +### Make a pick (requires authentication) +```bash +curl -X POST http://localhost:8000/api/v1/bracket/pick \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{ + "game_id": 1, + "team_id": 5 + }' +``` + +## Database Models + +Built with SQLAlchemy 2.0 and modern Python type hints: + +- **Region**: Tournament regions with relationships +- **Team**: Tournament teams with seeding information +- **Player**: User accounts with secure authentication +- **Game**: Tournament games with result tracking +- **Pick**: Player predictions with validation +- **Tournament**: Tournament metadata and state +- **RegionScore**: Regional scoring tracking +- **Session**: User session management + +## Configuration + +Environment variables (see `.env.example`): + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `sqlite:///./bracket.db` | Database connection string | +| `SECRET_KEY` | - | JWT secret key (required) | +| `DEBUG` | `false` | Enable debug mode | +| `PORT` | `8000` | Server port | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | JWT token expiration | + +## Development + +### Running tests +```bash +pytest +``` + +### Code formatting +```bash +black app/ +isort app/ +``` + +### Type checking +```bash +mypy app/ +``` + +### Running with hot reload +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +## Python-Specific Features + +### Modern Python Features +- **Type hints** throughout for better IDE support and error prevention +- **Pydantic v2** for fast data validation and serialization +- **SQLAlchemy 2.0** with modern async/await syntax +- **FastAPI** with automatic OpenAPI documentation generation +- **Async/await** for high-performance I/O operations + +### Developer Experience +- **Automatic API documentation** at `/docs` (Swagger UI) and `/redoc` +- **Hot reload** during development +- **Rich error messages** with detailed validation feedback +- **IDE support** with full type checking and autocompletion +- **Interactive testing** with built-in API documentation + +### Performance Benefits +- **Async database operations** for better concurrency +- **Pydantic serialization** optimized in Rust +- **FastAPI performance** comparable to Node.js and Go +- **Efficient JSON handling** with orjson support +- **Connection pooling** with SQLAlchemy + +## Security Features + +- **JWT-based authentication** with configurable expiration +- **Password hashing** with bcrypt and salt +- **SQL injection prevention** with SQLAlchemy ORM +- **Input validation** with Pydantic models +- **CORS configuration** for cross-origin requests +- **Environment-based configuration** for secrets + +## Testing + +Comprehensive test suite with pytest: + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=app + +# Run specific test file +pytest tests/test_auth.py + +# Run with verbose output +pytest -v +``` + +## Deployment + +### Production with Gunicorn +```bash +gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker +``` + +### Docker Production +```bash +docker build -t python-bracket . +docker run -p 8000:8000 python-bracket +``` + +### Environment Variables for Production +```bash +export SECRET_KEY="your-very-long-random-secret-key" +export DATABASE_URL="postgresql://user:pass@localhost:5432/bracket" +export DEBUG=false +``` + +## Tournament Logic + +Implements the complete NCAA tournament structure: + +- **64-team single elimination** bracket +- **Progressive scoring system** with increasing point values +- **Real-time score calculations** with efficient queries +- **Pick validation** to ensure game integrity +- **Admin controls** for game result updates + +## API Documentation + +FastAPI automatically generates comprehensive API documentation: + +- **Swagger UI**: Available at `/docs` +- **ReDoc**: Available at `/redoc` +- **OpenAPI JSON**: Available at `/openapi.json` + +The documentation includes: +- All endpoints with request/response schemas +- Authentication requirements +- Example requests and responses +- Interactive testing interface + +## Why Python/FastAPI? + +Python with FastAPI was chosen for this implementation because: + +- **Rapid Development**: Python's expressiveness enables quick feature development +- **Type Safety**: Modern Python with type hints provides compile-time error checking +- **Performance**: FastAPI offers performance comparable to Node.js and Go +- **Documentation**: Automatic API documentation generation +- **Ecosystem**: Rich ecosystem of packages for web development +- **Developer Experience**: Excellent tooling and IDE support +- **Testing**: Comprehensive testing frameworks and tools + +This implementation demonstrates Python's capabilities for building high-performance, well-documented APIs while maintaining the same functionality as the original Perl application with modern development practices. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Write tests for new functionality +4. Ensure all tests pass with `pytest` +5. Format code with `black` and `isort` +6. Type check with `mypy` +7. Submit a pull request + +## License + +This project maintains the same license as the original Perl implementation. + diff --git a/python-bracket/app/__init__.py b/python-bracket/app/__init__.py new file mode 100644 index 0000000..3a37bb9 --- /dev/null +++ b/python-bracket/app/__init__.py @@ -0,0 +1,2 @@ +# Python Bracket API + diff --git a/python-bracket/app/auth/__init__.py b/python-bracket/app/auth/__init__.py new file mode 100644 index 0000000..7c56d15 --- /dev/null +++ b/python-bracket/app/auth/__init__.py @@ -0,0 +1,12 @@ +from .security import verify_password, get_password_hash, create_access_token, verify_token +from .dependencies import get_current_user, get_current_admin_user + +__all__ = [ + "verify_password", + "get_password_hash", + "create_access_token", + "verify_token", + "get_current_user", + "get_current_admin_user", +] + diff --git a/python-bracket/app/auth/dependencies.py b/python-bracket/app/auth/dependencies.py new file mode 100644 index 0000000..44945b4 --- /dev/null +++ b/python-bracket/app/auth/dependencies.py @@ -0,0 +1,57 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from ..core.database import get_db +from ..models.player import Player +from .security import verify_token + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db) +) -> Player: + """Get the current authenticated user.""" + token = credentials.credentials + payload = verify_token(token) + + player_id: int = payload.get("sub") + if player_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + ) + + # Get user from database + result = await db.execute(select(Player).where(Player.id == player_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + if not user.active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user", + ) + + return user + + +async def get_current_admin_user( + current_user: Player = Depends(get_current_user) +) -> Player: + """Get the current authenticated admin user.""" + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions", + ) + return current_user + diff --git a/python-bracket/app/auth/security.py b/python-bracket/app/auth/security.py new file mode 100644 index 0000000..28ba99a --- /dev/null +++ b/python-bracket/app/auth/security.py @@ -0,0 +1,47 @@ +from datetime import datetime, timedelta +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import HTTPException, status + +from ..core.config import settings + +# Password hashing +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against its hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password.""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create a JWT access token.""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm) + return encoded_jwt + + +def verify_token(token: str) -> dict: + """Verify and decode a JWT token.""" + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + diff --git a/python-bracket/app/core/__init__.py b/python-bracket/app/core/__init__.py new file mode 100644 index 0000000..ec5049a --- /dev/null +++ b/python-bracket/app/core/__init__.py @@ -0,0 +1,5 @@ +from .config import settings +from .database import get_db, create_tables, seed_initial_data + +__all__ = ["settings", "get_db", "create_tables", "seed_initial_data"] + diff --git a/python-bracket/app/core/config.py b/python-bracket/app/core/config.py new file mode 100644 index 0000000..4adffe4 --- /dev/null +++ b/python-bracket/app/core/config.py @@ -0,0 +1,38 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + # Application + app_name: str = "Python Bracket API" + debug: bool = False + version: str = "1.0.0" + + # Server + host: str = "0.0.0.0" + port: int = 8000 + + # Database + database_url: str = "sqlite:///./bracket.db" + + # Security + secret_key: str = "your-secret-key-change-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 1440 # 24 hours + + # CORS + allowed_origins: list[str] = ["*"] + allowed_methods: list[str] = ["*"] + allowed_headers: list[str] = ["*"] + + # Pagination + default_page_size: int = 20 + max_page_size: int = 100 + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() + diff --git a/python-bracket/app/core/database.py b/python-bracket/app/core/database.py new file mode 100644 index 0000000..45a9bfb --- /dev/null +++ b/python-bracket/app/core/database.py @@ -0,0 +1,80 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from typing import AsyncGenerator + +from .config import settings +from ..models.base import Base + +# Create async engine +if settings.database_url.startswith("sqlite"): + # For SQLite, use aiosqlite + async_database_url = settings.database_url.replace("sqlite://", "sqlite+aiosqlite://") +else: + # For PostgreSQL, use asyncpg + async_database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://") + +async_engine = create_async_engine( + async_database_url, + echo=settings.debug, + future=True +) + +# Create async session factory +AsyncSessionLocal = sessionmaker( + bind=async_engine, + class_=AsyncSession, + expire_on_commit=False +) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """Dependency to get database session.""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + + +async def create_tables(): + """Create all tables.""" + async with async_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def seed_initial_data(): + """Seed initial data into the database.""" + from ..models import Region, Tournament + + async with AsyncSessionLocal() as session: + # Check if regions already exist + result = await session.execute("SELECT COUNT(*) FROM regions") + region_count = result.scalar() + + if region_count > 0: + return + + # Create regions + regions = [ + Region(id=1, name="East"), + Region(id=2, name="Midwest"), + Region(id=3, name="South"), + Region(id=4, name="West"), + ] + + for region in regions: + session.add(region) + + # Create sample tournament + tournament = Tournament( + year=2024, + name="NCAA Men's Basketball Tournament 2024", + is_active=True, + picks_locked=False + ) + session.add(tournament) + + await session.commit() + print("Initial data seeded successfully") + diff --git a/python-bracket/app/main.py b/python-bracket/app/main.py new file mode 100644 index 0000000..1a6140b --- /dev/null +++ b/python-bracket/app/main.py @@ -0,0 +1,65 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager + +from .core import settings, create_tables, seed_initial_data +from .routers import auth_router, bracket_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan events.""" + # Startup + await create_tables() + await seed_initial_data() + yield + # Shutdown + pass + + +app = FastAPI( + title=settings.app_name, + version=settings.version, + debug=settings.debug, + lifespan=lifespan +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins, + allow_credentials=True, + allow_methods=settings.allowed_methods, + allow_headers=settings.allowed_headers, +) + +# Include routers +app.include_router(auth_router) +app.include_router(bracket_router) + + +@app.get("/") +async def root(): + """Root endpoint.""" + return { + "message": f"Welcome to {settings.app_name}", + "version": settings.version, + "docs": "/docs" + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host=settings.host, + port=settings.port, + reload=settings.debug + ) + diff --git a/python-bracket/app/models/__init__.py b/python-bracket/app/models/__init__.py new file mode 100644 index 0000000..b71645d --- /dev/null +++ b/python-bracket/app/models/__init__.py @@ -0,0 +1,20 @@ +from .region import Region +from .team import Team +from .player import Player +from .game import Game +from .pick import Pick +from .region_score import RegionScore +from .session import Session +from .tournament import Tournament + +__all__ = [ + "Region", + "Team", + "Player", + "Game", + "Pick", + "RegionScore", + "Session", + "Tournament", +] + diff --git a/python-bracket/app/models/base.py b/python-bracket/app/models/base.py new file mode 100644 index 0000000..ba5bdaa --- /dev/null +++ b/python-bracket/app/models/base.py @@ -0,0 +1,23 @@ +from datetime import datetime +from sqlalchemy import DateTime, func +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Mapped, mapped_column + +Base = declarative_base() + + +class TimestampMixin: + """Mixin for adding created_at and updated_at timestamps.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False + ) + diff --git a/python-bracket/app/models/game.py b/python-bracket/app/models/game.py new file mode 100644 index 0000000..1a2ebbd --- /dev/null +++ b/python-bracket/app/models/game.py @@ -0,0 +1,41 @@ +from sqlalchemy import Integer, SmallInteger, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List, Optional, TYPE_CHECKING + +from .base import Base + +if TYPE_CHECKING: + from .region import Region + from .team import Team + from .pick import Pick + + +class Game(Base): + __tablename__ = "games" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + round: Mapped[int] = mapped_column(SmallInteger, nullable=False) + region_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("regions.id"), nullable=True) + team1_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("teams.id"), nullable=True) + team2_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("teams.id"), nullable=True) + winner_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("teams.id"), nullable=True) + point_value: Mapped[int] = mapped_column(Integer, nullable=False) + + # Relationships + region: Mapped[Optional["Region"]] = relationship("Region", back_populates="games") + team1: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[team1_id]) + team2: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[team2_id]) + winner: Mapped[Optional["Team"]] = relationship("Team", foreign_keys=[winner_id]) + picks: Mapped[List["Pick"]] = relationship("Pick", back_populates="game") + + @property + def is_completed(self) -> bool: + return self.winner_id is not None + + @property + def teams_set(self) -> bool: + return self.team1_id is not None and self.team2_id is not None + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/models/pick.py b/python-bracket/app/models/pick.py new file mode 100644 index 0000000..5b26e2f --- /dev/null +++ b/python-bracket/app/models/pick.py @@ -0,0 +1,33 @@ +from sqlalchemy import Integer, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import TYPE_CHECKING + +from .base import Base, TimestampMixin + +if TYPE_CHECKING: + from .player import Player + from .game import Game + from .team import Team + + +class Pick(Base, TimestampMixin): + __tablename__ = "picks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + player_id: Mapped[int] = mapped_column(Integer, ForeignKey("players.id"), nullable=False) + game_id: Mapped[int] = mapped_column(Integer, ForeignKey("games.id"), nullable=False) + pick_id: Mapped[int] = mapped_column(Integer, ForeignKey("teams.id"), nullable=False) + + # Relationships + player: Mapped["Player"] = relationship("Player", back_populates="picks") + game: Mapped["Game"] = relationship("Game", back_populates="picks") + pick_team: Mapped["Team"] = relationship("Team", back_populates="picks") + + @property + def is_correct(self) -> bool: + """Check if this pick is correct based on the game result.""" + return self.game.winner_id == self.pick_id if self.game.winner_id else False + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/models/player.py b/python-bracket/app/models/player.py new file mode 100644 index 0000000..e8d3906 --- /dev/null +++ b/python-bracket/app/models/player.py @@ -0,0 +1,36 @@ +from sqlalchemy import Integer, String, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List, TYPE_CHECKING + +from .base import Base, TimestampMixin + +if TYPE_CHECKING: + from .pick import Pick + from .region_score import RegionScore + from .session import Session + + +class Player(Base, TimestampMixin): + __tablename__ = "players" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + login: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + password: Mapped[str] = mapped_column(String(255), nullable=False) + first_name: Mapped[str] = mapped_column(String(50), nullable=False) + last_name: Mapped[str] = mapped_column(String(50), nullable=False) + email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # Relationships + picks: Mapped[List["Pick"]] = relationship("Pick", back_populates="player") + region_scores: Mapped[List["RegionScore"]] = relationship("RegionScore", back_populates="player") + sessions: Mapped[List["Session"]] = relationship("Session", back_populates="player") + + @property + def full_name(self) -> str: + return f"{self.first_name} {self.last_name}" + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/models/region.py b/python-bracket/app/models/region.py new file mode 100644 index 0000000..a148681 --- /dev/null +++ b/python-bracket/app/models/region.py @@ -0,0 +1,24 @@ +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List, TYPE_CHECKING + +from .base import Base + +if TYPE_CHECKING: + from .team import Team + from .game import Game + + +class Region(Base): + __tablename__ = "regions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + + # Relationships + teams: Mapped[List["Team"]] = relationship("Team", back_populates="region") + games: Mapped[List["Game"]] = relationship("Game", back_populates="region") + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/models/region_score.py b/python-bracket/app/models/region_score.py new file mode 100644 index 0000000..2f4b669 --- /dev/null +++ b/python-bracket/app/models/region_score.py @@ -0,0 +1,26 @@ +from sqlalchemy import Integer, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import TYPE_CHECKING + +from .base import Base + +if TYPE_CHECKING: + from .player import Player + from .region import Region + + +class RegionScore(Base): + __tablename__ = "region_scores" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + player_id: Mapped[int] = mapped_column(Integer, ForeignKey("players.id"), nullable=False) + region_id: Mapped[int] = mapped_column(Integer, ForeignKey("regions.id"), nullable=False) + points: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + # Relationships + player: Mapped["Player"] = relationship("Player", back_populates="region_scores") + region: Mapped["Region"] = relationship("Region") + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/models/session.py b/python-bracket/app/models/session.py new file mode 100644 index 0000000..db516ad --- /dev/null +++ b/python-bracket/app/models/session.py @@ -0,0 +1,30 @@ +from datetime import datetime +from sqlalchemy import Integer, String, ForeignKey, DateTime, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import Optional, TYPE_CHECKING + +from .base import Base + +if TYPE_CHECKING: + from .player import Player + + +class Session(Base): + __tablename__ = "sessions" + + id: Mapped[str] = mapped_column(String(255), primary_key=True) + player_id: Mapped[int] = mapped_column(Integer, ForeignKey("players.id"), nullable=False) + data: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + # Relationships + player: Mapped["Player"] = relationship("Player", back_populates="sessions") + + @property + def is_expired(self) -> bool: + return datetime.utcnow() > self.expires_at + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/models/team.py b/python-bracket/app/models/team.py new file mode 100644 index 0000000..10c9bfe --- /dev/null +++ b/python-bracket/app/models/team.py @@ -0,0 +1,27 @@ +from sqlalchemy import Integer, String, SmallInteger, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import List, Optional, TYPE_CHECKING + +from .base import Base + +if TYPE_CHECKING: + from .region import Region + from .pick import Pick + + +class Team(Base): + __tablename__ = "teams" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + seed: Mapped[int] = mapped_column(SmallInteger, nullable=False) + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + region_id: Mapped[int] = mapped_column(Integer, ForeignKey("regions.id"), nullable=False) + url: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + + # Relationships + region: Mapped["Region"] = relationship("Region", back_populates="teams") + picks: Mapped[List["Pick"]] = relationship("Pick", back_populates="pick_team") + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/models/tournament.py b/python-bracket/app/models/tournament.py new file mode 100644 index 0000000..3d044f7 --- /dev/null +++ b/python-bracket/app/models/tournament.py @@ -0,0 +1,32 @@ +from datetime import datetime +from sqlalchemy import Integer, String, Boolean, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from typing import Optional + +from .base import Base, TimestampMixin + + +class Tournament(Base, TimestampMixin): + __tablename__ = "tournaments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + year: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + start_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + end_date: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + picks_locked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + @property + def is_current(self) -> bool: + """Check if this tournament is currently active.""" + return self.is_active + + @property + def can_make_picks(self) -> bool: + """Check if picks can still be made for this tournament.""" + return self.is_active and not self.picks_locked + + def __repr__(self) -> str: + return f"" + diff --git a/python-bracket/app/routers/__init__.py b/python-bracket/app/routers/__init__.py new file mode 100644 index 0000000..51db75a --- /dev/null +++ b/python-bracket/app/routers/__init__.py @@ -0,0 +1,5 @@ +from .auth import router as auth_router +from .bracket import router as bracket_router + +__all__ = ["auth_router", "bracket_router"] + diff --git a/python-bracket/app/routers/auth.py b/python-bracket/app/routers/auth.py new file mode 100644 index 0000000..a1914d8 --- /dev/null +++ b/python-bracket/app/routers/auth.py @@ -0,0 +1,116 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, EmailStr +from typing import Optional + +from ..core.database import get_db +from ..models.player import Player +from ..auth import verify_password, get_password_hash, create_access_token, get_current_user + +router = APIRouter(prefix="/api/v1/auth", tags=["authentication"]) + + +class UserRegister(BaseModel): + login: str + password: str + first_name: str + last_name: str + email: EmailStr + + +class UserLogin(BaseModel): + login: str + password: str + + +class Token(BaseModel): + access_token: str + token_type: str + + +class UserResponse(BaseModel): + id: int + login: str + first_name: str + last_name: str + email: str + is_admin: bool + active: bool + + class Config: + from_attributes = True + + +@router.post("/register", response_model=UserResponse) +async def register(user_data: UserRegister, db: AsyncSession = Depends(get_db)): + """Register a new user.""" + # Check if user already exists + result = await db.execute( + select(Player).where( + (Player.login == user_data.login) | (Player.email == user_data.email) + ) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User with this login or email already exists" + ) + + # Create new user + hashed_password = get_password_hash(user_data.password) + new_user = Player( + login=user_data.login, + password=hashed_password, + first_name=user_data.first_name, + last_name=user_data.last_name, + email=user_data.email, + is_admin=False, + active=True + ) + + db.add(new_user) + await db.commit() + await db.refresh(new_user) + + return new_user + + +@router.post("/login", response_model=Token) +async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)): + """Login user and return access token.""" + # Get user from database + result = await db.execute( + select(Player).where(Player.login == user_data.login) + ) + user = result.scalar_one_or_none() + + if not user or not verify_password(user_data.password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect login or password", + ) + + if not user.active: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Inactive user", + ) + + # Create access token + access_token = create_access_token(data={"sub": str(user.id)}) + + return { + "access_token": access_token, + "token_type": "bearer" + } + + +@router.get("/profile", response_model=UserResponse) +async def get_profile(current_user: Player = Depends(get_current_user)): + """Get current user profile.""" + return current_user + diff --git a/python-bracket/app/routers/bracket.py b/python-bracket/app/routers/bracket.py new file mode 100644 index 0000000..c35f40e --- /dev/null +++ b/python-bracket/app/routers/bracket.py @@ -0,0 +1,221 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from pydantic import BaseModel +from typing import List, Dict, Any, Optional + +from ..core.database import get_db +from ..models import Team, Region, Pick, Player, Game +from ..auth import get_current_user, get_current_admin_user +from ..tournament import TournamentService + +router = APIRouter(prefix="/api/v1/bracket", tags=["bracket"]) + + +class CreatePickRequest(BaseModel): + game_id: int + team_id: int + + +class UpdateGameResultRequest(BaseModel): + winner_id: int + + +class TeamResponse(BaseModel): + id: int + seed: int + name: str + region_id: int + url: Optional[str] = None + + class Config: + from_attributes = True + + +class RegionResponse(BaseModel): + id: int + name: str + + class Config: + from_attributes = True + + +class PickResponse(BaseModel): + id: int + player_id: int + game_id: int + pick_id: int + + class Config: + from_attributes = True + + +class PlayerBracketResponse(BaseModel): + picks: List[PickResponse] + score: int + + +class LeaderboardEntry(BaseModel): + player: Dict[str, Any] + score: int + + +@router.get("/") +async def get_bracket(db: AsyncSession = Depends(get_db)): + """Get the current tournament bracket.""" + tournament_service = TournamentService(db) + return await tournament_service.get_bracket_status() + + +@router.get("/teams", response_model=List[TeamResponse]) +async def get_teams(db: AsyncSession = Depends(get_db)): + """Get all teams.""" + result = await db.execute(select(Team)) + teams = result.scalars().all() + return teams + + +@router.get("/regions", response_model=List[RegionResponse]) +async def get_regions(db: AsyncSession = Depends(get_db)): + """Get all regions.""" + result = await db.execute(select(Region)) + regions = result.scalars().all() + return regions + + +@router.get("/player/{player_id}", response_model=PlayerBracketResponse) +async def get_player_bracket( + player_id: int, + db: AsyncSession = Depends(get_db), + current_user: Player = Depends(get_current_user) +): + """Get a player's bracket and score.""" + # Users can only view their own bracket unless they're admin + if current_user.id != player_id and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to view this bracket" + ) + + # Get player's picks + result = await db.execute( + select(Pick).where(Pick.player_id == player_id) + ) + picks = result.scalars().all() + + # Calculate score + tournament_service = TournamentService(db) + score = await tournament_service.calculate_player_score(player_id) + + return PlayerBracketResponse( + picks=[PickResponse.from_orm(pick) for pick in picks], + score=score + ) + + +@router.post("/pick", response_model=PickResponse) +async def create_pick( + pick_data: CreatePickRequest, + db: AsyncSession = Depends(get_db), + current_user: Player = Depends(get_current_user) +): + """Create or update a pick for the current user.""" + tournament_service = TournamentService(db) + + # Check if picks can be made + if not await tournament_service.can_make_pick(pick_data.game_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Picks are locked or game is completed" + ) + + # Verify the game exists and the team is valid for that game + game_result = await db.execute( + select(Game).where(Game.id == pick_data.game_id) + ) + game = game_result.scalar_one_or_none() + + if not game: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Game not found" + ) + + # Check if team is valid for this game + if game.team1_id and game.team2_id: + if pick_data.team_id not in [game.team1_id, game.team2_id]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Team is not playing in this game" + ) + + # Check if pick already exists + existing_pick_result = await db.execute( + select(Pick).where( + (Pick.player_id == current_user.id) & + (Pick.game_id == pick_data.game_id) + ) + ) + existing_pick = existing_pick_result.scalar_one_or_none() + + if existing_pick: + # Update existing pick + existing_pick.pick_id = pick_data.team_id + await db.commit() + await db.refresh(existing_pick) + return existing_pick + else: + # Create new pick + new_pick = Pick( + player_id=current_user.id, + game_id=pick_data.game_id, + pick_id=pick_data.team_id + ) + db.add(new_pick) + await db.commit() + await db.refresh(new_pick) + return new_pick + + +@router.get("/leaderboard", response_model=List[LeaderboardEntry]) +async def get_leaderboard(db: AsyncSession = Depends(get_db)): + """Get the current leaderboard.""" + tournament_service = TournamentService(db) + leaderboard = await tournament_service.get_leaderboard() + + return [ + LeaderboardEntry( + player={ + "id": entry["player"].id, + "login": entry["player"].login, + "first_name": entry["player"].first_name, + "last_name": entry["player"].last_name, + "full_name": entry["player"].full_name + }, + score=entry["score"] + ) + for entry in leaderboard + ] + + +# Admin routes +@router.put("/admin/game/{game_id}/result") +async def update_game_result( + game_id: int, + result_data: UpdateGameResultRequest, + db: AsyncSession = Depends(get_db), + current_user: Player = Depends(get_current_admin_user) +): + """Update the result of a game (admin only).""" + tournament_service = TournamentService(db) + + try: + await tournament_service.update_game_result(game_id, result_data.winner_id) + return {"message": "Game result updated successfully"} + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + diff --git a/python-bracket/app/tournament/__init__.py b/python-bracket/app/tournament/__init__.py new file mode 100644 index 0000000..a8e333e --- /dev/null +++ b/python-bracket/app/tournament/__init__.py @@ -0,0 +1,4 @@ +from .service import TournamentService + +__all__ = ["TournamentService"] + diff --git a/python-bracket/app/tournament/service.py b/python-bracket/app/tournament/service.py new file mode 100644 index 0000000..32ca593 --- /dev/null +++ b/python-bracket/app/tournament/service.py @@ -0,0 +1,173 @@ +from typing import List, Dict, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_ +from sqlalchemy.orm import selectinload + +from ..models import Game, Pick, Player, Team, Region, Tournament + + +class TournamentService: + """Service for tournament-related operations.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_bracket_status(self) -> Dict[str, Any]: + """Get the current bracket status.""" + # Get active tournament + result = await self.db.execute( + select(Tournament).where(Tournament.is_active == True) + ) + tournament = result.scalar_one_or_none() + + if not tournament: + return {"error": "No active tournament"} + + # Get all games with teams + games_result = await self.db.execute( + select(Game) + .options( + selectinload(Game.team1), + selectinload(Game.team2), + selectinload(Game.winner), + selectinload(Game.region) + ) + .order_by(Game.round, Game.id) + ) + games = games_result.scalars().all() + + # Get all regions + regions_result = await self.db.execute(select(Region)) + regions = regions_result.scalars().all() + + return { + "tournament": { + "id": tournament.id, + "year": tournament.year, + "name": tournament.name, + "picks_locked": tournament.picks_locked, + "is_active": tournament.is_active + }, + "games": [ + { + "id": game.id, + "round": game.round, + "region": game.region.name if game.region else None, + "team1": { + "id": game.team1.id, + "name": game.team1.name, + "seed": game.team1.seed + } if game.team1 else None, + "team2": { + "id": game.team2.id, + "name": game.team2.name, + "seed": game.team2.seed + } if game.team2 else None, + "winner": { + "id": game.winner.id, + "name": game.winner.name, + "seed": game.winner.seed + } if game.winner else None, + "point_value": game.point_value, + "completed": game.is_completed + } + for game in games + ], + "regions": [ + { + "id": region.id, + "name": region.name + } + for region in regions + ] + } + + async def calculate_player_score(self, player_id: int) -> int: + """Calculate total score for a player.""" + result = await self.db.execute( + select(func.sum(Game.point_value)) + .select_from(Pick) + .join(Game, Pick.game_id == Game.id) + .where( + and_( + Pick.player_id == player_id, + Pick.pick_id == Game.winner_id + ) + ) + ) + score = result.scalar() + return score or 0 + + async def get_leaderboard(self) -> List[Dict[str, Any]]: + """Get the current leaderboard.""" + # Get all players with their scores + result = await self.db.execute( + select( + Player, + func.coalesce(func.sum(Game.point_value), 0).label('score') + ) + .outerjoin(Pick, Pick.player_id == Player.id) + .outerjoin( + Game, + and_( + Pick.game_id == Game.id, + Pick.pick_id == Game.winner_id + ) + ) + .where(Player.active == True) + .group_by(Player.id) + .order_by(func.coalesce(func.sum(Game.point_value), 0).desc()) + ) + + leaderboard = [] + for player, score in result: + leaderboard.append({ + "player": player, + "score": score + }) + + return leaderboard + + async def update_game_result(self, game_id: int, winner_id: int) -> bool: + """Update the result of a game.""" + # Get the game + result = await self.db.execute( + select(Game).where(Game.id == game_id) + ) + game = result.scalar_one_or_none() + + if not game: + raise ValueError("Game not found") + + # Verify the winner is one of the teams in the game + if winner_id not in [game.team1_id, game.team2_id]: + raise ValueError("Winner must be one of the teams in the game") + + # Update the game + game.winner_id = winner_id + await self.db.commit() + + return True + + async def can_make_pick(self, game_id: int) -> bool: + """Check if picks can still be made for a game.""" + # Check if tournament picks are locked + result = await self.db.execute( + select(Tournament).where(Tournament.is_active == True) + ) + tournament = result.scalar_one_or_none() + + if not tournament or tournament.picks_locked: + return False + + # Check if game is already completed + game_result = await self.db.execute( + select(Game).where(Game.id == game_id) + ) + game = game_result.scalar_one_or_none() + + if not game or game.is_completed: + return False + + return True + diff --git a/python-bracket/docker-compose.yml b/python-bracket/docker-compose.yml new file mode 100644 index 0000000..6150f95 --- /dev/null +++ b/python-bracket/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + python-bracket-api: + build: . + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://bracket:bracket123@postgres:5432/bracket + - DEBUG=true + depends_on: + - postgres + volumes: + - ./data:/data + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_USER=bracket + - POSTGRES_PASSWORD=bracket123 + - POSTGRES_DB=bracket + ports: + - "5434:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + # SQLite version (alternative) + python-bracket-sqlite: + build: . + ports: + - "8001:8000" + environment: + - DATABASE_URL=sqlite:///data/bracket.db + - DEBUG=true + volumes: + - ./data:/data + profiles: + - sqlite + +volumes: + postgres_data: + diff --git a/python-bracket/requirements.txt b/python-bracket/requirements.txt new file mode 100644 index 0000000..d3bab45 --- /dev/null +++ b/python-bracket/requirements.txt @@ -0,0 +1,36 @@ +# Web Framework +fastapi==0.104.1 +uvicorn[standard]==0.24.0 + +# Database +sqlalchemy==2.0.23 +alembic==1.12.1 +psycopg2-binary==2.9.9 +aiosqlite==0.19.0 + +# Authentication +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 + +# Validation +pydantic==2.5.0 +pydantic-settings==2.1.0 +email-validator==2.1.0 + +# Utilities +python-dateutil==2.8.2 +pytz==2023.3 + +# Development +pytest==7.4.3 +pytest-asyncio==0.21.1 +httpx==0.25.2 +black==23.11.0 +isort==5.12.0 +flake8==6.1.0 +mypy==1.7.1 + +# Production +gunicorn==21.2.0 + diff --git a/rust-bracket/Cargo.toml b/rust-bracket/Cargo.toml new file mode 100644 index 0000000..dd91832 --- /dev/null +++ b/rust-bracket/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "rust-bracket" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Web framework +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1.0", features = ["full"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "trace"] } + +# Database +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "sqlite", "chrono", "uuid", "migrate"] } +sea-orm = { version = "0.12", features = ["sqlx-postgres", "sqlx-sqlite", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Authentication +jsonwebtoken = "9.0" +bcrypt = "0.15" + +# Utilities +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.0", features = ["v4", "serde"] } +anyhow = "1.0" +thiserror = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Configuration +config = "0.14" +dotenvy = "0.15" + +# Validation +validator = { version = "0.18", features = ["derive"] } + +# HTTP client (for testing) +reqwest = { version = "0.11", features = ["json"], optional = true } + +[dev-dependencies] +reqwest = { version = "0.11", features = ["json"] } + +[features] +default = [] +testing = ["reqwest"] + diff --git a/rust-bracket/Dockerfile b/rust-bracket/Dockerfile new file mode 100644 index 0000000..a4d082c --- /dev/null +++ b/rust-bracket/Dockerfile @@ -0,0 +1,47 @@ +# Build stage +FROM rust:1.75 as builder + +WORKDIR /app + +# Copy manifests +COPY Cargo.toml Cargo.lock ./ + +# Create a dummy main.rs to build dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs + +# Build dependencies +RUN cargo build --release +RUN rm src/main.rs + +# Copy source code +COPY src ./src + +# Build the application +RUN touch src/main.rs && cargo build --release + +# Runtime stage +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy the binary from builder stage +COPY --from=builder /app/target/release/rust-bracket /app/rust-bracket + +# Create directory for SQLite database +RUN mkdir -p /data + +# Expose port +EXPOSE 3000 + +# Set environment variables +ENV DATABASE_URL=sqlite:///data/bracket.db +ENV RUST_LOG=info + +# Run the application +CMD ["./rust-bracket"] + diff --git a/rust-bracket/README.md b/rust-bracket/README.md new file mode 100644 index 0000000..01b9df0 --- /dev/null +++ b/rust-bracket/README.md @@ -0,0 +1,244 @@ +# Rust Bracket - NCAA Tournament Bracket API + +A high-performance Rust implementation of the NCAA Basketball Tournament Bracket system, built with Axum and Sea-ORM for maximum type safety and performance. + +## Features + +- **Type-safe REST API** with Axum framework +- **Sea-ORM** for compile-time verified database queries +- **JWT authentication** with bcrypt password hashing +- **Multi-database support** (PostgreSQL, SQLite) +- **Async/await** throughout for high concurrency +- **Comprehensive error handling** with structured logging +- **Docker support** with multi-stage builds +- **Zero-cost abstractions** with Rust's performance guarantees + +## Architecture + +``` +rust-bracket/ +├── src/ +│ ├── handlers/ # HTTP request handlers +│ ├── models/ # Sea-ORM entity models +│ ├── middleware/ # Authentication & CORS middleware +│ ├── database/ # Database connection & seeding +│ ├── auth/ # JWT & password utilities +│ ├── tournament/ # Tournament business logic +│ └── main.rs # Application entry point +├── migrations/ # Database migrations +└── docker/ # Docker configuration +``` + +## Quick Start + +### Using Docker Compose (Recommended) + +```bash +# Start with PostgreSQL +docker-compose up -d + +# Or start with SQLite +docker-compose --profile sqlite up -d rust-bracket-sqlite +``` + +### Manual Setup + +1. **Install Rust** (if not already installed): +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +2. **Set environment variables:** +```bash +export DATABASE_URL=sqlite://bracket.db +export RUST_LOG=debug +``` + +3. **Run the application:** +```bash +cargo run +``` + +## API Endpoints + +### Authentication +- `POST /api/v1/auth/register` - Register new user +- `POST /api/v1/auth/login` - Login user +- `GET /api/v1/auth/profile` - Get user profile (protected) + +### Tournament +- `GET /api/v1/bracket` - Get tournament bracket +- `GET /api/v1/teams` - Get all teams +- `GET /api/v1/regions` - Get all regions +- `GET /api/v1/leaderboard` - Get current leaderboard + +### Player Actions (Protected) +- `GET /api/v1/bracket/player/:id` - Get player's bracket +- `POST /api/v1/bracket/pick` - Make a pick + +### Admin Actions (Admin Only) +- `PUT /api/v1/admin/bracket/game/:id/result` - Update game result + +## API Usage Examples + +### Register a new user +```bash +curl -X POST http://localhost:3000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "login": "john_doe", + "password": "password123", + "first_name": "John", + "last_name": "Doe", + "email": "john@example.com" + }' +``` + +### Login +```bash +curl -X POST http://localhost:3000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "login": "john_doe", + "password": "password123" + }' +``` + +### Make a pick (requires authentication) +```bash +curl -X POST http://localhost:3000/api/v1/bracket/pick \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{ + "game_id": 1, + "team_id": 5 + }' +``` + +## Database Models + +Built with Sea-ORM for compile-time query verification: + +- **Region**: Tournament regions with type-safe relationships +- **Team**: Tournament teams with seeding information +- **Player**: User accounts with secure authentication +- **Game**: Tournament games with result tracking +- **Pick**: Player predictions with validation +- **Tournament**: Tournament metadata and state + +## Configuration + +Environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | `sqlite://bracket.db` | Database connection string | +| `PORT` | `3000` | Server port | +| `RUST_LOG` | `info` | Logging level | + +## Development + +### Running tests +```bash +cargo test +``` + +### Building for production +```bash +cargo build --release +``` + +### Database migrations +```bash +# Install sea-orm-cli +cargo install sea-orm-cli + +# Generate migration +sea-orm-cli migrate generate create_tables + +# Run migrations +sea-orm-cli migrate up +``` + +## Performance Benefits + +Rust provides significant performance advantages: + +- **Zero-cost abstractions**: No runtime overhead for safety features +- **Memory safety**: No garbage collector, predictable performance +- **Concurrency**: Async/await with excellent performance characteristics +- **Type safety**: Compile-time error prevention +- **Small binary size**: Optimized release builds + +## Rust-Specific Features + +### Type Safety +- **Compile-time query verification** with Sea-ORM +- **Strong typing** prevents runtime errors +- **Option types** eliminate null pointer exceptions +- **Result types** for explicit error handling + +### Performance +- **Zero-copy deserialization** with serde +- **Efficient async runtime** with tokio +- **Memory-efficient** data structures +- **SIMD optimizations** where applicable + +### Developer Experience +- **Excellent tooling** with cargo +- **Built-in testing** framework +- **Documentation generation** with rustdoc +- **Package management** with crates.io + +## Security Features + +- **Memory safety** by design +- **JWT-based authentication** with secure defaults +- **Password hashing** with bcrypt +- **SQL injection prevention** with Sea-ORM +- **Input validation** with validator crate + +## Deployment + +The application can be deployed using: + +1. **Docker** - Multi-stage builds for minimal image size +2. **Binary** - Single executable with no dependencies +3. **Cloud platforms** - Deploy anywhere Rust runs +4. **Kubernetes** - Container orchestration ready + +## Tournament Logic + +Implements the complete NCAA tournament structure: + +- **64-team single elimination** +- **Progressive scoring system** +- **Real-time calculations** +- **Type-safe game state management** +- **Efficient bracket traversal** + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Write tests for new functionality +4. Ensure `cargo clippy` passes +5. Format code with `cargo fmt` +6. Submit a pull request + +## Why Rust? + +Rust was chosen for this implementation because: + +- **Performance**: Near C/C++ performance with safety +- **Reliability**: Prevents entire classes of bugs at compile time +- **Concurrency**: Excellent async/await support +- **Ecosystem**: Rich crate ecosystem for web development +- **Future-proof**: Growing adoption in systems programming + +This implementation demonstrates Rust's capabilities for building high-performance, type-safe web APIs while maintaining the same functionality as the original Perl application. + +## License + +This project maintains the same license as the original Perl implementation. + diff --git a/rust-bracket/docker-compose.yml b/rust-bracket/docker-compose.yml new file mode 100644 index 0000000..dfbba19 --- /dev/null +++ b/rust-bracket/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + rust-bracket-api: + build: . + ports: + - "3000:3000" + environment: + - DATABASE_URL=postgresql://bracket:bracket123@postgres:5432/bracket + - RUST_LOG=debug + depends_on: + - postgres + volumes: + - ./data:/data + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_USER=bracket + - POSTGRES_PASSWORD=bracket123 + - POSTGRES_DB=bracket + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + # SQLite version (alternative) + rust-bracket-sqlite: + build: . + ports: + - "3001:3000" + environment: + - DATABASE_URL=sqlite:///data/bracket.db + - RUST_LOG=debug + volumes: + - ./data:/data + profiles: + - sqlite + +volumes: + postgres_data: + diff --git a/rust-bracket/src/auth/mod.rs b/rust-bracket/src/auth/mod.rs new file mode 100644 index 0000000..7feb771 --- /dev/null +++ b/rust-bracket/src/auth/mod.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use bcrypt::{hash, verify, DEFAULT_COST}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +use crate::models::player; + +const JWT_SECRET: &[u8] = b"your-secret-key"; // In production, use environment variable + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub player_id: i32, + pub login: String, + pub is_admin: bool, + pub exp: i64, + pub iat: i64, +} + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("Invalid credentials")] + InvalidCredentials, + #[error("Token generation failed")] + TokenGeneration, + #[error("Token validation failed")] + TokenValidation, + #[error("Password hashing failed")] + PasswordHashing, +} + +pub fn hash_password(password: &str) -> Result { + hash(password, DEFAULT_COST).map_err(|_| AuthError::PasswordHashing) +} + +pub fn verify_password(password: &str, hash: &str) -> bool { + verify(password, hash).unwrap_or(false) +} + +pub fn generate_token(player: &player::Model) -> Result { + let now = Utc::now(); + let expiration = now + Duration::hours(24); + + let claims = Claims { + player_id: player.id, + login: player.login.clone(), + is_admin: player.is_admin, + exp: expiration.timestamp(), + iat: now.timestamp(), + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(JWT_SECRET), + ) + .map_err(|_| AuthError::TokenGeneration) +} + +pub fn validate_token(token: &str) -> Result { + let token_data = decode::( + token, + &DecodingKey::from_secret(JWT_SECRET), + &Validation::default(), + ) + .map_err(|_| AuthError::TokenValidation)?; + + Ok(token_data.claims) +} + diff --git a/rust-bracket/src/database/mod.rs b/rust-bracket/src/database/mod.rs new file mode 100644 index 0000000..8572d78 --- /dev/null +++ b/rust-bracket/src/database/mod.rs @@ -0,0 +1,60 @@ +use anyhow::Result; +use sea_orm::{Database, DatabaseConnection, DbErr}; +use tracing::info; + +use crate::models::{region, tournament}; + +pub async fn establish_connection(database_url: &str) -> Result { + let db = Database::connect(database_url).await?; + info!("Database connection established"); + Ok(db) +} + +pub async fn seed_initial_data(db: &DatabaseConnection) -> Result<()> { + use sea_orm::{ActiveModelTrait, EntityTrait, Set}; + + // Check if regions already exist + let region_count = region::Entity::find().count(db).await?; + if region_count > 0 { + return Ok(()); + } + + // Create regions + let regions = vec![ + region::ActiveModel { + id: Set(1), + name: Set("East".to_string()), + }, + region::ActiveModel { + id: Set(2), + name: Set("Midwest".to_string()), + }, + region::ActiveModel { + id: Set(3), + name: Set("South".to_string()), + }, + region::ActiveModel { + id: Set(4), + name: Set("West".to_string()), + }, + ]; + + for region in regions { + region.insert(db).await?; + } + + // Create sample tournament + let tournament = tournament::ActiveModel { + year: Set(2024), + name: Set("NCAA Men's Basketball Tournament 2024".to_string()), + is_active: Set(true), + picks_locked: Set(false), + ..Default::default() + }; + + tournament.insert(db).await?; + + info!("Initial data seeded successfully"); + Ok(()) +} + diff --git a/rust-bracket/src/handlers/auth.rs b/rust-bracket/src/handlers/auth.rs new file mode 100644 index 0000000..77bacb6 --- /dev/null +++ b/rust-bracket/src/handlers/auth.rs @@ -0,0 +1,136 @@ +use axum::{ + extract::State, + http::StatusCode, + response::Json, +}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::{ + auth::{generate_token, hash_password, verify_password}, + models::player, + AppState, +}; + +#[derive(Debug, Deserialize, Validate)] +pub struct LoginRequest { + #[validate(length(min = 1))] + pub login: String, + #[validate(length(min = 1))] + pub password: String, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct RegisterRequest { + #[validate(length(min = 1))] + pub login: String, + #[validate(length(min = 6))] + pub password: String, + #[validate(length(min = 1))] + pub first_name: String, + #[validate(length(min = 1))] + pub last_name: String, + #[validate(email)] + pub email: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub token: String, + pub player: player::Model, +} + +pub async fn login( + State(state): State, + Json(payload): Json, +) -> Result, StatusCode> { + // Validate input + if let Err(_) = payload.validate() { + return Err(StatusCode::BAD_REQUEST); + } + + // Find player by login + let player = player::Entity::find() + .filter(player::Column::Login.eq(&payload.login)) + .filter(player::Column::Active.eq(true)) + .one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Verify password + if !verify_password(&payload.password, &player.password) { + return Err(StatusCode::UNAUTHORIZED); + } + + // Generate token + let token = generate_token(&player).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(AuthResponse { token, player })) +} + +pub async fn register( + State(state): State, + Json(payload): Json, +) -> Result, StatusCode> { + // Validate input + if let Err(_) = payload.validate() { + return Err(StatusCode::BAD_REQUEST); + } + + // Check if login or email already exists + let existing_player = player::Entity::find() + .filter( + player::Column::Login + .eq(&payload.login) + .or(player::Column::Email.eq(&payload.email)), + ) + .one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if existing_player.is_some() { + return Err(StatusCode::CONFLICT); + } + + // Hash password + let hashed_password = hash_password(&payload.password) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Create new player + let new_player = player::ActiveModel { + login: Set(payload.login), + password: Set(hashed_password), + first_name: Set(payload.first_name), + last_name: Set(payload.last_name), + email: Set(payload.email), + is_admin: Set(false), + active: Set(true), + ..Default::default() + }; + + let player = new_player + .insert(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Generate token + let token = generate_token(&player).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(AuthResponse { token, player })) +} + +pub async fn get_profile( + State(state): State, + player_id: i32, +) -> Result, StatusCode> { + let player = player::Entity::find_by_id(player_id) + .one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(player)) +} + diff --git a/rust-bracket/src/handlers/bracket.rs b/rust-bracket/src/handlers/bracket.rs new file mode 100644 index 0000000..8658215 --- /dev/null +++ b/rust-bracket/src/handlers/bracket.rs @@ -0,0 +1,197 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::Json, +}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::{ + models::{game, pick, player, region, team, tournament}, + tournament::BracketService, + AppState, +}; + +#[derive(Debug, Deserialize, Validate)] +pub struct CreatePickRequest { + pub game_id: i32, + pub team_id: i32, +} + +#[derive(Debug, Deserialize, Validate)] +pub struct UpdateGameResultRequest { + pub winner_id: i32, +} + +#[derive(Debug, Serialize)] +pub struct PlayerBracketResponse { + pub picks: Vec, + pub score: i32, +} + +#[derive(Debug, Serialize)] +pub struct LeaderboardEntry { + pub player: player::Model, + pub score: i32, +} + +pub async fn get_bracket( + State(state): State, +) -> Result, StatusCode> { + let bracket_service = BracketService::new(state.db.clone()); + let status = bracket_service + .get_bracket_status() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(serde_json::to_value(status).unwrap())) +} + +pub async fn get_player_bracket( + State(state): State, + Path(player_id): Path, +) -> Result, StatusCode> { + // Get player's picks + let picks = pick::Entity::find() + .filter(pick::Column::PlayerId.eq(player_id)) + .all(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Calculate score + let bracket_service = BracketService::new(state.db.clone()); + let score = bracket_service + .calculate_player_score(player_id) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(PlayerBracketResponse { picks, score })) +} + +pub async fn create_pick( + State(state): State, + player_id: i32, + Json(payload): Json, +) -> Result, StatusCode> { + // Validate input + if let Err(_) = payload.validate() { + return Err(StatusCode::BAD_REQUEST); + } + + // Check if tournament picks are locked + let tournament = tournament::Entity::find() + .filter(tournament::Column::IsActive.eq(true)) + .one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::BAD_REQUEST)?; + + if tournament.picks_locked { + return Err(StatusCode::FORBIDDEN); + } + + // Verify the game exists and the team is valid for that game + let game = game::Entity::find_by_id(payload.game_id) + .one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + // Check if team is valid for this game + if let (Some(team1_id), Some(team2_id)) = (game.team1_id, game.team2_id) { + if payload.team_id != team1_id && payload.team_id != team2_id { + return Err(StatusCode::BAD_REQUEST); + } + } + + // Check if pick already exists, update if so + if let Some(existing_pick) = pick::Entity::find() + .filter(pick::Column::PlayerId.eq(player_id)) + .filter(pick::Column::GameId.eq(payload.game_id)) + .one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + { + // Update existing pick + let mut pick_active: pick::ActiveModel = existing_pick.into(); + pick_active.pick_id = Set(payload.team_id); + let updated_pick = pick_active + .update(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + return Ok(Json(updated_pick)); + } + + // Create new pick + let new_pick = pick::ActiveModel { + player_id: Set(player_id), + game_id: Set(payload.game_id), + pick_id: Set(payload.team_id), + ..Default::default() + }; + + let pick = new_pick + .insert(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(pick)) +} + +pub async fn update_game_result( + State(state): State, + Path(game_id): Path, + Json(payload): Json, +) -> Result, StatusCode> { + let bracket_service = BracketService::new(state.db.clone()); + bracket_service + .update_game_result(game_id, payload.winner_id) + .await + .map_err(|_| StatusCode::BAD_REQUEST)?; + + Ok(Json(serde_json::json!({"message": "Game result updated successfully"}))) +} + +pub async fn get_leaderboard( + State(state): State, +) -> Result>, StatusCode> { + let bracket_service = BracketService::new(state.db.clone()); + let leaderboard = bracket_service + .get_leaderboard() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let response: Vec = leaderboard + .into_iter() + .map(|entry| LeaderboardEntry { + player: entry.player, + score: entry.score, + }) + .collect(); + + Ok(Json(response)) +} + +pub async fn get_teams( + State(state): State, +) -> Result>, StatusCode> { + let teams = team::Entity::find() + .all(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(teams)) +} + +pub async fn get_regions( + State(state): State, +) -> Result>, StatusCode> { + let regions = region::Entity::find() + .all(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(regions)) +} + diff --git a/rust-bracket/src/handlers/mod.rs b/rust-bracket/src/handlers/mod.rs new file mode 100644 index 0000000..d049596 --- /dev/null +++ b/rust-bracket/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod bracket; + diff --git a/rust-bracket/src/main.rs b/rust-bracket/src/main.rs new file mode 100644 index 0000000..46522f1 --- /dev/null +++ b/rust-bracket/src/main.rs @@ -0,0 +1,115 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + middleware, + response::Json, + routing::{get, post, put}, + Router, +}; +use sea_orm::DatabaseConnection; +use std::env; +use tower::ServiceBuilder; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod auth; +mod database; +mod handlers; +mod middleware as app_middleware; +mod models; +mod tournament; + +#[derive(Clone)] +pub struct AppState { + pub db: DatabaseConnection, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize tracing + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "rust_bracket=debug,tower_http=debug".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Load environment variables + dotenvy::dotenv().ok(); + + // Database connection + let database_url = env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite://bracket.db".to_string()); + + let db = database::establish_connection(&database_url).await?; + + // Seed initial data + database::seed_initial_data(&db).await?; + + let state = AppState { db }; + + // Build our application with routes + let app = Router::new() + // Public routes + .route("/api/v1/auth/login", post(handlers::auth::login)) + .route("/api/v1/auth/register", post(handlers::auth::register)) + .route("/api/v1/bracket", get(handlers::bracket::get_bracket)) + .route("/api/v1/teams", get(handlers::bracket::get_teams)) + .route("/api/v1/regions", get(handlers::bracket::get_regions)) + .route("/api/v1/leaderboard", get(handlers::bracket::get_leaderboard)) + // Protected routes + .route( + "/api/v1/auth/profile", + get(|State(state): State, request: axum::extract::Request| async move { + let claims = request.extensions().get::().unwrap(); + handlers::auth::get_profile(State(state), claims.player_id).await + }), + ) + .route( + "/api/v1/bracket/player/:player_id", + get(handlers::bracket::get_player_bracket), + ) + .route( + "/api/v1/bracket/pick", + post(|State(state): State, request: axum::extract::Request, Json(payload): Json| async move { + let claims = request.extensions().get::().unwrap(); + handlers::bracket::create_pick(State(state), claims.player_id, Json(payload)).await + }), + ) + .route_layer(middleware::from_fn_with_state( + state.clone(), + app_middleware::auth::auth_middleware, + )) + // Admin routes + .route( + "/api/v1/admin/bracket/game/:game_id/result", + put(handlers::bracket::update_game_result), + ) + .route_layer(middleware::from_fn( + app_middleware::auth::admin_middleware, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + app_middleware::auth::auth_middleware, + )) + // Health check + .route("/health", get(|| async { Json(serde_json::json!({"status": "ok"})) })) + .layer( + ServiceBuilder::new() + .layer(TraceLayer::new_for_http()) + .layer(CorsLayer::permissive()), + ) + .with_state(state); + + // Run the server + let port = env::var("PORT").unwrap_or_else(|_| "3000".to_string()); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?; + + tracing::info!("Server running on port {}", port); + + axum::serve(listener, app).await?; + + Ok(()) +} + diff --git a/rust-bracket/src/middleware/auth.rs b/rust-bracket/src/middleware/auth.rs new file mode 100644 index 0000000..0cd1653 --- /dev/null +++ b/rust-bracket/src/middleware/auth.rs @@ -0,0 +1,49 @@ +use axum::{ + extract::{Request, State}, + http::{header::AUTHORIZATION, StatusCode}, + middleware::Next, + response::Response, +}; + +use crate::{auth::validate_token, AppState}; + +pub async fn auth_middleware( + State(_state): State, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request + .headers() + .get(AUTHORIZATION) + .and_then(|header| header.to_str().ok()) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if !auth_header.starts_with("Bearer ") { + return Err(StatusCode::UNAUTHORIZED); + } + + let token = &auth_header[7..]; // Remove "Bearer " prefix + let claims = validate_token(token).map_err(|_| StatusCode::UNAUTHORIZED)?; + + // Add claims to request extensions + request.extensions_mut().insert(claims); + + Ok(next.run(request).await) +} + +pub async fn admin_middleware( + request: Request, + next: Next, +) -> Result { + let claims = request + .extensions() + .get::() + .ok_or(StatusCode::UNAUTHORIZED)?; + + if !claims.is_admin { + return Err(StatusCode::FORBIDDEN); + } + + Ok(next.run(request).await) +} + diff --git a/rust-bracket/src/middleware/mod.rs b/rust-bracket/src/middleware/mod.rs new file mode 100644 index 0000000..ed9d022 --- /dev/null +++ b/rust-bracket/src/middleware/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; + diff --git a/rust-bracket/src/models/game.rs b/rust-bracket/src/models/game.rs new file mode 100644 index 0000000..ecf8c22 --- /dev/null +++ b/rust-bracket/src/models/game.rs @@ -0,0 +1,63 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "games")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub round: i16, + pub region_id: Option, + pub team1_id: Option, + pub team2_id: Option, + pub winner_id: Option, + pub point_value: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::region::Entity", + from = "Column::RegionId", + to = "super::region::Column::Id" + )] + Region, + #[sea_orm( + belongs_to = "super::team::Entity", + from = "Column::Team1Id", + to = "super::team::Column::Id" + )] + Team1, + #[sea_orm( + belongs_to = "super::team::Entity", + from = "Column::Team2Id", + to = "super::team::Column::Id" + )] + Team2, + #[sea_orm( + belongs_to = "super::team::Entity", + from = "Column::WinnerId", + to = "super::team::Column::Id" + )] + Winner, + #[sea_orm(has_many = "super::pick::Entity")] + Picks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Region.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Picks.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +pub use Entity as GameEntity; + diff --git a/rust-bracket/src/models/mod.rs b/rust-bracket/src/models/mod.rs new file mode 100644 index 0000000..81679f7 --- /dev/null +++ b/rust-bracket/src/models/mod.rs @@ -0,0 +1,17 @@ +pub mod region; +pub mod team; +pub mod player; +pub mod game; +pub mod pick; +pub mod region_score; +pub mod session; +pub mod tournament; + +pub use region::Entity as Region; +pub use team::Entity as Team; +pub use player::Entity as Player; +pub use game::Entity as Game; +pub use pick::Entity as Pick; +pub use region_score::Entity as RegionScore; +pub use session::Entity as Session; +pub use tournament::Entity as Tournament; diff --git a/rust-bracket/src/models/pick.rs b/rust-bracket/src/models/pick.rs new file mode 100644 index 0000000..076edfe --- /dev/null +++ b/rust-bracket/src/models/pick.rs @@ -0,0 +1,76 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "picks")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub player_id: i32, + pub game_id: i32, + pub pick_id: i32, // Team ID they picked to win + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::player::Entity", + from = "Column::PlayerId", + to = "super::player::Column::Id" + )] + Player, + #[sea_orm( + belongs_to = "super::game::Entity", + from = "Column::GameId", + to = "super::game::Column::Id" + )] + Game, + #[sea_orm( + belongs_to = "super::team::Entity", + from = "Column::PickId", + to = "super::team::Column::Id" + )] + Pick, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Player.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Game.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Pick.def() + } +} + +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + ..ActiveModelTrait::default() + } + } + + fn before_save(mut self, _db: &C, _insert: bool) -> Result + where + C: ConnectionTrait, + { + self.updated_at = Set(Utc::now()); + Ok(self) + } +} + +pub use Entity as PickEntity; + diff --git a/rust-bracket/src/models/player.rs b/rust-bracket/src/models/player.rs new file mode 100644 index 0000000..7f212db --- /dev/null +++ b/rust-bracket/src/models/player.rs @@ -0,0 +1,73 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "players")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub login: String, + #[serde(skip_serializing)] + pub password: String, + pub first_name: String, + pub last_name: String, + #[sea_orm(unique)] + pub email: String, + pub is_admin: bool, + pub active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::pick::Entity")] + Picks, + #[sea_orm(has_many = "super::region_score::Entity")] + RegionScores, + #[sea_orm(has_many = "super::session::Entity")] + Sessions, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Picks.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RegionScores.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Sessions.def() + } +} + +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + active: Set(true), + is_admin: Set(false), + ..ActiveModelTrait::default() + } + } + + fn before_save(mut self, _db: &C, _insert: bool) -> Result + where + C: ConnectionTrait, + { + self.updated_at = Set(Utc::now()); + Ok(self) + } +} + +pub use Entity as PlayerEntity; + diff --git a/rust-bracket/src/models/region.rs b/rust-bracket/src/models/region.rs new file mode 100644 index 0000000..3939e0b --- /dev/null +++ b/rust-bracket/src/models/region.rs @@ -0,0 +1,36 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "regions")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub name: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::team::Entity")] + Teams, + #[sea_orm(has_many = "super::game::Entity")] + Games, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Teams.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Games.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +pub use Entity as RegionEntity; + diff --git a/rust-bracket/src/models/region_score.rs b/rust-bracket/src/models/region_score.rs new file mode 100644 index 0000000..2d56957 --- /dev/null +++ b/rust-bracket/src/models/region_score.rs @@ -0,0 +1,45 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "region_scores")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub player_id: i32, + pub region_id: i32, + pub points: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::player::Entity", + from = "Column::PlayerId", + to = "super::player::Column::Id" + )] + Player, + #[sea_orm( + belongs_to = "super::region::Entity", + from = "Column::RegionId", + to = "super::region::Column::Id" + )] + Region, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Player.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Region.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +pub use Entity as RegionScoreEntity; + diff --git a/rust-bracket/src/models/session.rs b/rust-bracket/src/models/session.rs new file mode 100644 index 0000000..fa3a308 --- /dev/null +++ b/rust-bracket/src/models/session.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "sessions")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: String, + pub player_id: i32, + pub data: Option, + pub expires_at: DateTime, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::player::Entity", + from = "Column::PlayerId", + to = "super::player::Column::Id" + )] + Player, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Player.def() + } +} + +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + created_at: Set(Utc::now()), + ..ActiveModelTrait::default() + } + } +} + +pub use Entity as SessionEntity; + diff --git a/rust-bracket/src/models/team.rs b/rust-bracket/src/models/team.rs new file mode 100644 index 0000000..058101c --- /dev/null +++ b/rust-bracket/src/models/team.rs @@ -0,0 +1,44 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "teams")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub seed: i16, + #[sea_orm(unique)] + pub name: String, + pub region_id: i32, + pub url: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::region::Entity", + from = "Column::RegionId", + to = "super::region::Column::Id" + )] + Region, + #[sea_orm(has_many = "super::pick::Entity")] + Picks, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Region.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Picks.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} + +pub use Entity as TeamEntity; + diff --git a/rust-bracket/src/models/tournament.rs b/rust-bracket/src/models/tournament.rs new file mode 100644 index 0000000..1b8c485 --- /dev/null +++ b/rust-bracket/src/models/tournament.rs @@ -0,0 +1,45 @@ +use chrono::{DateTime, Utc}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "tournaments")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub year: i32, + pub name: String, + pub start_date: Option>, + pub end_date: Option>, + pub is_active: bool, + pub picks_locked: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel { + fn new() -> Self { + Self { + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + is_active: Set(false), + picks_locked: Set(false), + ..ActiveModelTrait::default() + } + } + + fn before_save(mut self, _db: &C, _insert: bool) -> Result + where + C: ConnectionTrait, + { + self.updated_at = Set(Utc::now()); + Ok(self) + } +} + +pub use Entity as TournamentEntity; + diff --git a/rust-bracket/src/tournament/mod.rs b/rust-bracket/src/tournament/mod.rs new file mode 100644 index 0000000..8d5cd45 --- /dev/null +++ b/rust-bracket/src/tournament/mod.rs @@ -0,0 +1,232 @@ +use anyhow::Result; +use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, ColumnTrait}; +use serde::{Deserialize, Serialize}; + +use crate::models::{game, pick, player, team}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct BracketStatus { + pub games: Vec, + pub total_games: usize, + pub completed_games: usize, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LeaderboardEntry { + pub player: player::Model, + pub score: i32, +} + +pub struct BracketService { + db: DatabaseConnection, +} + +impl BracketService { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } + + pub async fn get_bracket_status(&self) -> Result { + let games = game::Entity::find().all(&self.db).await?; + let completed_games = games.iter().filter(|g| g.winner_id.is_some()).count(); + + Ok(BracketStatus { + total_games: games.len(), + completed_games, + games, + }) + } + + pub async fn calculate_player_score(&self, player_id: i32) -> Result { + let picks = pick::Entity::find() + .filter(pick::Column::PlayerId.eq(player_id)) + .find_also_related(game::Entity) + .all(&self.db) + .await?; + + let mut total_score = 0; + + for (pick, game_opt) in picks { + if let Some(game) = game_opt { + if let Some(winner_id) = game.winner_id { + if winner_id == pick.pick_id { + total_score += game.point_value; + } + } + } + } + + Ok(total_score) + } + + pub async fn update_game_result(&self, game_id: i32, winner_id: i32) -> Result<()> { + use sea_orm::{ActiveModelTrait, Set}; + + // Get the game and verify the winner is valid + let game = game::Entity::find_by_id(game_id) + .one(&self.db) + .await? + .ok_or_else(|| anyhow::anyhow!("Game not found"))?; + + // Verify winner is one of the teams in the game + if let (Some(team1_id), Some(team2_id)) = (game.team1_id, game.team2_id) { + if winner_id != team1_id && winner_id != team2_id { + return Err(anyhow::anyhow!("Winner must be one of the teams in the game")); + } + } else { + return Err(anyhow::anyhow!("Game teams not set")); + } + + // Update the game result + let mut game_active: game::ActiveModel = game.into(); + game_active.winner_id = Set(Some(winner_id)); + game_active.update(&self.db).await?; + + Ok(()) + } + + pub async fn create_bracket(&self, _tournament_id: i32) -> Result<()> { + // Get all teams + let teams = team::Entity::find().all(&self.db).await?; + + if teams.len() != 64 { + return Err(anyhow::anyhow!("Tournament requires exactly 64 teams")); + } + + // Group teams by region + let mut region_teams: std::collections::HashMap> = + std::collections::HashMap::new(); + + for team in teams { + region_teams.entry(team.region_id).or_default().push(team); + } + + // Create regional games (rounds 1-4) + let mut game_id = 1; + for (region_id, teams) in region_teams { + if teams.len() != 16 { + return Err(anyhow::anyhow!("Region {} must have exactly 16 teams", region_id)); + } + + game_id = self.create_regional_games(teams, region_id, game_id).await?; + } + + // Create Final Four games + self.create_final_four_games(game_id).await?; + + Ok(()) + } + + async fn create_regional_games( + &self, + teams: Vec, + region_id: i32, + start_game_id: i32, + ) -> Result { + use sea_orm::{ActiveModelTrait, Set}; + + let mut game_id = start_game_id; + + // Round 1: 16 teams -> 8 games + for i in 0..8 { + let game = game::ActiveModel { + id: Set(game_id), + round: Set(1), + region_id: Set(Some(region_id)), + team1_id: Set(Some(teams[i * 2].id)), + team2_id: Set(Some(teams[i * 2 + 1].id)), + point_value: Set(1), + ..Default::default() + }; + game.insert(&self.db).await?; + game_id += 1; + } + + // Round 2: 8 winners -> 4 games + for _ in 0..4 { + let game = game::ActiveModel { + id: Set(game_id), + round: Set(2), + region_id: Set(Some(region_id)), + point_value: Set(2), + ..Default::default() + }; + game.insert(&self.db).await?; + game_id += 1; + } + + // Round 3 (Sweet 16): 4 winners -> 2 games + for _ in 0..2 { + let game = game::ActiveModel { + id: Set(game_id), + round: Set(3), + region_id: Set(Some(region_id)), + point_value: Set(4), + ..Default::default() + }; + game.insert(&self.db).await?; + game_id += 1; + } + + // Round 4 (Elite 8): 2 winners -> 1 game + let game = game::ActiveModel { + id: Set(game_id), + round: Set(4), + region_id: Set(Some(region_id)), + point_value: Set(8), + ..Default::default() + }; + game.insert(&self.db).await?; + game_id += 1; + + Ok(game_id) + } + + async fn create_final_four_games(&self, start_game_id: i32) -> Result<()> { + use sea_orm::{ActiveModelTrait, Set}; + + // Final Four (2 games) + for i in 0..2 { + let game = game::ActiveModel { + id: Set(start_game_id + i), + round: Set(5), + region_id: Set(None), + point_value: Set(16), + ..Default::default() + }; + game.insert(&self.db).await?; + } + + // Championship game + let championship = game::ActiveModel { + id: Set(start_game_id + 2), + round: Set(6), + region_id: Set(None), + point_value: Set(32), + ..Default::default() + }; + championship.insert(&self.db).await?; + + Ok(()) + } + + pub async fn get_leaderboard(&self) -> Result> { + let players = player::Entity::find() + .filter(player::Column::Active.eq(true)) + .all(&self.db) + .await?; + + let mut leaderboard = Vec::new(); + + for player in players { + let score = self.calculate_player_score(player.id).await.unwrap_or(0); + leaderboard.push(LeaderboardEntry { player, score }); + } + + // Sort by score descending + leaderboard.sort_by(|a, b| b.score.cmp(&a.score)); + + Ok(leaderboard) + } +} +