Spaces:
Sleeping
Sleeping
Commit ·
453520f
0
Parent(s):
chore: first code base version
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- CLAUDE.md +539 -0
- DESIGN_SYSTEM.md +776 -0
- README.md +357 -0
- backend/.env.example +54 -0
- backend/.gitignore +89 -0
- backend/README.md +340 -0
- backend/app/__init__.py +0 -0
- backend/app/config.py +147 -0
- backend/app/database.py +148 -0
- backend/app/main.py +201 -0
- backend/app/models/__init__.py +28 -0
- backend/app/models/challenge.py +295 -0
- backend/app/models/participant.py +190 -0
- backend/app/models/points_transaction.py +235 -0
- backend/app/routes/__init__.py +19 -0
- backend/app/routes/auth.py +87 -0
- backend/app/routes/challenges.py +322 -0
- backend/app/routes/leaderboard.py +131 -0
- backend/app/routes/participants.py +186 -0
- backend/app/routes/points.py +158 -0
- backend/app/schemas/__init__.py +83 -0
- backend/app/schemas/auth.py +144 -0
- backend/app/schemas/challenge.py +246 -0
- backend/app/schemas/common.py +156 -0
- backend/app/schemas/participant.py +188 -0
- backend/app/schemas/points.py +189 -0
- backend/app/services/__init__.py +19 -0
- backend/app/services/auth_service.py +202 -0
- backend/app/services/challenge_service.py +224 -0
- backend/app/services/leaderboard_service.py +237 -0
- backend/app/services/participant_service.py +294 -0
- backend/app/services/points_service.py +296 -0
- backend/app/utils/__init__.py +0 -0
- backend/app/utils/dependencies.py +225 -0
- backend/app/utils/exceptions.py +282 -0
- backend/app/utils/logger.py +195 -0
- backend/app/utils/security.py +280 -0
- backend/app/websocket/__init__.py +18 -0
- backend/app/websocket/leaderboard.py +153 -0
- backend/app/websocket/manager.py +135 -0
- backend/requirements.txt +50 -0
- backend/seed_database.py +263 -0
- backend/tests/__init__.py +0 -0
- frontend/.env.example +27 -0
- frontend/.gitignore +60 -0
- frontend/README.md +272 -0
- frontend/index.html +22 -0
- frontend/package.json +42 -0
- frontend/postcss.config.js +6 -0
- frontend/src/App.tsx +144 -0
CLAUDE.md
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EVG ULTIMATE TEAM - Development Guidelines
|
| 2 |
+
|
| 3 |
+
## Project Overview
|
| 4 |
+
|
| 5 |
+
**EVG ULTIMATE TEAM** is a gamification web application for Paul C's bachelor party (June 4-6, 2026). The app tracks points, challenges, and pack openings throughout a 3-day event (Friday evening to Sunday afternoon) with 13 participants.
|
| 6 |
+
|
| 7 |
+
The app is themed around FIFA Ultimate Team mechanics combined with Paris Saint-Germain (PSG) branding, as Paul is a PSG fan and loves FUT on console/mobile.
|
| 8 |
+
|
| 9 |
+
## Tech Stack
|
| 10 |
+
|
| 11 |
+
- **Frontend**: React (with TypeScript recommended)
|
| 12 |
+
- **Backend**: FastAPI (Python)
|
| 13 |
+
- **Real-time**: WebSockets for live leaderboard updates
|
| 14 |
+
- **Database**: SQLite or PostgreSQL
|
| 15 |
+
- **Styling**: TailwindCSS with custom PSG/FIFA theme
|
| 16 |
+
|
| 17 |
+
## Design System
|
| 18 |
+
|
| 19 |
+
### Color Palette (PSG/FIFA Inspired)
|
| 20 |
+
|
| 21 |
+
**Primary Colors:**
|
| 22 |
+
- PSG Blue: `#004170` (main blue)
|
| 23 |
+
- PSG Red: `#DA291C` (accent red)
|
| 24 |
+
- PSG Navy: `#001E41` (dark backgrounds)
|
| 25 |
+
|
| 26 |
+
**Secondary Colors:**
|
| 27 |
+
- FIFA Gold: `#D4AF37` (for Ultimate packs, premium elements)
|
| 28 |
+
- FIFA Silver: `#C0C0C0` (for Silver packs)
|
| 29 |
+
- FIFA Bronze: `#CD7F32` (for Bronze packs)
|
| 30 |
+
- FIFA Green: `#00FF41` (success, positive actions)
|
| 31 |
+
- FIFA Dark: `#0A0A0A` (backgrounds, cards)
|
| 32 |
+
|
| 33 |
+
**UI Elements:**
|
| 34 |
+
- Card backgrounds: Dark gradient similar to FIFA cards
|
| 35 |
+
- Buttons: PSG blue with red accents
|
| 36 |
+
- Borders: Gold/Silver/Bronze based on rarity
|
| 37 |
+
- Shadows: Glowing effects for rare items
|
| 38 |
+
|
| 39 |
+
### Typography
|
| 40 |
+
|
| 41 |
+
- **Headings**: Bold, sports-inspired font (e.g., Bebas Neue, Oswald)
|
| 42 |
+
- **Body**: Clean sans-serif (e.g., Inter, Roboto)
|
| 43 |
+
- **Numbers/Stats**: Monospace for leaderboard scores
|
| 44 |
+
|
| 45 |
+
### Visual Style
|
| 46 |
+
|
| 47 |
+
- FIFA-style card designs for participants
|
| 48 |
+
- Gradient backgrounds (dark blue to navy)
|
| 49 |
+
- Animated pack openings (cards flipping/revealing)
|
| 50 |
+
- PSG logo/patterns as background elements
|
| 51 |
+
- Neon glow effects for active elements
|
| 52 |
+
|
| 53 |
+
## Core Features
|
| 54 |
+
|
| 55 |
+
### 1. User Management
|
| 56 |
+
|
| 57 |
+
**Participants:**
|
| 58 |
+
- 13 participants including Paul (the groom)
|
| 59 |
+
- Each participant has:
|
| 60 |
+
- Name
|
| 61 |
+
- Avatar (photo or generated)
|
| 62 |
+
- Total points
|
| 63 |
+
- Pack inventory
|
| 64 |
+
- Challenge history
|
| 65 |
+
- Special role (Paul is marked as "Groom")
|
| 66 |
+
|
| 67 |
+
**Admin Access:**
|
| 68 |
+
- Clément (organizer) has admin privileges
|
| 69 |
+
- Can manually adjust points
|
| 70 |
+
- Can trigger events/reveals
|
| 71 |
+
- Can validate challenge completions
|
| 72 |
+
|
| 73 |
+
### 2. Points System
|
| 74 |
+
|
| 75 |
+
**Point Sources:**
|
| 76 |
+
- Individual challenges: 20-50 points
|
| 77 |
+
- Team challenges: 100 points (shared)
|
| 78 |
+
- Secret challenges: 50-100 points
|
| 79 |
+
- Event cards: variable
|
| 80 |
+
- Penalties: -20 points
|
| 81 |
+
|
| 82 |
+
**Points Display:**
|
| 83 |
+
- Real-time leaderboard (FIFA-style ranking)
|
| 84 |
+
- Points history/feed per participant
|
| 85 |
+
- Daily points breakdown
|
| 86 |
+
- Total accumulation over 3 days
|
| 87 |
+
|
| 88 |
+
### 3. Challenge System
|
| 89 |
+
|
| 90 |
+
**Challenge Types:**
|
| 91 |
+
|
| 92 |
+
**Individual Challenges (20-50 pts):**
|
| 93 |
+
- "Convince a stranger you're a Red Bull sales rep"
|
| 94 |
+
- "Win a 1v1 FIFA match against Paul"
|
| 95 |
+
- "Complete a rugby transformation"
|
| 96 |
+
- "Finish first in go-kart racing"
|
| 97 |
+
- "Give a 2-minute speech about why Paul is the best groom"
|
| 98 |
+
- "Order a shot mimicking Paul's accent"
|
| 99 |
+
|
| 100 |
+
**Team Challenges (100 pts shared):**
|
| 101 |
+
- "Win the padel tournament"
|
| 102 |
+
- "Win the football match"
|
| 103 |
+
- "Finish champagne bottle under X minutes"
|
| 104 |
+
- "Complete a 5-person karaoke"
|
| 105 |
+
|
| 106 |
+
**Secret Challenges (50-100 pts):**
|
| 107 |
+
- Revealed randomly or at specific times
|
| 108 |
+
- Example: "Next person to make Paul laugh wins 50 pts"
|
| 109 |
+
- Hidden until triggered
|
| 110 |
+
|
| 111 |
+
**Penalties (-20 pts):**
|
| 112 |
+
- Last person awake in the morning
|
| 113 |
+
- Refusing a shot
|
| 114 |
+
- Breaking house rules
|
| 115 |
+
|
| 116 |
+
**Challenge States:**
|
| 117 |
+
- Pending (waiting to be attempted)
|
| 118 |
+
- Active (currently in progress)
|
| 119 |
+
- Completed (validated by admin)
|
| 120 |
+
- Failed (not completed)
|
| 121 |
+
|
| 122 |
+
### 4. Pack System
|
| 123 |
+
|
| 124 |
+
**Pack Tiers:**
|
| 125 |
+
|
| 126 |
+
**Bronze Pack (100 pts):**
|
| 127 |
+
- Free shot
|
| 128 |
+
- Skip one penalty
|
| 129 |
+
- Choose music for 1 hour
|
| 130 |
+
|
| 131 |
+
**Silver Pack (200 pts):**
|
| 132 |
+
- Assign a dare to someone
|
| 133 |
+
- Double points on next challenge
|
| 134 |
+
- Paul must serve your drinks for 2 hours
|
| 135 |
+
|
| 136 |
+
**Gold Pack (300 pts):**
|
| 137 |
+
- Total immunity Sunday morning (no chores)
|
| 138 |
+
- Choose bonus activity
|
| 139 |
+
- Paul makes your bed tomorrow
|
| 140 |
+
|
| 141 |
+
**Ultimate Pack (500 pts - one draw Sunday midday):**
|
| 142 |
+
- Premium prize (expensive bottle, gift card, collector item)
|
| 143 |
+
- Paul wears your jersey/accessory for 1 hour
|
| 144 |
+
- Wildcard: cancel any dare for anyone
|
| 145 |
+
|
| 146 |
+
**Pack Opening Mechanics:**
|
| 147 |
+
- FIFA-style card reveal animation
|
| 148 |
+
- Participants spend points to open packs
|
| 149 |
+
- Random rewards from the tier pool
|
| 150 |
+
- Pack opening history tracked
|
| 151 |
+
- Cooldown between openings (optional)
|
| 152 |
+
|
| 153 |
+
**Pack Opening Schedule:**
|
| 154 |
+
- 3 opening moments per day:
|
| 155 |
+
- Morning (breakfast)
|
| 156 |
+
- Afternoon (pre-activity)
|
| 157 |
+
- Evening (party time)
|
| 158 |
+
- Special Ultimate Pack opening: Sunday midday only
|
| 159 |
+
|
| 160 |
+
### 5. Event Cards
|
| 161 |
+
|
| 162 |
+
**Random Event Mechanics:**
|
| 163 |
+
- Admin can trigger event cards at any time
|
| 164 |
+
- Events affect all or specific participants
|
| 165 |
+
- FIFA-style card reveal animation
|
| 166 |
+
|
| 167 |
+
**Event Types:**
|
| 168 |
+
|
| 169 |
+
**Boost Cards:**
|
| 170 |
+
- "x2 points on next 2 challenges"
|
| 171 |
+
- "Instant 50 points"
|
| 172 |
+
- "Free Bronze pack"
|
| 173 |
+
|
| 174 |
+
**Chaos Cards:**
|
| 175 |
+
- "Everyone loses 50 pts except last place"
|
| 176 |
+
- "Swap points with another player"
|
| 177 |
+
- "All pending challenges reset"
|
| 178 |
+
|
| 179 |
+
**Paul's Choice Cards:**
|
| 180 |
+
- "Paul selects who gets 100 pts"
|
| 181 |
+
- "Paul assigns a dare"
|
| 182 |
+
- "Paul chooses next challenge"
|
| 183 |
+
|
| 184 |
+
### 6. Schedule Integration
|
| 185 |
+
|
| 186 |
+
**3-Day Timeline:**
|
| 187 |
+
|
| 188 |
+
**Friday Evening:**
|
| 189 |
+
- App introduction and rules explanation
|
| 190 |
+
- First secret challenge draw
|
| 191 |
+
- Social challenges begin
|
| 192 |
+
|
| 193 |
+
**Saturday (Peak Day):**
|
| 194 |
+
- Morning: Extreme sports challenges (bungee/skydiving)
|
| 195 |
+
- Afternoon: Padel/football team challenges
|
| 196 |
+
- Midday pack opening
|
| 197 |
+
- Evening: Restaurant + nightlife challenges
|
| 198 |
+
- Evening pack opening
|
| 199 |
+
|
| 200 |
+
**Sunday:**
|
| 201 |
+
- Brunch recovery
|
| 202 |
+
- Afternoon: FIFA tournament + wine blind test
|
| 203 |
+
- **Ultimate Pack opening (midday)**
|
| 204 |
+
- Final leaderboard reveal
|
| 205 |
+
- Trophy ceremony before departure
|
| 206 |
+
|
| 207 |
+
**Time-based Features:**
|
| 208 |
+
- Challenges unlock at specific times
|
| 209 |
+
- Pack openings at scheduled moments
|
| 210 |
+
- Countdown timers for events
|
| 211 |
+
- Daily recap/summary
|
| 212 |
+
|
| 213 |
+
### 7. Live Leaderboard
|
| 214 |
+
|
| 215 |
+
**Display Elements:**
|
| 216 |
+
- Real-time ranking (1st to 13th)
|
| 217 |
+
- Participant photo/avatar
|
| 218 |
+
- Current points total
|
| 219 |
+
- Points gained today
|
| 220 |
+
- Active challenges count
|
| 221 |
+
- Pack inventory icons
|
| 222 |
+
- Special badges (leader, Paul, last place)
|
| 223 |
+
|
| 224 |
+
**Leaderboard Features:**
|
| 225 |
+
- Auto-refresh via WebSocket
|
| 226 |
+
- Animated position changes
|
| 227 |
+
- Highlight top 3 (podium style)
|
| 228 |
+
- Daily leader selection (chooses next day's aperitif theme)
|
| 229 |
+
|
| 230 |
+
### 8. Admin Dashboard
|
| 231 |
+
|
| 232 |
+
**Admin Capabilities:**
|
| 233 |
+
- Create/edit/delete challenges
|
| 234 |
+
- Validate challenge completions
|
| 235 |
+
- Manually adjust points (with reason)
|
| 236 |
+
- Trigger event cards
|
| 237 |
+
- Open pack opening windows
|
| 238 |
+
- View all participant details
|
| 239 |
+
- Reset or modify game state
|
| 240 |
+
- Send notifications to participants
|
| 241 |
+
|
| 242 |
+
**Analytics:**
|
| 243 |
+
- Total points distributed
|
| 244 |
+
- Most completed challenges
|
| 245 |
+
- Pack opening statistics
|
| 246 |
+
- Participant engagement metrics
|
| 247 |
+
|
| 248 |
+
## Data Models
|
| 249 |
+
|
| 250 |
+
### Participant
|
| 251 |
+
```python
|
| 252 |
+
{
|
| 253 |
+
"id": int,
|
| 254 |
+
"name": str,
|
| 255 |
+
"avatar_url": str,
|
| 256 |
+
"is_groom": bool, # True for Paul
|
| 257 |
+
"total_points": int,
|
| 258 |
+
"current_packs": {
|
| 259 |
+
"bronze": int,
|
| 260 |
+
"silver": int,
|
| 261 |
+
"gold": int,
|
| 262 |
+
"ultimate": int
|
| 263 |
+
},
|
| 264 |
+
"created_at": datetime
|
| 265 |
+
}
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
### Challenge
|
| 269 |
+
```python
|
| 270 |
+
{
|
| 271 |
+
"id": int,
|
| 272 |
+
"title": str,
|
| 273 |
+
"description": str,
|
| 274 |
+
"type": enum["individual", "team", "secret"],
|
| 275 |
+
"points": int,
|
| 276 |
+
"status": enum["pending", "active", "completed", "failed"],
|
| 277 |
+
"assigned_to": list[int], # participant IDs
|
| 278 |
+
"completed_by": list[int], # participant IDs
|
| 279 |
+
"created_at": datetime,
|
| 280 |
+
"completed_at": datetime | null,
|
| 281 |
+
"validated_by": int | null # admin ID
|
| 282 |
+
}
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
### Pack
|
| 286 |
+
```python
|
| 287 |
+
{
|
| 288 |
+
"id": int,
|
| 289 |
+
"tier": enum["bronze", "silver", "gold", "ultimate"],
|
| 290 |
+
"cost": int, # points required
|
| 291 |
+
"rewards": list[str], # possible rewards in this pack
|
| 292 |
+
"opened_by": int | null, # participant ID
|
| 293 |
+
"reward_received": str | null,
|
| 294 |
+
"opened_at": datetime | null
|
| 295 |
+
}
|
| 296 |
+
```
|
| 297 |
+
|
| 298 |
+
### Event Card
|
| 299 |
+
```python
|
| 300 |
+
{
|
| 301 |
+
"id": int,
|
| 302 |
+
"title": str,
|
| 303 |
+
"description": str,
|
| 304 |
+
"type": enum["boost", "chaos", "paul_choice"],
|
| 305 |
+
"effect": str, # JSON describing the effect
|
| 306 |
+
"triggered_at": datetime,
|
| 307 |
+
"triggered_by": int, # admin ID
|
| 308 |
+
"affected_participants": list[int]
|
| 309 |
+
}
|
| 310 |
+
```
|
| 311 |
+
|
| 312 |
+
### Points Transaction
|
| 313 |
+
```python
|
| 314 |
+
{
|
| 315 |
+
"id": int,
|
| 316 |
+
"participant_id": int,
|
| 317 |
+
"amount": int, # positive or negative
|
| 318 |
+
"reason": str,
|
| 319 |
+
"challenge_id": int | null,
|
| 320 |
+
"event_id": int | null,
|
| 321 |
+
"created_at": datetime,
|
| 322 |
+
"created_by": int # admin or system
|
| 323 |
+
}
|
| 324 |
+
```
|
| 325 |
+
|
| 326 |
+
## User Stories
|
| 327 |
+
|
| 328 |
+
### As a Participant
|
| 329 |
+
|
| 330 |
+
1. **View My Profile**
|
| 331 |
+
- See my current points total
|
| 332 |
+
- View my challenge history
|
| 333 |
+
- Check my pack inventory
|
| 334 |
+
- See my ranking on leaderboard
|
| 335 |
+
|
| 336 |
+
2. **Browse Challenges**
|
| 337 |
+
- See available challenges
|
| 338 |
+
- View challenge details and points
|
| 339 |
+
- See which challenges I've completed
|
| 340 |
+
- Check secret challenges when revealed
|
| 341 |
+
|
| 342 |
+
3. **Complete Challenges**
|
| 343 |
+
- Mark a challenge as "attempting"
|
| 344 |
+
- Request validation from admin
|
| 345 |
+
- See real-time points update after validation
|
| 346 |
+
|
| 347 |
+
4. **Open Packs**
|
| 348 |
+
- Check if pack opening window is active
|
| 349 |
+
- Spend points to open packs (if enough balance)
|
| 350 |
+
- Experience FIFA-style reveal animation
|
| 351 |
+
- Receive and view my reward
|
| 352 |
+
|
| 353 |
+
5. **Check Leaderboard**
|
| 354 |
+
- See real-time rankings
|
| 355 |
+
- View other participants' points
|
| 356 |
+
- See who's in the lead
|
| 357 |
+
- Check daily leader (who chooses aperitif)
|
| 358 |
+
|
| 359 |
+
6. **Receive Notifications**
|
| 360 |
+
- Get notified when points are earned
|
| 361 |
+
- Alerted when new challenges unlock
|
| 362 |
+
- Notified when pack opening windows open
|
| 363 |
+
- Receive event card notifications
|
| 364 |
+
|
| 365 |
+
### As Paul (The Groom)
|
| 366 |
+
|
| 367 |
+
1. **Special Role Display**
|
| 368 |
+
- Profile marked as "Groom" with special badge
|
| 369 |
+
- Featured on homepage/leaderboard
|
| 370 |
+
|
| 371 |
+
2. **Paul's Choice Events**
|
| 372 |
+
- Receive notifications when "Paul's Choice" cards trigger
|
| 373 |
+
- Select who receives points/rewards
|
| 374 |
+
- Assign dares to participants
|
| 375 |
+
|
| 376 |
+
### As Admin (Clément)
|
| 377 |
+
|
| 378 |
+
1. **Manage Challenges**
|
| 379 |
+
- Create new challenges
|
| 380 |
+
- Edit existing challenges
|
| 381 |
+
- Delete or deactivate challenges
|
| 382 |
+
- Validate challenge completions
|
| 383 |
+
|
| 384 |
+
2. **Control Points**
|
| 385 |
+
- Manually add/subtract points
|
| 386 |
+
- View all points transactions
|
| 387 |
+
- Adjust for errors or special circumstances
|
| 388 |
+
|
| 389 |
+
3. **Trigger Events**
|
| 390 |
+
- Launch event cards at strategic times
|
| 391 |
+
- Control pack opening windows
|
| 392 |
+
- Send global notifications
|
| 393 |
+
|
| 394 |
+
4. **Monitor Game**
|
| 395 |
+
- View real-time statistics
|
| 396 |
+
- Check participant engagement
|
| 397 |
+
- See completion rates
|
| 398 |
+
- Generate end-of-event report
|
| 399 |
+
|
| 400 |
+
## API Endpoints (FastAPI)
|
| 401 |
+
|
| 402 |
+
### Authentication
|
| 403 |
+
- `POST /api/auth/login` - Participant login
|
| 404 |
+
- `POST /api/auth/admin-login` - Admin login
|
| 405 |
+
|
| 406 |
+
### Participants
|
| 407 |
+
- `GET /api/participants` - List all participants
|
| 408 |
+
- `GET /api/participants/{id}` - Get participant details
|
| 409 |
+
- `PUT /api/participants/{id}` - Update participant (admin)
|
| 410 |
+
- `GET /api/participants/{id}/points-history` - Get points transactions
|
| 411 |
+
|
| 412 |
+
### Challenges
|
| 413 |
+
- `GET /api/challenges` - List all challenges
|
| 414 |
+
- `GET /api/challenges/{id}` - Get challenge details
|
| 415 |
+
- `POST /api/challenges` - Create challenge (admin)
|
| 416 |
+
- `PUT /api/challenges/{id}` - Update challenge (admin)
|
| 417 |
+
- `DELETE /api/challenges/{id}` - Delete challenge (admin)
|
| 418 |
+
- `POST /api/challenges/{id}/attempt` - Mark challenge as attempting
|
| 419 |
+
- `POST /api/challenges/{id}/validate` - Validate completion (admin)
|
| 420 |
+
|
| 421 |
+
### Packs
|
| 422 |
+
- `GET /api/packs/available` - Get available pack tiers
|
| 423 |
+
- `POST /api/packs/open` - Open a pack (spend points)
|
| 424 |
+
- `GET /api/packs/history` - Get pack opening history
|
| 425 |
+
- `POST /api/packs/schedule` - Set pack opening window (admin)
|
| 426 |
+
|
| 427 |
+
### Events
|
| 428 |
+
- `GET /api/events` - List all event cards
|
| 429 |
+
- `POST /api/events/trigger` - Trigger an event (admin)
|
| 430 |
+
- `GET /api/events/history` - Get event history
|
| 431 |
+
|
| 432 |
+
### Leaderboard
|
| 433 |
+
- `GET /api/leaderboard` - Get current rankings
|
| 434 |
+
- `GET /api/leaderboard/daily` - Get daily leader
|
| 435 |
+
|
| 436 |
+
### Points
|
| 437 |
+
- `POST /api/points/add` - Manually add points (admin)
|
| 438 |
+
- `POST /api/points/subtract` - Manually subtract points (admin)
|
| 439 |
+
|
| 440 |
+
### WebSocket
|
| 441 |
+
- `WS /ws/leaderboard` - Real-time leaderboard updates
|
| 442 |
+
- `WS /ws/notifications` - Real-time notifications
|
| 443 |
+
|
| 444 |
+
## Frontend Components (React)
|
| 445 |
+
|
| 446 |
+
### Pages
|
| 447 |
+
- `HomePage` - Welcome screen with PSG/FIFA branding
|
| 448 |
+
- `LeaderboardPage` - Live rankings and stats
|
| 449 |
+
- `ChallengesPage` - Browse and track challenges
|
| 450 |
+
- `PackOpeningPage` - Open packs with animation
|
| 451 |
+
- `ProfilePage` - Participant details and history
|
| 452 |
+
- `AdminDashboard` - Admin controls (protected)
|
| 453 |
+
|
| 454 |
+
### Key Components
|
| 455 |
+
- `ParticipantCard` - FIFA-style player card
|
| 456 |
+
- `ChallengeCard` - Challenge display with status
|
| 457 |
+
- `PackCard` - Pack tier display (Bronze/Silver/Gold/Ultimate)
|
| 458 |
+
- `PackOpeningAnimation` - FIFA-style reveal animation
|
| 459 |
+
- `Leaderboard` - Rankings table with live updates
|
| 460 |
+
- `PointsTransaction` - Points feed item
|
| 461 |
+
- `EventCardNotification` - Event card popup
|
| 462 |
+
- `CountdownTimer` - Timer for scheduled events
|
| 463 |
+
|
| 464 |
+
## Implementation Priorities
|
| 465 |
+
|
| 466 |
+
### Phase 1: Core Functionality (MVP)
|
| 467 |
+
1. User authentication (simple login)
|
| 468 |
+
2. Basic participant profiles
|
| 469 |
+
3. Challenge system (create, assign, validate)
|
| 470 |
+
4. Points system (earn, track, display)
|
| 471 |
+
5. Simple leaderboard
|
| 472 |
+
6. Admin dashboard
|
| 473 |
+
|
| 474 |
+
### Phase 2: Gamification
|
| 475 |
+
1. Pack system (purchase, open, rewards)
|
| 476 |
+
2. Pack opening animations
|
| 477 |
+
3. Event cards system
|
| 478 |
+
4. Real-time WebSocket updates
|
| 479 |
+
5. Notifications
|
| 480 |
+
|
| 481 |
+
### Phase 3: Polish
|
| 482 |
+
1. PSG/FIFA themed UI
|
| 483 |
+
2. Smooth animations
|
| 484 |
+
3. Mobile responsiveness
|
| 485 |
+
4. Advanced statistics
|
| 486 |
+
5. End-of-event report generation
|
| 487 |
+
|
| 488 |
+
## Special Considerations
|
| 489 |
+
|
| 490 |
+
### Commercial Challenges (Paul's Job)
|
| 491 |
+
- Include challenges that play on Paul's sales skills
|
| 492 |
+
- "Pitch absurd items" challenges
|
| 493 |
+
- "Negotiate discounts" at restaurants/bars
|
| 494 |
+
- Roleplay commercial scenarios
|
| 495 |
+
|
| 496 |
+
### Rugby Integration
|
| 497 |
+
- Challenges related to rugby (Paul loves rugby)
|
| 498 |
+
- Rugby terminology in challenge descriptions
|
| 499 |
+
- Toulouse Stade references
|
| 500 |
+
- Touch rugby challenges
|
| 501 |
+
|
| 502 |
+
### FIFA/FUT Theme
|
| 503 |
+
- Pack opening must feel like FUT mobile/console
|
| 504 |
+
- Card reveal animations
|
| 505 |
+
- Rarity indicators (common/rare/legendary)
|
| 506 |
+
- "Chemistry" style participant interactions
|
| 507 |
+
|
| 508 |
+
### Mobile-First Design
|
| 509 |
+
- Most participants will use phones during the event
|
| 510 |
+
- Touch-optimized interactions
|
| 511 |
+
- Quick load times
|
| 512 |
+
- Offline support for challenge viewing
|
| 513 |
+
|
| 514 |
+
## Success Metrics
|
| 515 |
+
|
| 516 |
+
- All 13 participants actively engaging with the app
|
| 517 |
+
- Average of 5+ challenges completed per person
|
| 518 |
+
- At least 2 pack openings per person per day
|
| 519 |
+
- Real-time leaderboard updates < 1 second
|
| 520 |
+
- Zero critical bugs during the 3-day event
|
| 521 |
+
|
| 522 |
+
## Deployment
|
| 523 |
+
|
| 524 |
+
- Deploy backend on a reliable host (Heroku, Railway, Render)
|
| 525 |
+
- Deploy frontend on Vercel or Netlify
|
| 526 |
+
- Ensure stable WebSocket connection
|
| 527 |
+
- Test thoroughly before June 4, 2026
|
| 528 |
+
|
| 529 |
+
## Additional Notes
|
| 530 |
+
|
| 531 |
+
- Event runs Friday evening through Sunday afternoon
|
| 532 |
+
- Sunday has NO evening party (departure time)
|
| 533 |
+
- Ultimate Pack opening: Sunday midday ONLY
|
| 534 |
+
- Daily leader on Saturday chooses Sunday aperitif theme
|
| 535 |
+
- Admin (Clément) is final authority on all points/validations
|
| 536 |
+
|
| 537 |
+
---
|
| 538 |
+
|
| 539 |
+
**Good luck building EVG ULTIMATE TEAM! Let's make Paul's bachelor party legendary! 🏆⚽🎮**
|
DESIGN_SYSTEM.md
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PSG Talent Index - Design System
|
| 2 |
+
|
| 3 |
+
## Brand Identity
|
| 4 |
+
|
| 5 |
+
This design system is built around Paris Saint-Germain (PSG) football club branding, creating an immersive football talent management experience inspired by FIFA Ultimate Team (FUT) mechanics.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Color Palette
|
| 10 |
+
|
| 11 |
+
### Primary Colors
|
| 12 |
+
|
| 13 |
+
```css
|
| 14 |
+
--psg-navy: #001F5B; /* Primary brand color - Deep PSG blue */
|
| 15 |
+
--psg-red: #DA291C; /* Accent color - PSG red */
|
| 16 |
+
--psg-white: #FFFFFF; /* Text and backgrounds */
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
### Background Colors
|
| 20 |
+
|
| 21 |
+
```css
|
| 22 |
+
--bg-primary: #0A1628; /* Main dark background */
|
| 23 |
+
--bg-secondary: #152238; /* Secondary dark background */
|
| 24 |
+
--bg-card: #1A2942; /* Card backgrounds */
|
| 25 |
+
--bg-card-hover: #223A5E; /* Card hover state */
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
### Gradient Colors
|
| 29 |
+
|
| 30 |
+
```css
|
| 31 |
+
--gradient-blue-red: linear-gradient(135deg, #2B5A9E 0%, #8B2844 100%);
|
| 32 |
+
--gradient-radar-fill: linear-gradient(180deg, rgba(218, 41, 28, 0.6) 0%, rgba(43, 90, 158, 0.6) 100%);
|
| 33 |
+
--gradient-card-bg: linear-gradient(180deg, rgba(0, 31, 91, 0.3) 0%, rgba(26, 41, 66, 0.8) 100%);
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
### Semantic Colors
|
| 37 |
+
|
| 38 |
+
```css
|
| 39 |
+
--success: #04F06A; /* Success states, positive metrics */
|
| 40 |
+
--warning: #FFB800; /* Warning states */
|
| 41 |
+
--error: #FF4444; /* Error states */
|
| 42 |
+
--info: #3B82F6; /* Information */
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
### Text Colors
|
| 46 |
+
|
| 47 |
+
```css
|
| 48 |
+
--text-primary: #FFFFFF;
|
| 49 |
+
--text-secondary: #A0AEC0;
|
| 50 |
+
--text-tertiary: #718096;
|
| 51 |
+
--text-muted: #4A5568;
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
## Typography
|
| 57 |
+
|
| 58 |
+
### Font Families
|
| 59 |
+
|
| 60 |
+
```css
|
| 61 |
+
--font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 62 |
+
--font-display: 'Montserrat', 'Inter', sans-serif;
|
| 63 |
+
--font-numbers: 'Rajdhani', 'Roboto Mono', monospace;
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### Font Sizes
|
| 67 |
+
|
| 68 |
+
```css
|
| 69 |
+
/* Display */
|
| 70 |
+
--text-display-xl: 4.5rem; /* 72px - Hero numbers */
|
| 71 |
+
--text-display-lg: 3.75rem; /* 60px - Large scores */
|
| 72 |
+
--text-display-md: 3rem; /* 48px - Player names */
|
| 73 |
+
--text-display-sm: 2.25rem; /* 36px - Section titles */
|
| 74 |
+
|
| 75 |
+
/* Headings */
|
| 76 |
+
--text-h1: 2rem; /* 32px */
|
| 77 |
+
--text-h2: 1.5rem; /* 24px */
|
| 78 |
+
--text-h3: 1.25rem; /* 20px */
|
| 79 |
+
--text-h4: 1.125rem; /* 18px */
|
| 80 |
+
|
| 81 |
+
/* Body */
|
| 82 |
+
--text-base: 1rem; /* 16px */
|
| 83 |
+
--text-sm: 0.875rem; /* 14px */
|
| 84 |
+
--text-xs: 0.75rem; /* 12px */
|
| 85 |
+
--text-xxs: 0.625rem; /* 10px */
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Font Weights
|
| 89 |
+
|
| 90 |
+
```css
|
| 91 |
+
--font-light: 300;
|
| 92 |
+
--font-regular: 400;
|
| 93 |
+
--font-medium: 500;
|
| 94 |
+
--font-semibold: 600;
|
| 95 |
+
--font-bold: 700;
|
| 96 |
+
--font-extrabold: 800;
|
| 97 |
+
--font-black: 900;
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
### Line Heights
|
| 101 |
+
|
| 102 |
+
```css
|
| 103 |
+
--leading-tight: 1.1;
|
| 104 |
+
--leading-snug: 1.25;
|
| 105 |
+
--leading-normal: 1.5;
|
| 106 |
+
--leading-relaxed: 1.75;
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
---
|
| 110 |
+
|
| 111 |
+
## Spacing System
|
| 112 |
+
|
| 113 |
+
```css
|
| 114 |
+
--space-1: 0.25rem; /* 4px */
|
| 115 |
+
--space-2: 0.5rem; /* 8px */
|
| 116 |
+
--space-3: 0.75rem; /* 12px */
|
| 117 |
+
--space-4: 1rem; /* 16px */
|
| 118 |
+
--space-5: 1.25rem; /* 20px */
|
| 119 |
+
--space-6: 1.5rem; /* 24px */
|
| 120 |
+
--space-8: 2rem; /* 32px */
|
| 121 |
+
--space-10: 2.5rem; /* 40px */
|
| 122 |
+
--space-12: 3rem; /* 48px */
|
| 123 |
+
--space-16: 4rem; /* 64px */
|
| 124 |
+
--space-20: 5rem; /* 80px */
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
## Border Radius
|
| 130 |
+
|
| 131 |
+
```css
|
| 132 |
+
--radius-sm: 0.25rem; /* 4px - Small elements */
|
| 133 |
+
--radius-md: 0.5rem; /* 8px - Buttons, inputs */
|
| 134 |
+
--radius-lg: 0.75rem; /* 12px - Cards */
|
| 135 |
+
--radius-xl: 1rem; /* 16px - Large cards */
|
| 136 |
+
--radius-2xl: 1.5rem; /* 24px - Hero cards */
|
| 137 |
+
--radius-full: 9999px; /* Pills, avatars */
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## Shadows
|
| 143 |
+
|
| 144 |
+
```css
|
| 145 |
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
| 146 |
+
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
| 147 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
| 148 |
+
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
|
| 149 |
+
--shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
|
| 150 |
+
--shadow-glow: 0 0 20px rgba(218, 41, 28, 0.4);
|
| 151 |
+
--shadow-glow-blue: 0 0 20px rgba(0, 31, 91, 0.5);
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
---
|
| 155 |
+
|
| 156 |
+
## Components
|
| 157 |
+
|
| 158 |
+
### Buttons
|
| 159 |
+
|
| 160 |
+
#### Primary Button (CTA Red)
|
| 161 |
+
```css
|
| 162 |
+
.btn-primary {
|
| 163 |
+
background: var(--psg-red);
|
| 164 |
+
color: var(--psg-white);
|
| 165 |
+
font-weight: var(--font-bold);
|
| 166 |
+
text-transform: uppercase;
|
| 167 |
+
letter-spacing: 0.05em;
|
| 168 |
+
padding: var(--space-3) var(--space-6);
|
| 169 |
+
border-radius: var(--radius-md);
|
| 170 |
+
box-shadow: var(--shadow-md);
|
| 171 |
+
transition: all 0.3s ease;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.btn-primary:hover {
|
| 175 |
+
background: #C31E1A;
|
| 176 |
+
box-shadow: var(--shadow-glow);
|
| 177 |
+
transform: translateY(-2px);
|
| 178 |
+
}
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
#### Secondary Button
|
| 182 |
+
```css
|
| 183 |
+
.btn-secondary {
|
| 184 |
+
background: transparent;
|
| 185 |
+
color: var(--psg-white);
|
| 186 |
+
border: 2px solid var(--psg-navy);
|
| 187 |
+
font-weight: var(--font-semibold);
|
| 188 |
+
padding: var(--space-3) var(--space-6);
|
| 189 |
+
border-radius: var(--radius-md);
|
| 190 |
+
transition: all 0.3s ease;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.btn-secondary:hover {
|
| 194 |
+
background: var(--psg-navy);
|
| 195 |
+
border-color: var(--psg-red);
|
| 196 |
+
}
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
#### Icon Button
|
| 200 |
+
```css
|
| 201 |
+
.btn-icon {
|
| 202 |
+
width: 40px;
|
| 203 |
+
height: 40px;
|
| 204 |
+
display: flex;
|
| 205 |
+
align-items: center;
|
| 206 |
+
justify-content: center;
|
| 207 |
+
background: var(--psg-red);
|
| 208 |
+
color: var(--psg-white);
|
| 209 |
+
border-radius: var(--radius-full);
|
| 210 |
+
box-shadow: var(--shadow-md);
|
| 211 |
+
transition: all 0.3s ease;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.btn-icon:hover {
|
| 215 |
+
transform: scale(1.1);
|
| 216 |
+
box-shadow: var(--shadow-glow);
|
| 217 |
+
}
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
### Cards
|
| 221 |
+
|
| 222 |
+
#### Player Card
|
| 223 |
+
```css
|
| 224 |
+
.player-card {
|
| 225 |
+
background: var(--bg-card);
|
| 226 |
+
border-radius: var(--radius-xl);
|
| 227 |
+
padding: var(--space-6);
|
| 228 |
+
box-shadow: var(--shadow-lg);
|
| 229 |
+
transition: all 0.3s ease;
|
| 230 |
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.player-card:hover {
|
| 234 |
+
background: var(--bg-card-hover);
|
| 235 |
+
box-shadow: var(--shadow-2xl);
|
| 236 |
+
transform: translateY(-4px);
|
| 237 |
+
border-color: rgba(218, 41, 28, 0.3);
|
| 238 |
+
}
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
#### Stat Card
|
| 242 |
+
```css
|
| 243 |
+
.stat-card {
|
| 244 |
+
background: linear-gradient(135deg, rgba(0, 31, 91, 0.4) 0%, rgba(26, 41, 66, 0.6) 100%);
|
| 245 |
+
border-radius: var(--radius-lg);
|
| 246 |
+
padding: var(--space-8);
|
| 247 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 248 |
+
backdrop-filter: blur(10px);
|
| 249 |
+
}
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
### Navigation
|
| 253 |
+
|
| 254 |
+
#### Header Navigation
|
| 255 |
+
```css
|
| 256 |
+
.nav-header {
|
| 257 |
+
background: var(--bg-primary);
|
| 258 |
+
height: 80px;
|
| 259 |
+
display: flex;
|
| 260 |
+
align-items: center;
|
| 261 |
+
padding: 0 var(--space-8);
|
| 262 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 263 |
+
box-shadow: var(--shadow-md);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
.nav-item {
|
| 267 |
+
color: var(--text-secondary);
|
| 268 |
+
font-weight: var(--font-semibold);
|
| 269 |
+
font-size: var(--text-sm);
|
| 270 |
+
text-transform: uppercase;
|
| 271 |
+
letter-spacing: 0.05em;
|
| 272 |
+
padding: var(--space-4) var(--space-6);
|
| 273 |
+
transition: all 0.3s ease;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.nav-item:hover,
|
| 277 |
+
.nav-item.active {
|
| 278 |
+
color: var(--psg-white);
|
| 279 |
+
background: rgba(218, 41, 28, 0.1);
|
| 280 |
+
border-bottom: 2px solid var(--psg-red);
|
| 281 |
+
}
|
| 282 |
+
```
|
| 283 |
+
|
| 284 |
+
### Tables & Leaderboards
|
| 285 |
+
|
| 286 |
+
#### Leaderboard Table
|
| 287 |
+
```css
|
| 288 |
+
.leaderboard {
|
| 289 |
+
background: var(--bg-card);
|
| 290 |
+
border-radius: var(--radius-xl);
|
| 291 |
+
overflow: hidden;
|
| 292 |
+
box-shadow: var(--shadow-lg);
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
.leaderboard-header {
|
| 296 |
+
background: rgba(0, 31, 91, 0.6);
|
| 297 |
+
padding: var(--space-4) var(--space-6);
|
| 298 |
+
font-weight: var(--font-bold);
|
| 299 |
+
text-transform: uppercase;
|
| 300 |
+
font-size: var(--text-xs);
|
| 301 |
+
letter-spacing: 0.1em;
|
| 302 |
+
color: var(--text-secondary);
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.leaderboard-row {
|
| 306 |
+
display: grid;
|
| 307 |
+
grid-template-columns: 60px 1fr repeat(6, 80px);
|
| 308 |
+
gap: var(--space-4);
|
| 309 |
+
padding: var(--space-4) var(--space-6);
|
| 310 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 311 |
+
transition: background 0.2s ease;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
.leaderboard-row:hover {
|
| 315 |
+
background: rgba(218, 41, 28, 0.05);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
.leaderboard-rank {
|
| 319 |
+
font-size: var(--text-h3);
|
| 320 |
+
font-weight: var(--font-black);
|
| 321 |
+
color: var(--text-secondary);
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.leaderboard-rank.top-3 {
|
| 325 |
+
color: var(--psg-red);
|
| 326 |
+
}
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
### Data Visualization
|
| 330 |
+
|
| 331 |
+
#### Radar Chart (Pentagon)
|
| 332 |
+
```css
|
| 333 |
+
.radar-chart {
|
| 334 |
+
width: 100%;
|
| 335 |
+
aspect-ratio: 1;
|
| 336 |
+
background: radial-gradient(circle, rgba(0, 31, 91, 0.2) 0%, transparent 70%);
|
| 337 |
+
border-radius: var(--radius-lg);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.radar-fill {
|
| 341 |
+
fill: url(#radarGradient);
|
| 342 |
+
opacity: 0.7;
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
.radar-stroke {
|
| 346 |
+
stroke: var(--psg-red);
|
| 347 |
+
stroke-width: 2;
|
| 348 |
+
fill: none;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.radar-grid {
|
| 352 |
+
stroke: rgba(255, 255, 255, 0.1);
|
| 353 |
+
stroke-width: 1;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
.radar-label {
|
| 357 |
+
fill: var(--text-secondary);
|
| 358 |
+
font-size: var(--text-xs);
|
| 359 |
+
font-weight: var(--font-medium);
|
| 360 |
+
text-transform: uppercase;
|
| 361 |
+
}
|
| 362 |
+
```
|
| 363 |
+
|
| 364 |
+
#### Stat Bars
|
| 365 |
+
```css
|
| 366 |
+
.stat-bar-container {
|
| 367 |
+
background: rgba(255, 255, 255, 0.05);
|
| 368 |
+
height: 40px;
|
| 369 |
+
border-radius: var(--radius-md);
|
| 370 |
+
overflow: hidden;
|
| 371 |
+
position: relative;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.stat-bar {
|
| 375 |
+
background: var(--psg-red);
|
| 376 |
+
height: 100%;
|
| 377 |
+
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
| 378 |
+
position: relative;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.stat-bar::after {
|
| 382 |
+
content: '';
|
| 383 |
+
position: absolute;
|
| 384 |
+
top: 0;
|
| 385 |
+
right: 0;
|
| 386 |
+
bottom: 0;
|
| 387 |
+
left: 0;
|
| 388 |
+
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.2) 100%);
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.stat-value {
|
| 392 |
+
position: absolute;
|
| 393 |
+
top: 50%;
|
| 394 |
+
right: var(--space-4);
|
| 395 |
+
transform: translateY(-50%);
|
| 396 |
+
font-size: var(--text-h3);
|
| 397 |
+
font-weight: var(--font-bold);
|
| 398 |
+
color: var(--psg-white);
|
| 399 |
+
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
.stat-label {
|
| 403 |
+
position: absolute;
|
| 404 |
+
top: 50%;
|
| 405 |
+
left: var(--space-4);
|
| 406 |
+
transform: translateY(-50%);
|
| 407 |
+
font-size: var(--text-sm);
|
| 408 |
+
font-weight: var(--font-semibold);
|
| 409 |
+
color: var(--text-primary);
|
| 410 |
+
text-transform: uppercase;
|
| 411 |
+
}
|
| 412 |
+
```
|
| 413 |
+
|
| 414 |
+
### Score Display (TIS - Talent Index Score)
|
| 415 |
+
|
| 416 |
+
```css
|
| 417 |
+
.tis-score {
|
| 418 |
+
background: var(--psg-white);
|
| 419 |
+
color: var(--psg-navy);
|
| 420 |
+
padding: var(--space-6);
|
| 421 |
+
border-radius: var(--radius-lg);
|
| 422 |
+
text-align: center;
|
| 423 |
+
box-shadow: var(--shadow-xl);
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.tis-label {
|
| 427 |
+
font-size: var(--text-sm);
|
| 428 |
+
font-weight: var(--font-bold);
|
| 429 |
+
text-transform: uppercase;
|
| 430 |
+
letter-spacing: 0.1em;
|
| 431 |
+
color: var(--text-muted);
|
| 432 |
+
margin-bottom: var(--space-2);
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.tis-value {
|
| 436 |
+
font-size: var(--text-display-lg);
|
| 437 |
+
font-weight: var(--font-black);
|
| 438 |
+
font-family: var(--font-numbers);
|
| 439 |
+
line-height: var(--leading-tight);
|
| 440 |
+
color: var(--psg-navy);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.tis-cta {
|
| 444 |
+
margin-top: var(--space-4);
|
| 445 |
+
font-size: var(--text-xs);
|
| 446 |
+
font-weight: var(--font-semibold);
|
| 447 |
+
text-transform: uppercase;
|
| 448 |
+
color: var(--psg-red);
|
| 449 |
+
display: flex;
|
| 450 |
+
align-items: center;
|
| 451 |
+
justify-content: center;
|
| 452 |
+
gap: var(--space-2);
|
| 453 |
+
}
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
### Pack Opening (FUT-style)
|
| 457 |
+
|
| 458 |
+
#### Pack Card
|
| 459 |
+
```css
|
| 460 |
+
.pack-card {
|
| 461 |
+
background: linear-gradient(135deg, #1A2942 0%, #0A1628 100%);
|
| 462 |
+
border: 2px solid rgba(218, 41, 28, 0.3);
|
| 463 |
+
border-radius: var(--radius-2xl);
|
| 464 |
+
padding: var(--space-8);
|
| 465 |
+
position: relative;
|
| 466 |
+
overflow: hidden;
|
| 467 |
+
cursor: pointer;
|
| 468 |
+
transition: all 0.3s ease;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.pack-card::before {
|
| 472 |
+
content: '';
|
| 473 |
+
position: absolute;
|
| 474 |
+
top: -50%;
|
| 475 |
+
left: -50%;
|
| 476 |
+
width: 200%;
|
| 477 |
+
height: 200%;
|
| 478 |
+
background: radial-gradient(circle, rgba(218, 41, 28, 0.1) 0%, transparent 70%);
|
| 479 |
+
animation: pulse 3s ease-in-out infinite;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
.pack-card:hover {
|
| 483 |
+
transform: scale(1.05);
|
| 484 |
+
border-color: var(--psg-red);
|
| 485 |
+
box-shadow: 0 0 30px rgba(218, 41, 28, 0.6);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
@keyframes pulse {
|
| 489 |
+
0%, 100% { transform: scale(1); opacity: 0.5; }
|
| 490 |
+
50% { transform: scale(1.1); opacity: 0.8; }
|
| 491 |
+
}
|
| 492 |
+
```
|
| 493 |
+
|
| 494 |
+
#### Pack Rarity Indicators
|
| 495 |
+
```css
|
| 496 |
+
.rarity-common {
|
| 497 |
+
border-color: #718096;
|
| 498 |
+
box-shadow: 0 0 15px rgba(113, 128, 150, 0.3);
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.rarity-rare {
|
| 502 |
+
border-color: #3B82F6;
|
| 503 |
+
box-shadow: 0 0 15px rgba(59, 130, 246, 0.4);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.rarity-epic {
|
| 507 |
+
border-color: #8B2844;
|
| 508 |
+
box-shadow: 0 0 20px rgba(139, 40, 68, 0.5);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.rarity-legendary {
|
| 512 |
+
border-color: #FFB800;
|
| 513 |
+
box-shadow: 0 0 25px rgba(255, 184, 0, 0.6);
|
| 514 |
+
animation: shimmer 2s ease-in-out infinite;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
@keyframes shimmer {
|
| 518 |
+
0%, 100% { box-shadow: 0 0 25px rgba(255, 184, 0, 0.6); }
|
| 519 |
+
50% { box-shadow: 0 0 40px rgba(255, 184, 0, 0.9); }
|
| 520 |
+
}
|
| 521 |
+
```
|
| 522 |
+
|
| 523 |
+
### Badges & Tags
|
| 524 |
+
|
| 525 |
+
```css
|
| 526 |
+
.badge {
|
| 527 |
+
display: inline-flex;
|
| 528 |
+
align-items: center;
|
| 529 |
+
padding: var(--space-2) var(--space-4);
|
| 530 |
+
border-radius: var(--radius-full);
|
| 531 |
+
font-size: var(--text-xs);
|
| 532 |
+
font-weight: var(--font-bold);
|
| 533 |
+
text-transform: uppercase;
|
| 534 |
+
letter-spacing: 0.05em;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.badge-primary {
|
| 538 |
+
background: var(--psg-red);
|
| 539 |
+
color: var(--psg-white);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.badge-secondary {
|
| 543 |
+
background: rgba(0, 31, 91, 0.5);
|
| 544 |
+
color: var(--psg-white);
|
| 545 |
+
border: 1px solid rgba(0, 31, 91, 0.8);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
.badge-success {
|
| 549 |
+
background: rgba(4, 240, 106, 0.2);
|
| 550 |
+
color: var(--success);
|
| 551 |
+
border: 1px solid var(--success);
|
| 552 |
+
}
|
| 553 |
+
```
|
| 554 |
+
|
| 555 |
+
### Profile Avatar
|
| 556 |
+
|
| 557 |
+
```css
|
| 558 |
+
.avatar {
|
| 559 |
+
width: 80px;
|
| 560 |
+
height: 80px;
|
| 561 |
+
border-radius: var(--radius-full);
|
| 562 |
+
border: 3px solid var(--psg-red);
|
| 563 |
+
box-shadow: var(--shadow-glow);
|
| 564 |
+
object-fit: cover;
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
.avatar-sm {
|
| 568 |
+
width: 40px;
|
| 569 |
+
height: 40px;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.avatar-lg {
|
| 573 |
+
width: 120px;
|
| 574 |
+
height: 120px;
|
| 575 |
+
border-width: 4px;
|
| 576 |
+
}
|
| 577 |
+
```
|
| 578 |
+
|
| 579 |
+
---
|
| 580 |
+
|
| 581 |
+
## Layout Grid
|
| 582 |
+
|
| 583 |
+
```css
|
| 584 |
+
.container {
|
| 585 |
+
max-width: 1440px;
|
| 586 |
+
margin: 0 auto;
|
| 587 |
+
padding: 0 var(--space-8);
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
.grid-player-cards {
|
| 591 |
+
display: grid;
|
| 592 |
+
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
| 593 |
+
gap: var(--space-6);
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.grid-leaderboard {
|
| 597 |
+
display: grid;
|
| 598 |
+
grid-template-columns: 1fr;
|
| 599 |
+
gap: var(--space-4);
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.grid-stats {
|
| 603 |
+
display: grid;
|
| 604 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 605 |
+
gap: var(--space-6);
|
| 606 |
+
}
|
| 607 |
+
```
|
| 608 |
+
|
| 609 |
+
---
|
| 610 |
+
|
| 611 |
+
## Animations
|
| 612 |
+
|
| 613 |
+
### Entrance Animations
|
| 614 |
+
|
| 615 |
+
```css
|
| 616 |
+
@keyframes fadeInUp {
|
| 617 |
+
from {
|
| 618 |
+
opacity: 0;
|
| 619 |
+
transform: translateY(30px);
|
| 620 |
+
}
|
| 621 |
+
to {
|
| 622 |
+
opacity: 1;
|
| 623 |
+
transform: translateY(0);
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
@keyframes scaleIn {
|
| 628 |
+
from {
|
| 629 |
+
opacity: 0;
|
| 630 |
+
transform: scale(0.9);
|
| 631 |
+
}
|
| 632 |
+
to {
|
| 633 |
+
opacity: 1;
|
| 634 |
+
transform: scale(1);
|
| 635 |
+
}
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
.animate-fade-in-up {
|
| 639 |
+
animation: fadeInUp 0.6s ease-out;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
.animate-scale-in {
|
| 643 |
+
animation: scaleIn 0.4s ease-out;
|
| 644 |
+
}
|
| 645 |
+
```
|
| 646 |
+
|
| 647 |
+
### Hover Effects
|
| 648 |
+
|
| 649 |
+
```css
|
| 650 |
+
.hover-lift {
|
| 651 |
+
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.hover-lift:hover {
|
| 655 |
+
transform: translateY(-4px);
|
| 656 |
+
box-shadow: var(--shadow-2xl);
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
.hover-glow {
|
| 660 |
+
transition: box-shadow 0.3s ease;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.hover-glow:hover {
|
| 664 |
+
box-shadow: 0 0 20px rgba(218, 41, 28, 0.5);
|
| 665 |
+
}
|
| 666 |
+
```
|
| 667 |
+
|
| 668 |
+
---
|
| 669 |
+
|
| 670 |
+
## Responsive Breakpoints
|
| 671 |
+
|
| 672 |
+
```css
|
| 673 |
+
/* Mobile First Approach */
|
| 674 |
+
--breakpoint-sm: 640px; /* Small devices */
|
| 675 |
+
--breakpoint-md: 768px; /* Tablets */
|
| 676 |
+
--breakpoint-lg: 1024px; /* Laptops */
|
| 677 |
+
--breakpoint-xl: 1280px; /* Desktops */
|
| 678 |
+
--breakpoint-2xl: 1536px; /* Large screens */
|
| 679 |
+
```
|
| 680 |
+
|
| 681 |
+
---
|
| 682 |
+
|
| 683 |
+
## Icons & Assets
|
| 684 |
+
|
| 685 |
+
### Logo Usage
|
| 686 |
+
- PSG logo should always be visible in the top left corner
|
| 687 |
+
- Minimum size: 40px x 40px
|
| 688 |
+
- Always maintain aspect ratio
|
| 689 |
+
- Use white version on dark backgrounds
|
| 690 |
+
|
| 691 |
+
### Icon Set
|
| 692 |
+
Recommended icon library: **Lucide Icons** or **Heroicons**
|
| 693 |
+
- Stroke width: 2px
|
| 694 |
+
- Size variants: 16px, 20px, 24px, 32px
|
| 695 |
+
- Color: Use `--text-primary` or `--psg-red` for emphasis
|
| 696 |
+
|
| 697 |
+
---
|
| 698 |
+
|
| 699 |
+
## Accessibility
|
| 700 |
+
|
| 701 |
+
### Color Contrast
|
| 702 |
+
- Text on dark backgrounds: Minimum contrast ratio 7:1
|
| 703 |
+
- Interactive elements: Minimum 4.5:1
|
| 704 |
+
- Large text (18px+): Minimum 3:1
|
| 705 |
+
|
| 706 |
+
### Focus States
|
| 707 |
+
```css
|
| 708 |
+
:focus-visible {
|
| 709 |
+
outline: 2px solid var(--psg-red);
|
| 710 |
+
outline-offset: 2px;
|
| 711 |
+
}
|
| 712 |
+
```
|
| 713 |
+
|
| 714 |
+
### Motion Preferences
|
| 715 |
+
```css
|
| 716 |
+
@media (prefers-reduced-motion: reduce) {
|
| 717 |
+
*,
|
| 718 |
+
*::before,
|
| 719 |
+
*::after {
|
| 720 |
+
animation-duration: 0.01ms !important;
|
| 721 |
+
animation-iteration-count: 1 !important;
|
| 722 |
+
transition-duration: 0.01ms !important;
|
| 723 |
+
}
|
| 724 |
+
}
|
| 725 |
+
```
|
| 726 |
+
|
| 727 |
+
---
|
| 728 |
+
|
| 729 |
+
## Usage Examples
|
| 730 |
+
|
| 731 |
+
### Player Profile Header
|
| 732 |
+
```html
|
| 733 |
+
<div class="player-profile">
|
| 734 |
+
<div class="player-avatar">
|
| 735 |
+
<img src="player.jpg" alt="Warren Zaïre-Emery" class="avatar avatar-lg">
|
| 736 |
+
</div>
|
| 737 |
+
<h1 class="player-name">WARREN ZAÏRE-EMERY</h1>
|
| 738 |
+
<p class="player-position">Milieu de terrain</p>
|
| 739 |
+
|
| 740 |
+
<div class="tis-score">
|
| 741 |
+
<div class="tis-label">TIS</div>
|
| 742 |
+
<div class="tis-value">8.00</div>
|
| 743 |
+
<div class="tis-cta">
|
| 744 |
+
VOIR PLUS <span>→</span>
|
| 745 |
+
</div>
|
| 746 |
+
</div>
|
| 747 |
+
</div>
|
| 748 |
+
```
|
| 749 |
+
|
| 750 |
+
### Challenge Card
|
| 751 |
+
```html
|
| 752 |
+
<div class="stat-card">
|
| 753 |
+
<h3 class="challenge-title">Défi du jour</h3>
|
| 754 |
+
<div class="challenge-progress">
|
| 755 |
+
<div class="stat-bar-container">
|
| 756 |
+
<div class="stat-bar" style="width: 65%"></div>
|
| 757 |
+
<span class="stat-label">Passes réussies</span>
|
| 758 |
+
<span class="stat-value">65/100</span>
|
| 759 |
+
</div>
|
| 760 |
+
</div>
|
| 761 |
+
<button class="btn-primary">Compléter le défi</button>
|
| 762 |
+
</div>
|
| 763 |
+
```
|
| 764 |
+
|
| 765 |
+
---
|
| 766 |
+
|
| 767 |
+
## Design Principles
|
| 768 |
+
|
| 769 |
+
1. **Football First**: Every design decision should reinforce the football/PSG theme
|
| 770 |
+
2. **Data Visualization**: Make stats engaging and easy to understand through visual hierarchy
|
| 771 |
+
3. **Gamification**: Incorporate FUT-like mechanics (packs, cards, rewards)
|
| 772 |
+
4. **Performance**: Prioritize smooth animations and fast load times
|
| 773 |
+
5. **Mobile-First**: Ensure all components work perfectly on mobile devices
|
| 774 |
+
6. **Accessibility**: Make the experience enjoyable for all users
|
| 775 |
+
|
| 776 |
+
---
|
README.md
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🏆 EVG ULTIMATE TEAM
|
| 2 |
+
|
| 3 |
+
**Gamification Web Application for Paul's Bachelor Party**
|
| 4 |
+
*June 4-6, 2026 | Paris Saint-Germain × FIFA Ultimate Team Theme*
|
| 5 |
+
|
| 6 |
+
A complete full-stack web application for tracking points, challenges, and pack openings during a 3-day bachelor party event with 13 participants.
|
| 7 |
+
|
| 8 |
+
## 🎮 Overview
|
| 9 |
+
|
| 10 |
+
EVG Ultimate Team brings FIFA Ultimate Team mechanics to real life, themed with Paris Saint-Germain branding for Paul (the groom) who's a massive PSG fan and FUT enthusiast.
|
| 11 |
+
|
| 12 |
+
**Event Details:**
|
| 13 |
+
- **Dates**: June 4-6, 2026 (Friday evening → Sunday afternoon)
|
| 14 |
+
- **Participants**: 13 friends including Paul (the groom)
|
| 15 |
+
- **Organizer**: Clément P. (admin with full control)
|
| 16 |
+
- **Theme**: PSG colors + FIFA Ultimate Team mechanics
|
| 17 |
+
|
| 18 |
+
## ✨ Features
|
| 19 |
+
|
| 20 |
+
### Phase 1 (MVP) - ✅ COMPLETE
|
| 21 |
+
|
| 22 |
+
- ✅ **Simple Authentication** - Username-only login for participants, password for admin
|
| 23 |
+
- ✅ **13 Participants** - Pre-seeded with all bachelor party attendees
|
| 24 |
+
- ✅ **Challenge System** - Individual, team, and secret challenges with points
|
| 25 |
+
- ✅ **Points Tracking** - Complete transaction history and audit trail
|
| 26 |
+
- ✅ **Live Leaderboard** - Real-time rankings via WebSocket
|
| 27 |
+
- ✅ **Admin Dashboard** - Challenge validation, points adjustment, full control
|
| 28 |
+
- ✅ **PSG/FIFA Theme** - Custom Tailwind design with authentic colors
|
| 29 |
+
- ✅ **Mobile-First** - Responsive design optimized for phones
|
| 30 |
+
|
| 31 |
+
### Future Phases
|
| 32 |
+
|
| 33 |
+
- 🔄 **Pack System** - Bronze/Silver/Gold/Ultimate packs with rewards
|
| 34 |
+
- 🔄 **Event Cards** - Boost, Chaos, and Paul's Choice cards
|
| 35 |
+
- 🔄 **Pack Opening Animations** - FIFA-style card reveals
|
| 36 |
+
- 🔄 **Advanced Stats** - Daily breakdowns, engagement metrics
|
| 37 |
+
|
| 38 |
+
## 🚀 Quick Start
|
| 39 |
+
|
| 40 |
+
### Prerequisites
|
| 41 |
+
|
| 42 |
+
- **Backend**: Python 3.10+, pip
|
| 43 |
+
- **Frontend**: Node.js 18+, npm 9+
|
| 44 |
+
|
| 45 |
+
### 1. Clone Repository
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
git clone <repository-url>
|
| 49 |
+
cd evg_ultimate_team
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
### 2. Setup Backend
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
cd backend
|
| 56 |
+
|
| 57 |
+
# Create virtual environment
|
| 58 |
+
python -m venv venv
|
| 59 |
+
source venv/bin/activate # On Windows: venv\Scripts\activate
|
| 60 |
+
|
| 61 |
+
# Install dependencies
|
| 62 |
+
pip install -r requirements.txt
|
| 63 |
+
|
| 64 |
+
# Copy environment file
|
| 65 |
+
cp .env.example .env
|
| 66 |
+
|
| 67 |
+
# Seed database with 13 participants + challenges
|
| 68 |
+
python seed_database.py
|
| 69 |
+
|
| 70 |
+
# Start server
|
| 71 |
+
python -m app.main
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
Backend runs at: **http://localhost:8000**
|
| 75 |
+
|
| 76 |
+
### 3. Setup Frontend
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
cd frontend
|
| 80 |
+
|
| 81 |
+
# Install dependencies
|
| 82 |
+
npm install
|
| 83 |
+
|
| 84 |
+
# Start development server
|
| 85 |
+
npm run dev
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
Frontend runs at: **http://localhost:5173**
|
| 89 |
+
|
| 90 |
+
### 4. Login
|
| 91 |
+
|
| 92 |
+
**Participants** (username only):
|
| 93 |
+
- Paul C., Clément P., Hugo F., Paul J., Théo C., Antonin M., Philippe C., Lancelot M., Vianney D., Thomas S., Martin L., Guillaume V., Adrien M.
|
| 94 |
+
|
| 95 |
+
**Admin** (username + password):
|
| 96 |
+
- Username: `clement`
|
| 97 |
+
- Password: `evg2026_admin`
|
| 98 |
+
|
| 99 |
+
## 📁 Project Structure
|
| 100 |
+
|
| 101 |
+
```
|
| 102 |
+
evg_ultimate_team/
|
| 103 |
+
├── backend/ # FastAPI backend
|
| 104 |
+
│ ├── app/
|
| 105 |
+
│ │ ├── models/ # SQLAlchemy models
|
| 106 |
+
│ │ ├── schemas/ # Pydantic schemas
|
| 107 |
+
│ │ ├── routes/ # API endpoints
|
| 108 |
+
│ │ ├── services/ # Business logic
|
| 109 |
+
│ │ ├── utils/ # Utilities (logging, security)
|
| 110 |
+
│ │ ├── websocket/ # WebSocket handlers
|
| 111 |
+
│ │ └── main.py # FastAPI app
|
| 112 |
+
│ ├── seed_database.py # Database seeding
|
| 113 |
+
│ ├── requirements.txt # Python dependencies
|
| 114 |
+
│ └── README.md
|
| 115 |
+
│
|
| 116 |
+
├── frontend/ # React + TypeScript frontend
|
| 117 |
+
│ ├── src/
|
| 118 |
+
│ │ ├── components/ # UI components
|
| 119 |
+
│ │ ├── pages/ # Page components
|
| 120 |
+
│ │ ├── services/ # API client
|
| 121 |
+
│ │ ├── hooks/ # Custom hooks
|
| 122 |
+
│ │ ├── context/ # React Context
|
| 123 |
+
│ │ ├── types/ # TypeScript types
|
| 124 |
+
│ │ └── styles/ # Tailwind styles
|
| 125 |
+
│ ├── package.json
|
| 126 |
+
│ └── README.md
|
| 127 |
+
│
|
| 128 |
+
├── CLAUDE.md # Full specifications
|
| 129 |
+
└── README.md # This file
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
## 🛠️ Tech Stack
|
| 133 |
+
|
| 134 |
+
### Backend
|
| 135 |
+
- **Framework**: FastAPI (Python)
|
| 136 |
+
- **Database**: SQLite (dev) / PostgreSQL (prod-ready)
|
| 137 |
+
- **ORM**: SQLAlchemy 2.0
|
| 138 |
+
- **Auth**: JWT tokens (python-jose)
|
| 139 |
+
- **Validation**: Pydantic v2
|
| 140 |
+
- **WebSocket**: Native FastAPI
|
| 141 |
+
- **Server**: Uvicorn
|
| 142 |
+
|
| 143 |
+
### Frontend
|
| 144 |
+
- **Framework**: React 18 + TypeScript
|
| 145 |
+
- **Styling**: TailwindCSS (custom PSG/FIFA theme)
|
| 146 |
+
- **Routing**: React Router v6
|
| 147 |
+
- **HTTP**: Axios
|
| 148 |
+
- **State**: React Context + Hooks
|
| 149 |
+
- **WebSocket**: Native WebSocket API
|
| 150 |
+
- **Build**: Vite
|
| 151 |
+
|
| 152 |
+
## 📊 Database Models
|
| 153 |
+
|
| 154 |
+
- **Participant** - 13 participants with points, packs, stats
|
| 155 |
+
- **Challenge** - Individual/team/secret challenges with status
|
| 156 |
+
- **PointsTransaction** - Complete audit trail of all point changes
|
| 157 |
+
- **Pack** (Phase 2) - Pack tiers and rewards
|
| 158 |
+
- **EventCard** (Phase 2) - Boost/Chaos/Paul's Choice events
|
| 159 |
+
|
| 160 |
+
## 🎯 API Endpoints
|
| 161 |
+
|
| 162 |
+
### Authentication
|
| 163 |
+
- `POST /api/auth/login` - Participant login
|
| 164 |
+
- `POST /api/auth/admin-login` - Admin login
|
| 165 |
+
|
| 166 |
+
### Participants
|
| 167 |
+
- `GET /api/participants` - List all
|
| 168 |
+
- `GET /api/participants/{id}` - Get one
|
| 169 |
+
- `GET /api/participants/me/profile` - Current user
|
| 170 |
+
- `POST /api/participants` - Create (admin)
|
| 171 |
+
- `PUT /api/participants/{id}` - Update (admin)
|
| 172 |
+
|
| 173 |
+
### Challenges
|
| 174 |
+
- `GET /api/challenges` - List all
|
| 175 |
+
- `POST /api/challenges` - Create (admin)
|
| 176 |
+
- `PUT /api/challenges/{id}` - Update (admin)
|
| 177 |
+
- `POST /api/challenges/{id}/attempt` - Mark active
|
| 178 |
+
- `POST /api/challenges/{id}/validate` - Validate (admin)
|
| 179 |
+
|
| 180 |
+
### Points
|
| 181 |
+
- `POST /api/points/add` - Add points (admin)
|
| 182 |
+
- `POST /api/points/subtract` - Subtract points (admin)
|
| 183 |
+
- `GET /api/points/history/{id}` - Transaction history
|
| 184 |
+
|
| 185 |
+
### Leaderboard
|
| 186 |
+
- `GET /api/leaderboard` - Full rankings
|
| 187 |
+
- `GET /api/leaderboard/top-3` - Podium
|
| 188 |
+
- `GET /api/leaderboard/daily` - Today's leader
|
| 189 |
+
- `WS /ws/leaderboard` - Real-time updates
|
| 190 |
+
|
| 191 |
+
## 🎨 Design System
|
| 192 |
+
|
| 193 |
+
### Color Palette
|
| 194 |
+
|
| 195 |
+
**PSG Colors:**
|
| 196 |
+
- Blue: `#004170` (main)
|
| 197 |
+
- Red: `#DA291C` (accent)
|
| 198 |
+
- Navy: `#001E41` (background)
|
| 199 |
+
|
| 200 |
+
**FIFA Colors:**
|
| 201 |
+
- Gold: `#D4AF37` (premium)
|
| 202 |
+
- Silver: `#C0C0C0`
|
| 203 |
+
- Bronze: `#CD7F32`
|
| 204 |
+
- Green: `#00FF41` (success)
|
| 205 |
+
|
| 206 |
+
### Typography
|
| 207 |
+
- **Headings**: Bebas Neue (sports-inspired)
|
| 208 |
+
- **Body**: Inter (clean sans-serif)
|
| 209 |
+
- **Stats**: JetBrains Mono (monospace)
|
| 210 |
+
|
| 211 |
+
## 📱 User Roles
|
| 212 |
+
|
| 213 |
+
### Participant
|
| 214 |
+
- View own profile and points
|
| 215 |
+
- Browse available challenges
|
| 216 |
+
- See real-time leaderboard
|
| 217 |
+
- Check points history
|
| 218 |
+
|
| 219 |
+
### Admin (Clément)
|
| 220 |
+
- All participant features
|
| 221 |
+
- Create/edit/delete challenges
|
| 222 |
+
- Validate challenge completions
|
| 223 |
+
- Manually adjust points
|
| 224 |
+
- View all statistics
|
| 225 |
+
|
| 226 |
+
### Paul (The Groom)
|
| 227 |
+
- All participant features
|
| 228 |
+
- Special "Groom" badge
|
| 229 |
+
- Featured on leaderboard
|
| 230 |
+
|
| 231 |
+
## 🔧 Development
|
| 232 |
+
|
| 233 |
+
### Backend Development
|
| 234 |
+
|
| 235 |
+
```bash
|
| 236 |
+
cd backend
|
| 237 |
+
source venv/bin/activate
|
| 238 |
+
uvicorn app.main:app --reload
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
### Frontend Development
|
| 242 |
+
|
| 243 |
+
```bash
|
| 244 |
+
cd frontend
|
| 245 |
+
npm run dev
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
### Code Quality
|
| 249 |
+
|
| 250 |
+
All code follows strict standards:
|
| 251 |
+
- ✅ Type hints (Python) / TypeScript
|
| 252 |
+
- ✅ Comprehensive docstrings
|
| 253 |
+
- ✅ Error handling with custom exceptions
|
| 254 |
+
- ✅ Structured logging
|
| 255 |
+
- ✅ Input validation
|
| 256 |
+
|
| 257 |
+
## 📝 Documentation
|
| 258 |
+
|
| 259 |
+
- **Backend API**: http://localhost:8000/docs (Swagger UI)
|
| 260 |
+
- **Backend README**: [backend/README.md](backend/README.md)
|
| 261 |
+
- **Frontend README**: [frontend/README.md](frontend/README.md)
|
| 262 |
+
- **Specifications**: [CLAUDE.md](CLAUDE.md)
|
| 263 |
+
|
| 264 |
+
## 🚢 Production Deployment
|
| 265 |
+
|
| 266 |
+
### Backend
|
| 267 |
+
|
| 268 |
+
```bash
|
| 269 |
+
# Update .env for production
|
| 270 |
+
DATABASE_URL=postgresql://...
|
| 271 |
+
SECRET_KEY=<generate-new-key>
|
| 272 |
+
CORS_ORIGINS=https://your-frontend.com
|
| 273 |
+
|
| 274 |
+
# Run with Gunicorn
|
| 275 |
+
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
### Frontend
|
| 279 |
+
|
| 280 |
+
```bash
|
| 281 |
+
# Update .env for production
|
| 282 |
+
VITE_API_URL=https://your-api.com
|
| 283 |
+
VITE_WS_URL=wss://your-api.com
|
| 284 |
+
|
| 285 |
+
# Build
|
| 286 |
+
npm run build
|
| 287 |
+
|
| 288 |
+
# Deploy dist/ folder to Vercel/Netlify
|
| 289 |
+
```
|
| 290 |
+
|
| 291 |
+
## 🎉 Event Schedule
|
| 292 |
+
|
| 293 |
+
**Friday Evening:**
|
| 294 |
+
- App introduction
|
| 295 |
+
- First challenges begin
|
| 296 |
+
|
| 297 |
+
**Saturday (Peak Day):**
|
| 298 |
+
- Morning: Extreme sports challenges
|
| 299 |
+
- Afternoon: Team sports (padel, football)
|
| 300 |
+
- Evening: Restaurant + nightlife challenges
|
| 301 |
+
|
| 302 |
+
**Sunday:**
|
| 303 |
+
- Brunch recovery
|
| 304 |
+
- Final challenges (FIFA tournament, wine tasting)
|
| 305 |
+
- Ultimate Pack opening (midday)
|
| 306 |
+
- Final leaderboard + trophy ceremony
|
| 307 |
+
|
| 308 |
+
## 👥 Participants
|
| 309 |
+
|
| 310 |
+
1. **Paul C.** 👑 - The Groom
|
| 311 |
+
2. **Clément P.** ⚙️ - Admin / Wedding Witness
|
| 312 |
+
3. **Paul J.** - Wedding Witness
|
| 313 |
+
4. **Hugo F.** - Wedding Witness
|
| 314 |
+
5. **Théo C.** - Brother / Wedding Witness
|
| 315 |
+
6. **Antonin M.** - Cousin / Wedding Witness
|
| 316 |
+
7. **Philippe C.** - Cousin / Wedding Witness
|
| 317 |
+
8. **Lancelot M.** - Wedding Witness
|
| 318 |
+
9. **Vianney D.**
|
| 319 |
+
10. **Thomas S.**
|
| 320 |
+
11. **Martin L.**
|
| 321 |
+
12. **Guillaume V.**
|
| 322 |
+
13. **Adrien M.**
|
| 323 |
+
|
| 324 |
+
## 🐛 Troubleshooting
|
| 325 |
+
|
| 326 |
+
**Backend won't start:**
|
| 327 |
+
- Check virtual environment is activated
|
| 328 |
+
- Verify all dependencies installed: `pip install -r requirements.txt`
|
| 329 |
+
- Check database file permissions
|
| 330 |
+
|
| 331 |
+
**Frontend won't start:**
|
| 332 |
+
- Clear node_modules: `rm -rf node_modules && npm install`
|
| 333 |
+
- Check Node.js version: `node --version` (should be 18+)
|
| 334 |
+
|
| 335 |
+
**WebSocket not connecting:**
|
| 336 |
+
- Ensure backend is running
|
| 337 |
+
- Check CORS settings in backend `.env`
|
| 338 |
+
- Verify WebSocket URL in frontend `.env`
|
| 339 |
+
|
| 340 |
+
## 📄 License
|
| 341 |
+
|
| 342 |
+
Built for Paul's Bachelor Party - Private Use
|
| 343 |
+
|
| 344 |
+
## 🙏 Credits
|
| 345 |
+
|
| 346 |
+
**Built with:**
|
| 347 |
+
- FastAPI (backend framework)
|
| 348 |
+
- React (frontend framework)
|
| 349 |
+
- Tailwind CSS (styling)
|
| 350 |
+
- SQLAlchemy (ORM)
|
| 351 |
+
- Vite (build tool)
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
**Let's make Paul's bachelor party legendary!** 🏆⚽🎮
|
| 356 |
+
|
| 357 |
+
*Allez Paris! 🔴🔵*
|
backend/.env.example
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# EVG ULTIMATE TEAM - Backend Environment Configuration
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# Copy this file to .env and update with your actual values
|
| 5 |
+
# NEVER commit .env to version control
|
| 6 |
+
|
| 7 |
+
# -----------------------------------------------------------------------------
|
| 8 |
+
# Database Configuration
|
| 9 |
+
# -----------------------------------------------------------------------------
|
| 10 |
+
# SQLite for development (file-based, no server required)
|
| 11 |
+
# For production, replace with PostgreSQL:
|
| 12 |
+
# DATABASE_URL=postgresql://user:password@localhost:5432/evg_ultimate_team
|
| 13 |
+
DATABASE_URL=sqlite:///./evg_ultimate_team.db
|
| 14 |
+
|
| 15 |
+
# -----------------------------------------------------------------------------
|
| 16 |
+
# Security Configuration
|
| 17 |
+
# -----------------------------------------------------------------------------
|
| 18 |
+
# Secret key for session management and token generation
|
| 19 |
+
# Generate a new one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 20 |
+
SECRET_KEY=change-this-to-a-random-secret-key-in-production
|
| 21 |
+
|
| 22 |
+
# Admin credentials for Clément (organizer)
|
| 23 |
+
ADMIN_USERNAME=clement
|
| 24 |
+
ADMIN_PASSWORD=evg2026_admin
|
| 25 |
+
|
| 26 |
+
# -----------------------------------------------------------------------------
|
| 27 |
+
# CORS Configuration
|
| 28 |
+
# -----------------------------------------------------------------------------
|
| 29 |
+
# Allowed origins for CORS (comma-separated for multiple origins)
|
| 30 |
+
# Development: http://localhost:5173
|
| 31 |
+
# Production: https://your-frontend-domain.com
|
| 32 |
+
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
| 33 |
+
|
| 34 |
+
# -----------------------------------------------------------------------------
|
| 35 |
+
# Logging Configuration
|
| 36 |
+
# -----------------------------------------------------------------------------
|
| 37 |
+
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 38 |
+
LOG_LEVEL=INFO
|
| 39 |
+
|
| 40 |
+
# Log file path (optional, logs to console if not specified)
|
| 41 |
+
LOG_FILE=logs/evg_ultimate_team.log
|
| 42 |
+
|
| 43 |
+
# -----------------------------------------------------------------------------
|
| 44 |
+
# Application Configuration
|
| 45 |
+
# -----------------------------------------------------------------------------
|
| 46 |
+
# Application environment: development, staging, production
|
| 47 |
+
ENVIRONMENT=development
|
| 48 |
+
|
| 49 |
+
# API host and port
|
| 50 |
+
API_HOST=0.0.0.0
|
| 51 |
+
API_PORT=8000
|
| 52 |
+
|
| 53 |
+
# Enable debug mode (auto-reload on code changes)
|
| 54 |
+
DEBUG=true
|
backend/.gitignore
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# EVG ULTIMATE TEAM - Backend .gitignore
|
| 3 |
+
# =============================================================================
|
| 4 |
+
|
| 5 |
+
# -----------------------------------------------------------------------------
|
| 6 |
+
# Environment & Secrets
|
| 7 |
+
# -----------------------------------------------------------------------------
|
| 8 |
+
.env
|
| 9 |
+
.env.local
|
| 10 |
+
.env.*.local
|
| 11 |
+
*.env
|
| 12 |
+
|
| 13 |
+
# -----------------------------------------------------------------------------
|
| 14 |
+
# Python
|
| 15 |
+
# -----------------------------------------------------------------------------
|
| 16 |
+
# Byte-compiled / optimized / DLL files
|
| 17 |
+
__pycache__/
|
| 18 |
+
*.py[cod]
|
| 19 |
+
*$py.class
|
| 20 |
+
*.so
|
| 21 |
+
|
| 22 |
+
# Distribution / packaging
|
| 23 |
+
.Python
|
| 24 |
+
build/
|
| 25 |
+
develop-eggs/
|
| 26 |
+
dist/
|
| 27 |
+
downloads/
|
| 28 |
+
eggs/
|
| 29 |
+
.eggs/
|
| 30 |
+
lib/
|
| 31 |
+
lib64/
|
| 32 |
+
parts/
|
| 33 |
+
sdist/
|
| 34 |
+
var/
|
| 35 |
+
wheels/
|
| 36 |
+
*.egg-info/
|
| 37 |
+
.installed.cfg
|
| 38 |
+
*.egg
|
| 39 |
+
|
| 40 |
+
# Virtual environments
|
| 41 |
+
venv/
|
| 42 |
+
env/
|
| 43 |
+
ENV/
|
| 44 |
+
.venv
|
| 45 |
+
|
| 46 |
+
# -----------------------------------------------------------------------------
|
| 47 |
+
# Database
|
| 48 |
+
# -----------------------------------------------------------------------------
|
| 49 |
+
*.db
|
| 50 |
+
*.sqlite
|
| 51 |
+
*.sqlite3
|
| 52 |
+
evg_ultimate_team.db
|
| 53 |
+
|
| 54 |
+
# -----------------------------------------------------------------------------
|
| 55 |
+
# Logs
|
| 56 |
+
# -----------------------------------------------------------------------------
|
| 57 |
+
logs/
|
| 58 |
+
*.log
|
| 59 |
+
|
| 60 |
+
# -----------------------------------------------------------------------------
|
| 61 |
+
# IDE & Editors
|
| 62 |
+
# -----------------------------------------------------------------------------
|
| 63 |
+
.vscode/
|
| 64 |
+
.idea/
|
| 65 |
+
*.swp
|
| 66 |
+
*.swo
|
| 67 |
+
*~
|
| 68 |
+
.DS_Store
|
| 69 |
+
|
| 70 |
+
# -----------------------------------------------------------------------------
|
| 71 |
+
# Testing
|
| 72 |
+
# -----------------------------------------------------------------------------
|
| 73 |
+
.pytest_cache/
|
| 74 |
+
.coverage
|
| 75 |
+
htmlcov/
|
| 76 |
+
.tox/
|
| 77 |
+
|
| 78 |
+
# -----------------------------------------------------------------------------
|
| 79 |
+
# Alembic (Database Migrations)
|
| 80 |
+
# -----------------------------------------------------------------------------
|
| 81 |
+
# Keep alembic.ini and versions/ folder, but ignore temp files
|
| 82 |
+
alembic/__pycache__/
|
| 83 |
+
|
| 84 |
+
# -----------------------------------------------------------------------------
|
| 85 |
+
# Other
|
| 86 |
+
# -----------------------------------------------------------------------------
|
| 87 |
+
*.bak
|
| 88 |
+
*.tmp
|
| 89 |
+
.mypy_cache/
|
backend/README.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EVG Ultimate Team - Backend API
|
| 2 |
+
|
| 3 |
+
FastAPI backend for Paul's bachelor party gamification app.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- 🔐 **Simple Authentication** - Username-only login for participants, password-protected admin access
|
| 8 |
+
- 👥 **Participant Management** - CRUD operations for all 13 participants
|
| 9 |
+
- 🎯 **Challenge System** - Create, assign, and validate challenges with points
|
| 10 |
+
- 📊 **Points Tracking** - Complete transaction history with audit trail
|
| 11 |
+
- 🏆 **Live Leaderboard** - Real-time rankings via WebSocket
|
| 12 |
+
- 📝 **Comprehensive Logging** - Structured logging for all operations
|
| 13 |
+
- 🛡️ **Error Handling** - Custom exceptions with meaningful error messages
|
| 14 |
+
|
| 15 |
+
## Tech Stack
|
| 16 |
+
|
| 17 |
+
- **Framework**: FastAPI 0.104+
|
| 18 |
+
- **Database**: SQLite (development) / PostgreSQL (production-ready)
|
| 19 |
+
- **ORM**: SQLAlchemy 2.0+
|
| 20 |
+
- **Authentication**: JWT tokens (python-jose)
|
| 21 |
+
- **Validation**: Pydantic v2
|
| 22 |
+
- **WebSocket**: Native FastAPI WebSocket support
|
| 23 |
+
- **Server**: Uvicorn with auto-reload
|
| 24 |
+
|
| 25 |
+
## Project Structure
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
backend/
|
| 29 |
+
├── app/
|
| 30 |
+
│ ├── main.py # FastAPI app initialization
|
| 31 |
+
│ ├── config.py # Configuration management
|
| 32 |
+
│ ├── database.py # Database setup
|
| 33 |
+
│ ├── models/ # SQLAlchemy models
|
| 34 |
+
│ ├── schemas/ # Pydantic schemas
|
| 35 |
+
│ ├── routes/ # API endpoints
|
| 36 |
+
│ ├── services/ # Business logic
|
| 37 |
+
│ ├── utils/ # Utilities (logging, security, etc.)
|
| 38 |
+
│ └── websocket/ # WebSocket handlers
|
| 39 |
+
├── seed_database.py # Database seeding script
|
| 40 |
+
├── requirements.txt # Python dependencies
|
| 41 |
+
└── .env # Environment variables (create from .env.example)
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Setup Instructions
|
| 45 |
+
|
| 46 |
+
### 1. Prerequisites
|
| 47 |
+
|
| 48 |
+
- Python 3.10 or higher
|
| 49 |
+
- pip (Python package manager)
|
| 50 |
+
|
| 51 |
+
### 2. Installation
|
| 52 |
+
|
| 53 |
+
```bash
|
| 54 |
+
# Navigate to backend directory
|
| 55 |
+
cd backend
|
| 56 |
+
|
| 57 |
+
# Create virtual environment
|
| 58 |
+
python -m venv venv
|
| 59 |
+
|
| 60 |
+
# Activate virtual environment
|
| 61 |
+
# On Windows:
|
| 62 |
+
venv\Scripts\activate
|
| 63 |
+
# On macOS/Linux:
|
| 64 |
+
source venv/bin/activate
|
| 65 |
+
|
| 66 |
+
# Install dependencies
|
| 67 |
+
pip install -r requirements.txt
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### 3. Environment Configuration
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# Copy environment template
|
| 74 |
+
cp .env.example .env
|
| 75 |
+
|
| 76 |
+
# Edit .env with your settings (optional for development)
|
| 77 |
+
# Default values work for local development
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
**Key Environment Variables:**
|
| 81 |
+
|
| 82 |
+
```env
|
| 83 |
+
DATABASE_URL=sqlite:///./evg_ultimate_team.db
|
| 84 |
+
SECRET_KEY=change-this-to-a-random-secret-key-in-production
|
| 85 |
+
ADMIN_USERNAME=clement
|
| 86 |
+
ADMIN_PASSWORD=evg2026_admin
|
| 87 |
+
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
| 88 |
+
LOG_LEVEL=INFO
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
### 4. Database Initialization
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
# Seed database with 13 participants and sample challenges
|
| 95 |
+
python seed_database.py
|
| 96 |
+
|
| 97 |
+
# Confirm with 'yes' when prompted
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
This creates:
|
| 101 |
+
- ✅ 13 participants (Paul C. as groom)
|
| 102 |
+
- ✅ 15 sample challenges (individual, team, secret)
|
| 103 |
+
- ✅ Database schema (all tables)
|
| 104 |
+
|
| 105 |
+
### 5. Run the Server
|
| 106 |
+
|
| 107 |
+
```bash
|
| 108 |
+
# Development mode (with auto-reload)
|
| 109 |
+
python -m app.main
|
| 110 |
+
|
| 111 |
+
# Or using uvicorn directly
|
| 112 |
+
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
Server will start at: **http://localhost:8000**
|
| 116 |
+
|
| 117 |
+
## API Documentation
|
| 118 |
+
|
| 119 |
+
Once the server is running, access interactive API docs at:
|
| 120 |
+
|
| 121 |
+
- **Swagger UI**: http://localhost:8000/docs
|
| 122 |
+
- **ReDoc**: http://localhost:8000/redoc
|
| 123 |
+
|
| 124 |
+
## API Endpoints
|
| 125 |
+
|
| 126 |
+
### Authentication
|
| 127 |
+
- `POST /api/auth/login` - Participant login (username only)
|
| 128 |
+
- `POST /api/auth/admin-login` - Admin login (username + password)
|
| 129 |
+
|
| 130 |
+
### Participants
|
| 131 |
+
- `GET /api/participants` - List all participants
|
| 132 |
+
- `GET /api/participants/{id}` - Get participant details
|
| 133 |
+
- `GET /api/participants/me/profile` - Get current user profile
|
| 134 |
+
- `POST /api/participants` - Create participant (admin)
|
| 135 |
+
- `PUT /api/participants/{id}` - Update participant (admin)
|
| 136 |
+
- `DELETE /api/participants/{id}` - Delete participant (admin)
|
| 137 |
+
|
| 138 |
+
### Challenges
|
| 139 |
+
- `GET /api/challenges` - List all challenges
|
| 140 |
+
- `GET /api/challenges/{id}` - Get challenge details
|
| 141 |
+
- `POST /api/challenges` - Create challenge (admin)
|
| 142 |
+
- `PUT /api/challenges/{id}` - Update challenge (admin)
|
| 143 |
+
- `DELETE /api/challenges/{id}` - Delete challenge (admin)
|
| 144 |
+
- `POST /api/challenges/{id}/attempt` - Mark challenge as active
|
| 145 |
+
- `POST /api/challenges/{id}/validate` - Validate completion (admin)
|
| 146 |
+
- `GET /api/challenges/participant/{id}` - Get participant's challenges
|
| 147 |
+
|
| 148 |
+
### Points
|
| 149 |
+
- `POST /api/points/add` - Add points (admin)
|
| 150 |
+
- `POST /api/points/subtract` - Subtract points (admin)
|
| 151 |
+
- `GET /api/points/history/{participant_id}` - Get points history
|
| 152 |
+
- `GET /api/points/recent` - Get recent transactions
|
| 153 |
+
|
| 154 |
+
### Leaderboard
|
| 155 |
+
- `GET /api/leaderboard` - Get full leaderboard
|
| 156 |
+
- `GET /api/leaderboard/top-3` - Get podium (top 3)
|
| 157 |
+
- `GET /api/leaderboard/daily` - Get daily leader
|
| 158 |
+
- `GET /api/leaderboard/rank/{participant_id}` - Get participant rank
|
| 159 |
+
- `GET /api/leaderboard/stats` - Get statistics
|
| 160 |
+
|
| 161 |
+
### WebSocket
|
| 162 |
+
- `WS /ws/leaderboard` - Real-time leaderboard updates
|
| 163 |
+
|
| 164 |
+
## Testing the API
|
| 165 |
+
|
| 166 |
+
### Using cURL
|
| 167 |
+
|
| 168 |
+
```bash
|
| 169 |
+
# Participant login
|
| 170 |
+
curl -X POST http://localhost:8000/api/auth/login \
|
| 171 |
+
-H "Content-Type: application/json" \
|
| 172 |
+
-d '{"username": "Paul C."}'
|
| 173 |
+
|
| 174 |
+
# Admin login
|
| 175 |
+
curl -X POST http://localhost:8000/api/auth/admin-login \
|
| 176 |
+
-H "Content-Type: application/json" \
|
| 177 |
+
-d '{"username": "clement", "password": "evg2026_admin"}'
|
| 178 |
+
|
| 179 |
+
# Get leaderboard
|
| 180 |
+
curl http://localhost:8000/api/leaderboard
|
| 181 |
+
```
|
| 182 |
+
|
| 183 |
+
### Using Python
|
| 184 |
+
|
| 185 |
+
```python
|
| 186 |
+
import requests
|
| 187 |
+
|
| 188 |
+
# Login
|
| 189 |
+
response = requests.post(
|
| 190 |
+
"http://localhost:8000/api/auth/login",
|
| 191 |
+
json={"username": "Hugo F."}
|
| 192 |
+
)
|
| 193 |
+
token = response.json()["data"]["access_token"]
|
| 194 |
+
|
| 195 |
+
# Get leaderboard (authenticated)
|
| 196 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 197 |
+
leaderboard = requests.get(
|
| 198 |
+
"http://localhost:8000/api/leaderboard",
|
| 199 |
+
headers=headers
|
| 200 |
+
)
|
| 201 |
+
print(leaderboard.json())
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
## Default Participants
|
| 205 |
+
|
| 206 |
+
The seed script creates these 13 participants:
|
| 207 |
+
|
| 208 |
+
1. **Paul C.** (Groom) - The man of the hour
|
| 209 |
+
2. **Clément P.** (Admin) - Organizer and wedding witness
|
| 210 |
+
3. **Paul J.** - Wedding witness
|
| 211 |
+
4. **Hugo F.** - Wedding witness
|
| 212 |
+
5. **Théo C.** - Groom's brother and wedding witness
|
| 213 |
+
6. **Antonin M.** - Groom's cousin and wedding witness
|
| 214 |
+
7. **Philippe C.** - Groom's cousin and wedding witness
|
| 215 |
+
8. **Lancelot M.** - Wedding witness
|
| 216 |
+
9. **Vianney D.**
|
| 217 |
+
10. **Thomas S.**
|
| 218 |
+
11. **Martin L.**
|
| 219 |
+
12. **Guillaume V.**
|
| 220 |
+
13. **Adrien M.**
|
| 221 |
+
|
| 222 |
+
## Admin Credentials
|
| 223 |
+
|
| 224 |
+
**Username**: `clement`
|
| 225 |
+
**Password**: `evg2026_admin`
|
| 226 |
+
|
| 227 |
+
⚠️ **Change these in production!**
|
| 228 |
+
|
| 229 |
+
## Database Management
|
| 230 |
+
|
| 231 |
+
### Reset Database
|
| 232 |
+
|
| 233 |
+
```bash
|
| 234 |
+
# WARNING: This deletes all data
|
| 235 |
+
python seed_database.py
|
| 236 |
+
```
|
| 237 |
+
|
| 238 |
+
### Backup Database (SQLite)
|
| 239 |
+
|
| 240 |
+
```bash
|
| 241 |
+
# Create backup
|
| 242 |
+
cp evg_ultimate_team.db evg_ultimate_team.db.backup
|
| 243 |
+
|
| 244 |
+
# Restore from backup
|
| 245 |
+
cp evg_ultimate_team.db.backup evg_ultimate_team.db
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
## Logging
|
| 249 |
+
|
| 250 |
+
Logs are output to console by default. Configure file logging in `.env`:
|
| 251 |
+
|
| 252 |
+
```env
|
| 253 |
+
LOG_FILE=logs/evg_ultimate_team.log
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
Log levels: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
|
| 257 |
+
|
| 258 |
+
## Production Deployment
|
| 259 |
+
|
| 260 |
+
### Environment Variables
|
| 261 |
+
|
| 262 |
+
```env
|
| 263 |
+
ENVIRONMENT=production
|
| 264 |
+
DEBUG=false
|
| 265 |
+
DATABASE_URL=postgresql://user:password@localhost:5432/evg_ultimate_team
|
| 266 |
+
SECRET_KEY=<generate-strong-random-key>
|
| 267 |
+
CORS_ORIGINS=https://your-frontend-domain.com
|
| 268 |
+
```
|
| 269 |
+
|
| 270 |
+
### Generate Secret Key
|
| 271 |
+
|
| 272 |
+
```bash
|
| 273 |
+
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
| 274 |
+
```
|
| 275 |
+
|
| 276 |
+
### Run with Gunicorn
|
| 277 |
+
|
| 278 |
+
```bash
|
| 279 |
+
pip install gunicorn
|
| 280 |
+
gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
## Troubleshooting
|
| 284 |
+
|
| 285 |
+
### Port Already in Use
|
| 286 |
+
|
| 287 |
+
```bash
|
| 288 |
+
# Change port in .env
|
| 289 |
+
API_PORT=8001
|
| 290 |
+
|
| 291 |
+
# Or specify when running
|
| 292 |
+
uvicorn app.main:app --port 8001
|
| 293 |
+
```
|
| 294 |
+
|
| 295 |
+
### Database Locked (SQLite)
|
| 296 |
+
|
| 297 |
+
```bash
|
| 298 |
+
# Close all connections and restart server
|
| 299 |
+
rm evg_ultimate_team.db
|
| 300 |
+
python seed_database.py
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
### Import Errors
|
| 304 |
+
|
| 305 |
+
```bash
|
| 306 |
+
# Ensure virtual environment is activated
|
| 307 |
+
# Reinstall dependencies
|
| 308 |
+
pip install -r requirements.txt --force-reinstall
|
| 309 |
+
```
|
| 310 |
+
|
| 311 |
+
## Development
|
| 312 |
+
|
| 313 |
+
### Code Quality
|
| 314 |
+
|
| 315 |
+
All code follows these standards:
|
| 316 |
+
- ✅ Type hints everywhere
|
| 317 |
+
- ✅ Docstrings for all functions
|
| 318 |
+
- ✅ Comprehensive error handling
|
| 319 |
+
- ✅ Structured logging
|
| 320 |
+
- ✅ Input validation
|
| 321 |
+
|
| 322 |
+
### Adding New Features
|
| 323 |
+
|
| 324 |
+
1. Create model in `app/models/`
|
| 325 |
+
2. Create schema in `app/schemas/`
|
| 326 |
+
3. Create service in `app/services/`
|
| 327 |
+
4. Create route in `app/routes/`
|
| 328 |
+
5. Update `app/main.py` to include new router
|
| 329 |
+
|
| 330 |
+
## Support
|
| 331 |
+
|
| 332 |
+
For issues or questions, check:
|
| 333 |
+
- API docs: http://localhost:8000/docs
|
| 334 |
+
- Logs: Check console output
|
| 335 |
+
- Database: Use SQLite browser to inspect data
|
| 336 |
+
|
| 337 |
+
---
|
| 338 |
+
|
| 339 |
+
**Built for Paul's Bachelor Party (June 4-6, 2026)**
|
| 340 |
+
*Let's make it legendary!* 🏆⚽🎮
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration management for EVG Ultimate Team backend.
|
| 3 |
+
|
| 4 |
+
This module handles loading and validating configuration from environment variables.
|
| 5 |
+
Uses Pydantic Settings for type-safe configuration with validation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from pydantic_settings import BaseSettings
|
| 9 |
+
from pydantic import Field
|
| 10 |
+
from typing import List
|
| 11 |
+
from functools import lru_cache
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Settings(BaseSettings):
|
| 15 |
+
"""
|
| 16 |
+
Application settings loaded from environment variables.
|
| 17 |
+
|
| 18 |
+
All settings can be overridden by setting environment variables
|
| 19 |
+
with the corresponding names (case-insensitive).
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
# ==========================================================================
|
| 23 |
+
# Database Configuration
|
| 24 |
+
# ==========================================================================
|
| 25 |
+
database_url: str = Field(
|
| 26 |
+
default="sqlite:///./evg_ultimate_team.db",
|
| 27 |
+
description="Database connection URL"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# ==========================================================================
|
| 31 |
+
# Security Configuration
|
| 32 |
+
# ==========================================================================
|
| 33 |
+
secret_key: str = Field(
|
| 34 |
+
default="change-this-to-a-random-secret-key-in-production",
|
| 35 |
+
description="Secret key for session management and token generation"
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
admin_username: str = Field(
|
| 39 |
+
default="clement",
|
| 40 |
+
description="Admin username for Clément"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
admin_password: str = Field(
|
| 44 |
+
default="evg2026_admin",
|
| 45 |
+
description="Admin password"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# ==========================================================================
|
| 49 |
+
# CORS Configuration
|
| 50 |
+
# ==========================================================================
|
| 51 |
+
cors_origins: str = Field(
|
| 52 |
+
default="http://localhost:5173,http://localhost:3000",
|
| 53 |
+
description="Comma-separated list of allowed CORS origins"
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def cors_origins_list(self) -> List[str]:
|
| 58 |
+
"""
|
| 59 |
+
Parse CORS origins string into a list.
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
List of allowed origin URLs
|
| 63 |
+
"""
|
| 64 |
+
return [origin.strip() for origin in self.cors_origins.split(",")]
|
| 65 |
+
|
| 66 |
+
# ==========================================================================
|
| 67 |
+
# Logging Configuration
|
| 68 |
+
# ==========================================================================
|
| 69 |
+
log_level: str = Field(
|
| 70 |
+
default="INFO",
|
| 71 |
+
description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
log_file: str = Field(
|
| 75 |
+
default="",
|
| 76 |
+
description="Path to log file (empty = console only)"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# ==========================================================================
|
| 80 |
+
# Application Configuration
|
| 81 |
+
# ==========================================================================
|
| 82 |
+
environment: str = Field(
|
| 83 |
+
default="development",
|
| 84 |
+
description="Application environment (development, staging, production)"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
api_host: str = Field(
|
| 88 |
+
default="0.0.0.0",
|
| 89 |
+
description="API host address"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
api_port: int = Field(
|
| 93 |
+
default=8000,
|
| 94 |
+
description="API port number"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
debug: bool = Field(
|
| 98 |
+
default=True,
|
| 99 |
+
description="Enable debug mode (auto-reload on code changes)"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# ==========================================================================
|
| 103 |
+
# Application Metadata
|
| 104 |
+
# ==========================================================================
|
| 105 |
+
app_name: str = Field(
|
| 106 |
+
default="EVG Ultimate Team API",
|
| 107 |
+
description="Application name"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
app_version: str = Field(
|
| 111 |
+
default="1.0.0",
|
| 112 |
+
description="Application version"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
app_description: str = Field(
|
| 116 |
+
default="Gamification API for Paul's bachelor party",
|
| 117 |
+
description="Application description"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
class Config:
|
| 121 |
+
"""Pydantic configuration."""
|
| 122 |
+
env_file = ".env"
|
| 123 |
+
env_file_encoding = "utf-8"
|
| 124 |
+
case_sensitive = False
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@lru_cache()
|
| 128 |
+
def get_settings() -> Settings:
|
| 129 |
+
"""
|
| 130 |
+
Get cached settings instance.
|
| 131 |
+
|
| 132 |
+
Uses LRU cache to ensure settings are loaded only once.
|
| 133 |
+
This is the recommended way to access settings throughout the app.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Settings instance with all configuration values
|
| 137 |
+
|
| 138 |
+
Example:
|
| 139 |
+
>>> from app.config import get_settings
|
| 140 |
+
>>> settings = get_settings()
|
| 141 |
+
>>> print(settings.database_url)
|
| 142 |
+
"""
|
| 143 |
+
return Settings()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# Create a global settings instance for convenience
|
| 147 |
+
settings = get_settings()
|
backend/app/database.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database connection and session management for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
This module sets up SQLAlchemy database engine, session factory,
|
| 5 |
+
and base model class for all database models.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import create_engine, event
|
| 9 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 10 |
+
from sqlalchemy.orm import sessionmaker, Session
|
| 11 |
+
from typing import Generator
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
from app.utils.logger import logger
|
| 14 |
+
|
| 15 |
+
# Get settings
|
| 16 |
+
settings = get_settings()
|
| 17 |
+
|
| 18 |
+
# =============================================================================
|
| 19 |
+
# Database Engine Setup
|
| 20 |
+
# =============================================================================
|
| 21 |
+
|
| 22 |
+
# Create SQLAlchemy engine
|
| 23 |
+
# For SQLite: connect_args with check_same_thread=False allows multiple threads
|
| 24 |
+
# For production PostgreSQL, remove connect_args
|
| 25 |
+
if settings.database_url.startswith("sqlite"):
|
| 26 |
+
engine = create_engine(
|
| 27 |
+
settings.database_url,
|
| 28 |
+
connect_args={"check_same_thread": False},
|
| 29 |
+
echo=False # Disable SQL query logging (too verbose)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Enable foreign key constraints for SQLite
|
| 33 |
+
@event.listens_for(engine, "connect")
|
| 34 |
+
def set_sqlite_pragma(dbapi_connection, connection_record):
|
| 35 |
+
"""
|
| 36 |
+
Enable foreign key constraints for SQLite.
|
| 37 |
+
|
| 38 |
+
SQLite doesn't enforce foreign keys by default, so we need to
|
| 39 |
+
enable them explicitly for each connection.
|
| 40 |
+
"""
|
| 41 |
+
cursor = dbapi_connection.cursor()
|
| 42 |
+
cursor.execute("PRAGMA foreign_keys=ON")
|
| 43 |
+
cursor.close()
|
| 44 |
+
else:
|
| 45 |
+
engine = create_engine(
|
| 46 |
+
settings.database_url,
|
| 47 |
+
echo=False, # Disable SQL query logging (too verbose)
|
| 48 |
+
pool_pre_ping=True # Verify connections before using them
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# =============================================================================
|
| 52 |
+
# Session Factory
|
| 53 |
+
# =============================================================================
|
| 54 |
+
|
| 55 |
+
# Create session factory
|
| 56 |
+
# autocommit=False: Transactions must be explicitly committed
|
| 57 |
+
# autoflush=False: Manual control over when data is flushed to DB
|
| 58 |
+
SessionLocal = sessionmaker(
|
| 59 |
+
autocommit=False,
|
| 60 |
+
autoflush=False,
|
| 61 |
+
bind=engine
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# =============================================================================
|
| 65 |
+
# Base Model Class
|
| 66 |
+
# =============================================================================
|
| 67 |
+
|
| 68 |
+
# Create base class for all models
|
| 69 |
+
# All model classes will inherit from this
|
| 70 |
+
Base = declarative_base()
|
| 71 |
+
|
| 72 |
+
# =============================================================================
|
| 73 |
+
# Database Dependency
|
| 74 |
+
# =============================================================================
|
| 75 |
+
|
| 76 |
+
def get_db() -> Generator[Session, None, None]:
|
| 77 |
+
"""
|
| 78 |
+
Database session dependency for FastAPI routes.
|
| 79 |
+
|
| 80 |
+
Yields a database session and ensures it's properly closed after use.
|
| 81 |
+
This function is used as a dependency in FastAPI route handlers.
|
| 82 |
+
|
| 83 |
+
Yields:
|
| 84 |
+
SQLAlchemy database session
|
| 85 |
+
|
| 86 |
+
Example:
|
| 87 |
+
>>> from fastapi import Depends
|
| 88 |
+
>>> @app.get("/participants")
|
| 89 |
+
>>> def get_participants(db: Session = Depends(get_db)):
|
| 90 |
+
>>> return db.query(Participant).all()
|
| 91 |
+
"""
|
| 92 |
+
db = SessionLocal()
|
| 93 |
+
try:
|
| 94 |
+
yield db
|
| 95 |
+
finally:
|
| 96 |
+
db.close()
|
| 97 |
+
|
| 98 |
+
# =============================================================================
|
| 99 |
+
# Database Initialization
|
| 100 |
+
# =============================================================================
|
| 101 |
+
|
| 102 |
+
def init_db() -> None:
|
| 103 |
+
"""
|
| 104 |
+
Initialize the database by creating all tables.
|
| 105 |
+
|
| 106 |
+
This function should be called once when the application starts.
|
| 107 |
+
It creates all tables defined in the models if they don't exist.
|
| 108 |
+
|
| 109 |
+
Note:
|
| 110 |
+
In production, use Alembic migrations instead of this function.
|
| 111 |
+
This is primarily for development and testing.
|
| 112 |
+
"""
|
| 113 |
+
logger.info("Initializing database...")
|
| 114 |
+
try:
|
| 115 |
+
# Import all models to ensure they're registered with Base
|
| 116 |
+
from app.models import participant, challenge, points_transaction
|
| 117 |
+
|
| 118 |
+
# Create all tables
|
| 119 |
+
Base.metadata.create_all(bind=engine)
|
| 120 |
+
logger.info("Database initialized successfully")
|
| 121 |
+
except Exception as e:
|
| 122 |
+
logger.error(f"Failed to initialize database: {str(e)}")
|
| 123 |
+
raise
|
| 124 |
+
|
| 125 |
+
def drop_all_tables() -> None:
|
| 126 |
+
"""
|
| 127 |
+
Drop all tables from the database.
|
| 128 |
+
|
| 129 |
+
WARNING: This will delete all data! Only use in development/testing.
|
| 130 |
+
"""
|
| 131 |
+
logger.warning("Dropping all database tables...")
|
| 132 |
+
try:
|
| 133 |
+
Base.metadata.drop_all(bind=engine)
|
| 134 |
+
logger.warning("All tables dropped successfully")
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logger.error(f"Failed to drop tables: {str(e)}")
|
| 137 |
+
raise
|
| 138 |
+
|
| 139 |
+
def reset_db() -> None:
|
| 140 |
+
"""
|
| 141 |
+
Reset the database by dropping and recreating all tables.
|
| 142 |
+
|
| 143 |
+
WARNING: This will delete all data! Only use in development/testing.
|
| 144 |
+
"""
|
| 145 |
+
logger.warning("Resetting database...")
|
| 146 |
+
drop_all_tables()
|
| 147 |
+
init_db()
|
| 148 |
+
logger.info("Database reset completed")
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main FastAPI application for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
This is the entry point for the backend API.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, Request, status, Depends, WebSocket
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
from fastapi.responses import JSONResponse
|
| 10 |
+
from contextlib import asynccontextmanager
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
from app.database import init_db, get_db
|
| 13 |
+
from app.routes import (
|
| 14 |
+
auth_router,
|
| 15 |
+
participants_router,
|
| 16 |
+
challenges_router,
|
| 17 |
+
points_router,
|
| 18 |
+
leaderboard_router
|
| 19 |
+
)
|
| 20 |
+
from app.websocket.leaderboard import leaderboard_websocket_endpoint
|
| 21 |
+
from app.utils.logger import logger
|
| 22 |
+
from app.utils.exceptions import EVGException, format_exception_response
|
| 23 |
+
|
| 24 |
+
# Get settings
|
| 25 |
+
settings = get_settings()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# =============================================================================
|
| 29 |
+
# Lifespan Events
|
| 30 |
+
# =============================================================================
|
| 31 |
+
|
| 32 |
+
@asynccontextmanager
|
| 33 |
+
async def lifespan(app: FastAPI):
|
| 34 |
+
"""
|
| 35 |
+
Lifespan context manager for startup and shutdown events.
|
| 36 |
+
|
| 37 |
+
Startup:
|
| 38 |
+
- Initialize database (create tables if they don't exist)
|
| 39 |
+
- Log application startup
|
| 40 |
+
|
| 41 |
+
Shutdown:
|
| 42 |
+
- Log application shutdown
|
| 43 |
+
"""
|
| 44 |
+
# Startup
|
| 45 |
+
logger.info("=" * 80)
|
| 46 |
+
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
| 47 |
+
logger.info(f"Environment: {settings.environment}")
|
| 48 |
+
logger.info(f"Debug mode: {settings.debug}")
|
| 49 |
+
logger.info("=" * 80)
|
| 50 |
+
|
| 51 |
+
# Initialize database
|
| 52 |
+
init_db()
|
| 53 |
+
logger.info("Database initialized")
|
| 54 |
+
|
| 55 |
+
yield
|
| 56 |
+
|
| 57 |
+
# Shutdown
|
| 58 |
+
logger.info("Shutting down application")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# =============================================================================
|
| 62 |
+
# FastAPI Application
|
| 63 |
+
# =============================================================================
|
| 64 |
+
|
| 65 |
+
app = FastAPI(
|
| 66 |
+
title=settings.app_name,
|
| 67 |
+
description=settings.app_description,
|
| 68 |
+
version=settings.app_version,
|
| 69 |
+
lifespan=lifespan,
|
| 70 |
+
docs_url="/docs" if settings.debug else None, # Disable docs in production
|
| 71 |
+
redoc_url="/redoc" if settings.debug else None,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# =============================================================================
|
| 76 |
+
# CORS Middleware
|
| 77 |
+
# =============================================================================
|
| 78 |
+
|
| 79 |
+
app.add_middleware(
|
| 80 |
+
CORSMiddleware,
|
| 81 |
+
allow_origins=settings.cors_origins_list,
|
| 82 |
+
allow_credentials=True,
|
| 83 |
+
allow_methods=["*"],
|
| 84 |
+
allow_headers=["*"],
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# =============================================================================
|
| 89 |
+
# Exception Handlers
|
| 90 |
+
# =============================================================================
|
| 91 |
+
|
| 92 |
+
@app.exception_handler(EVGException)
|
| 93 |
+
async def evg_exception_handler(request: Request, exc: EVGException):
|
| 94 |
+
"""
|
| 95 |
+
Global exception handler for custom EVG exceptions.
|
| 96 |
+
|
| 97 |
+
Converts EVGException instances to properly formatted JSON responses.
|
| 98 |
+
"""
|
| 99 |
+
logger.error(
|
| 100 |
+
f"EVGException: {exc.message}",
|
| 101 |
+
extra={"status_code": exc.status_code, "detail": exc.detail}
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
return JSONResponse(
|
| 105 |
+
status_code=exc.status_code,
|
| 106 |
+
content=format_exception_response(exc)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.exception_handler(Exception)
|
| 111 |
+
async def general_exception_handler(request: Request, exc: Exception):
|
| 112 |
+
"""
|
| 113 |
+
Global exception handler for unexpected exceptions.
|
| 114 |
+
|
| 115 |
+
Prevents server errors from exposing internal details.
|
| 116 |
+
"""
|
| 117 |
+
logger.error(
|
| 118 |
+
f"Unexpected exception: {str(exc)}",
|
| 119 |
+
exc_info=True
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
return JSONResponse(
|
| 123 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 124 |
+
content={
|
| 125 |
+
"success": False,
|
| 126 |
+
"error": "Internal server error",
|
| 127 |
+
"detail": str(exc) if settings.debug else "An unexpected error occurred"
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# =============================================================================
|
| 133 |
+
# API Routes
|
| 134 |
+
# =============================================================================
|
| 135 |
+
|
| 136 |
+
# Include all API routers
|
| 137 |
+
app.include_router(auth_router, prefix="/api")
|
| 138 |
+
app.include_router(participants_router, prefix="/api")
|
| 139 |
+
app.include_router(challenges_router, prefix="/api")
|
| 140 |
+
app.include_router(points_router, prefix="/api")
|
| 141 |
+
app.include_router(leaderboard_router, prefix="/api")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# =============================================================================
|
| 145 |
+
# WebSocket Routes
|
| 146 |
+
# =============================================================================
|
| 147 |
+
|
| 148 |
+
@app.websocket("/ws/leaderboard")
|
| 149 |
+
async def websocket_leaderboard(websocket: WebSocket):
|
| 150 |
+
"""WebSocket endpoint for real-time leaderboard updates."""
|
| 151 |
+
await leaderboard_websocket_endpoint(websocket)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
# =============================================================================
|
| 155 |
+
# Root Endpoint
|
| 156 |
+
# =============================================================================
|
| 157 |
+
|
| 158 |
+
@app.get("/")
|
| 159 |
+
async def root():
|
| 160 |
+
"""
|
| 161 |
+
Root endpoint - API health check.
|
| 162 |
+
|
| 163 |
+
Returns basic API information and status.
|
| 164 |
+
"""
|
| 165 |
+
return {
|
| 166 |
+
"success": True,
|
| 167 |
+
"message": f"Welcome to {settings.app_name} API",
|
| 168 |
+
"version": settings.app_version,
|
| 169 |
+
"status": "online",
|
| 170 |
+
"docs": "/docs" if settings.debug else "Documentation disabled in production"
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@app.get("/health")
|
| 175 |
+
async def health_check():
|
| 176 |
+
"""
|
| 177 |
+
Health check endpoint.
|
| 178 |
+
|
| 179 |
+
Used by monitoring systems to verify the API is running.
|
| 180 |
+
"""
|
| 181 |
+
return {
|
| 182 |
+
"success": True,
|
| 183 |
+
"status": "healthy",
|
| 184 |
+
"environment": settings.environment
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# =============================================================================
|
| 189 |
+
# Run Application (for development)
|
| 190 |
+
# =============================================================================
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
import uvicorn
|
| 194 |
+
|
| 195 |
+
uvicorn.run(
|
| 196 |
+
"app.main:app",
|
| 197 |
+
host=settings.api_host,
|
| 198 |
+
port=settings.api_port,
|
| 199 |
+
reload=settings.debug,
|
| 200 |
+
log_level=settings.log_level.lower()
|
| 201 |
+
)
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database models for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
This module exports all SQLAlchemy models and related enums.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from app.models.participant import Participant
|
| 8 |
+
from app.models.challenge import (
|
| 9 |
+
Challenge,
|
| 10 |
+
ChallengeType,
|
| 11 |
+
ChallengeStatus,
|
| 12 |
+
challenge_assignments,
|
| 13 |
+
challenge_completions
|
| 14 |
+
)
|
| 15 |
+
from app.models.points_transaction import PointsTransaction
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
# Models
|
| 19 |
+
"Participant",
|
| 20 |
+
"Challenge",
|
| 21 |
+
"PointsTransaction",
|
| 22 |
+
# Enums
|
| 23 |
+
"ChallengeType",
|
| 24 |
+
"ChallengeStatus",
|
| 25 |
+
# Association tables
|
| 26 |
+
"challenge_assignments",
|
| 27 |
+
"challenge_completions",
|
| 28 |
+
]
|
backend/app/models/challenge.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Challenge database model for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Represents challenges that participants can complete to earn points.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import Column, Integer, String, Enum, DateTime, ForeignKey, Table
|
| 8 |
+
from sqlalchemy.orm import relationship
|
| 9 |
+
from sqlalchemy.sql import func
|
| 10 |
+
from app.database import Base
|
| 11 |
+
import enum
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# =============================================================================
|
| 15 |
+
# Enums
|
| 16 |
+
# =============================================================================
|
| 17 |
+
|
| 18 |
+
class ChallengeType(str, enum.Enum):
|
| 19 |
+
"""
|
| 20 |
+
Types of challenges available in the game.
|
| 21 |
+
|
| 22 |
+
- INDIVIDUAL: Challenge for individual participants
|
| 23 |
+
- TEAM: Challenge for teams (points shared among team members)
|
| 24 |
+
- SECRET: Secret challenge revealed at specific times
|
| 25 |
+
"""
|
| 26 |
+
INDIVIDUAL = "individual"
|
| 27 |
+
TEAM = "team"
|
| 28 |
+
SECRET = "secret"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ChallengeStatus(str, enum.Enum):
|
| 32 |
+
"""
|
| 33 |
+
Status of a challenge.
|
| 34 |
+
|
| 35 |
+
- PENDING: Challenge available but not yet attempted
|
| 36 |
+
- ACTIVE: Challenge is currently being attempted
|
| 37 |
+
- COMPLETED: Challenge successfully completed and validated
|
| 38 |
+
- FAILED: Challenge attempted but failed
|
| 39 |
+
"""
|
| 40 |
+
PENDING = "pending"
|
| 41 |
+
ACTIVE = "active"
|
| 42 |
+
COMPLETED = "completed"
|
| 43 |
+
FAILED = "failed"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# =============================================================================
|
| 47 |
+
# Association Tables (Many-to-Many Relationships)
|
| 48 |
+
# =============================================================================
|
| 49 |
+
|
| 50 |
+
# Association table for participants assigned to challenges
|
| 51 |
+
challenge_assignments = Table(
|
| 52 |
+
"challenge_assignments",
|
| 53 |
+
Base.metadata,
|
| 54 |
+
Column(
|
| 55 |
+
"challenge_id",
|
| 56 |
+
Integer,
|
| 57 |
+
ForeignKey("challenges.id", ondelete="CASCADE"),
|
| 58 |
+
primary_key=True,
|
| 59 |
+
comment="Challenge ID"
|
| 60 |
+
),
|
| 61 |
+
Column(
|
| 62 |
+
"participant_id",
|
| 63 |
+
Integer,
|
| 64 |
+
ForeignKey("participants.id", ondelete="CASCADE"),
|
| 65 |
+
primary_key=True,
|
| 66 |
+
comment="Participant ID assigned to the challenge"
|
| 67 |
+
),
|
| 68 |
+
comment="Associates participants with challenges they're assigned to"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Association table for participants who completed challenges
|
| 72 |
+
challenge_completions = Table(
|
| 73 |
+
"challenge_completions",
|
| 74 |
+
Base.metadata,
|
| 75 |
+
Column(
|
| 76 |
+
"challenge_id",
|
| 77 |
+
Integer,
|
| 78 |
+
ForeignKey("challenges.id", ondelete="CASCADE"),
|
| 79 |
+
primary_key=True,
|
| 80 |
+
comment="Challenge ID"
|
| 81 |
+
),
|
| 82 |
+
Column(
|
| 83 |
+
"participant_id",
|
| 84 |
+
Integer,
|
| 85 |
+
ForeignKey("participants.id", ondelete="CASCADE"),
|
| 86 |
+
primary_key=True,
|
| 87 |
+
comment="Participant ID who completed the challenge"
|
| 88 |
+
),
|
| 89 |
+
comment="Associates participants with challenges they've completed"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# =============================================================================
|
| 94 |
+
# Challenge Model
|
| 95 |
+
# =============================================================================
|
| 96 |
+
|
| 97 |
+
class Challenge(Base):
|
| 98 |
+
"""
|
| 99 |
+
Challenge model representing a task participants can complete for points.
|
| 100 |
+
|
| 101 |
+
Challenges can be individual, team-based, or secret.
|
| 102 |
+
Admins create challenges and validate completions.
|
| 103 |
+
|
| 104 |
+
Attributes:
|
| 105 |
+
id: Primary key, auto-incrementing
|
| 106 |
+
title: Short title of the challenge
|
| 107 |
+
description: Detailed description of what participants must do
|
| 108 |
+
type: Type of challenge (individual, team, secret)
|
| 109 |
+
points: Points awarded for completing the challenge
|
| 110 |
+
status: Current status (pending, active, completed, failed)
|
| 111 |
+
validated_by: ID of admin who validated the challenge completion
|
| 112 |
+
created_at: Timestamp when challenge was created
|
| 113 |
+
completed_at: Timestamp when challenge was completed (None if not completed)
|
| 114 |
+
updated_at: Timestamp when challenge was last updated
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
__tablename__ = "challenges"
|
| 118 |
+
|
| 119 |
+
# Primary Key
|
| 120 |
+
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
| 121 |
+
|
| 122 |
+
# Basic Information
|
| 123 |
+
title = Column(
|
| 124 |
+
String(200),
|
| 125 |
+
nullable=False,
|
| 126 |
+
index=True,
|
| 127 |
+
comment="Short title of the challenge"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
description = Column(
|
| 131 |
+
String(1000),
|
| 132 |
+
nullable=False,
|
| 133 |
+
comment="Detailed description of the challenge"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Challenge Configuration
|
| 137 |
+
type = Column(
|
| 138 |
+
Enum(ChallengeType),
|
| 139 |
+
nullable=False,
|
| 140 |
+
default=ChallengeType.INDIVIDUAL,
|
| 141 |
+
index=True,
|
| 142 |
+
comment="Type of challenge (individual, team, secret)"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
points = Column(
|
| 146 |
+
Integer,
|
| 147 |
+
nullable=False,
|
| 148 |
+
default=20,
|
| 149 |
+
comment="Points awarded for completing the challenge"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
status = Column(
|
| 153 |
+
Enum(ChallengeStatus),
|
| 154 |
+
nullable=False,
|
| 155 |
+
default=ChallengeStatus.PENDING,
|
| 156 |
+
index=True,
|
| 157 |
+
comment="Current status of the challenge"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# Validation
|
| 161 |
+
validated_by = Column(
|
| 162 |
+
Integer,
|
| 163 |
+
nullable=True,
|
| 164 |
+
comment="ID of admin who validated the challenge completion"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Timestamps
|
| 168 |
+
created_at = Column(
|
| 169 |
+
DateTime(timezone=True),
|
| 170 |
+
server_default=func.now(),
|
| 171 |
+
nullable=False,
|
| 172 |
+
index=True,
|
| 173 |
+
comment="Timestamp when challenge was created"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
completed_at = Column(
|
| 177 |
+
DateTime(timezone=True),
|
| 178 |
+
nullable=True,
|
| 179 |
+
comment="Timestamp when challenge was completed"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
updated_at = Column(
|
| 183 |
+
DateTime(timezone=True),
|
| 184 |
+
server_default=func.now(),
|
| 185 |
+
onupdate=func.now(),
|
| 186 |
+
nullable=False,
|
| 187 |
+
comment="Timestamp when challenge was last updated"
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# ==========================================================================
|
| 191 |
+
# Relationships
|
| 192 |
+
# ==========================================================================
|
| 193 |
+
|
| 194 |
+
# Participants assigned to this challenge (many-to-many)
|
| 195 |
+
assigned_participants = relationship(
|
| 196 |
+
"Participant",
|
| 197 |
+
secondary=challenge_assignments,
|
| 198 |
+
backref="assigned_challenges",
|
| 199 |
+
lazy="dynamic"
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# Participants who completed this challenge (many-to-many)
|
| 203 |
+
completed_by_participants = relationship(
|
| 204 |
+
"Participant",
|
| 205 |
+
secondary=challenge_completions,
|
| 206 |
+
backref="completed_challenges",
|
| 207 |
+
lazy="dynamic"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# ==========================================================================
|
| 211 |
+
# Methods
|
| 212 |
+
# ==========================================================================
|
| 213 |
+
|
| 214 |
+
def assign_to_participant(self, participant) -> None:
|
| 215 |
+
"""
|
| 216 |
+
Assign this challenge to a participant.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
participant: Participant instance to assign the challenge to
|
| 220 |
+
"""
|
| 221 |
+
if participant not in self.assigned_participants:
|
| 222 |
+
self.assigned_participants.append(participant)
|
| 223 |
+
|
| 224 |
+
def mark_as_active(self) -> None:
|
| 225 |
+
"""
|
| 226 |
+
Mark challenge as active (being attempted).
|
| 227 |
+
|
| 228 |
+
Raises:
|
| 229 |
+
ValueError: If challenge is already completed or failed
|
| 230 |
+
"""
|
| 231 |
+
if self.status in [ChallengeStatus.COMPLETED, ChallengeStatus.FAILED]:
|
| 232 |
+
raise ValueError(f"Cannot activate a {self.status.value} challenge")
|
| 233 |
+
self.status = ChallengeStatus.ACTIVE
|
| 234 |
+
|
| 235 |
+
def mark_as_completed(self, participant, admin_id: int) -> None:
|
| 236 |
+
"""
|
| 237 |
+
Mark challenge as completed by a participant.
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
participant: Participant who completed the challenge
|
| 241 |
+
admin_id: ID of admin validating the completion
|
| 242 |
+
|
| 243 |
+
Raises:
|
| 244 |
+
ValueError: If challenge is already completed
|
| 245 |
+
"""
|
| 246 |
+
if self.status == ChallengeStatus.COMPLETED:
|
| 247 |
+
raise ValueError("Challenge is already completed")
|
| 248 |
+
|
| 249 |
+
self.status = ChallengeStatus.COMPLETED
|
| 250 |
+
self.completed_at = func.now()
|
| 251 |
+
self.validated_by = admin_id
|
| 252 |
+
|
| 253 |
+
# Add participant to completed_by list if not already there
|
| 254 |
+
if participant not in self.completed_by_participants:
|
| 255 |
+
self.completed_by_participants.append(participant)
|
| 256 |
+
|
| 257 |
+
def mark_as_failed(self) -> None:
|
| 258 |
+
"""
|
| 259 |
+
Mark challenge as failed.
|
| 260 |
+
|
| 261 |
+
Raises:
|
| 262 |
+
ValueError: If challenge is already completed
|
| 263 |
+
"""
|
| 264 |
+
if self.status == ChallengeStatus.COMPLETED:
|
| 265 |
+
raise ValueError("Cannot fail a completed challenge")
|
| 266 |
+
self.status = ChallengeStatus.FAILED
|
| 267 |
+
|
| 268 |
+
def get_assigned_participant_ids(self) -> list[int]:
|
| 269 |
+
"""
|
| 270 |
+
Get list of participant IDs assigned to this challenge.
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
List of participant IDs
|
| 274 |
+
"""
|
| 275 |
+
return [p.id for p in self.assigned_participants.all()]
|
| 276 |
+
|
| 277 |
+
def get_completed_participant_ids(self) -> list[int]:
|
| 278 |
+
"""
|
| 279 |
+
Get list of participant IDs who completed this challenge.
|
| 280 |
+
|
| 281 |
+
Returns:
|
| 282 |
+
List of participant IDs
|
| 283 |
+
"""
|
| 284 |
+
return [p.id for p in self.completed_by_participants.all()]
|
| 285 |
+
|
| 286 |
+
def __repr__(self) -> str:
|
| 287 |
+
"""String representation of the challenge."""
|
| 288 |
+
return (
|
| 289 |
+
f"<Challenge(id={self.id}, title='{self.title}', "
|
| 290 |
+
f"type={self.type.value}, points={self.points}, status={self.status.value})>"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
def __str__(self) -> str:
|
| 294 |
+
"""Human-readable string representation."""
|
| 295 |
+
return f"{self.title} ({self.type.value}) - {self.points} pts - {self.status.value}"
|
backend/app/models/participant.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Participant database model for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Represents a participant in the bachelor party event.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON
|
| 8 |
+
from sqlalchemy.orm import relationship
|
| 9 |
+
from sqlalchemy.sql import func
|
| 10 |
+
from app.database import Base
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Participant(Base):
|
| 14 |
+
"""
|
| 15 |
+
Participant model representing an event participant.
|
| 16 |
+
|
| 17 |
+
Each participant can complete challenges, earn points, and open packs.
|
| 18 |
+
Paul (the groom) is marked with is_groom=True for special handling.
|
| 19 |
+
|
| 20 |
+
Attributes:
|
| 21 |
+
id: Primary key, auto-incrementing
|
| 22 |
+
name: Participant's full name
|
| 23 |
+
avatar_url: URL or path to participant's avatar image
|
| 24 |
+
is_groom: Whether this participant is the groom (Paul)
|
| 25 |
+
total_points: Current total points accumulated
|
| 26 |
+
current_packs: JSON object with pack counts {bronze: int, silver: int, gold: int, ultimate: int}
|
| 27 |
+
created_at: Timestamp when participant was created
|
| 28 |
+
updated_at: Timestamp when participant was last updated
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
__tablename__ = "participants"
|
| 32 |
+
|
| 33 |
+
# Primary Key
|
| 34 |
+
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
| 35 |
+
|
| 36 |
+
# Basic Information
|
| 37 |
+
name = Column(
|
| 38 |
+
String(100),
|
| 39 |
+
nullable=False,
|
| 40 |
+
unique=True,
|
| 41 |
+
index=True,
|
| 42 |
+
comment="Participant's full name (used for login)"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
avatar_url = Column(
|
| 46 |
+
String(500),
|
| 47 |
+
nullable=True,
|
| 48 |
+
comment="URL or path to participant's avatar image"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
is_groom = Column(
|
| 52 |
+
Boolean,
|
| 53 |
+
default=False,
|
| 54 |
+
nullable=False,
|
| 55 |
+
index=True,
|
| 56 |
+
comment="True if this participant is the groom (Paul)"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Points
|
| 60 |
+
total_points = Column(
|
| 61 |
+
Integer,
|
| 62 |
+
default=0,
|
| 63 |
+
nullable=False,
|
| 64 |
+
index=True,
|
| 65 |
+
comment="Current total points accumulated"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Pack Inventory (Phase 2 feature, prepared for future use)
|
| 69 |
+
current_packs = Column(
|
| 70 |
+
JSON,
|
| 71 |
+
default={"bronze": 0, "silver": 0, "gold": 0, "ultimate": 0},
|
| 72 |
+
nullable=False,
|
| 73 |
+
comment="Pack counts for each tier"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Timestamps
|
| 77 |
+
created_at = Column(
|
| 78 |
+
DateTime(timezone=True),
|
| 79 |
+
server_default=func.now(),
|
| 80 |
+
nullable=False,
|
| 81 |
+
comment="Timestamp when participant was created"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
updated_at = Column(
|
| 85 |
+
DateTime(timezone=True),
|
| 86 |
+
server_default=func.now(),
|
| 87 |
+
onupdate=func.now(),
|
| 88 |
+
nullable=False,
|
| 89 |
+
comment="Timestamp when participant was last updated"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# ==========================================================================
|
| 93 |
+
# Relationships
|
| 94 |
+
# ==========================================================================
|
| 95 |
+
|
| 96 |
+
# Relationship to points transactions
|
| 97 |
+
points_transactions = relationship(
|
| 98 |
+
"PointsTransaction",
|
| 99 |
+
back_populates="participant",
|
| 100 |
+
cascade="all, delete-orphan",
|
| 101 |
+
lazy="dynamic"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Relationship to challenges (many-to-many through association table)
|
| 105 |
+
# This will be defined when we create the Challenge model
|
| 106 |
+
|
| 107 |
+
# ==========================================================================
|
| 108 |
+
# Methods
|
| 109 |
+
# ==========================================================================
|
| 110 |
+
|
| 111 |
+
def add_points(self, amount: int) -> None:
|
| 112 |
+
"""
|
| 113 |
+
Add points to the participant's total.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
amount: Points to add (positive integer)
|
| 117 |
+
|
| 118 |
+
Raises:
|
| 119 |
+
ValueError: If amount is negative
|
| 120 |
+
"""
|
| 121 |
+
if amount < 0:
|
| 122 |
+
raise ValueError("Cannot add negative points. Use subtract_points() instead.")
|
| 123 |
+
self.total_points += amount
|
| 124 |
+
|
| 125 |
+
def subtract_points(self, amount: int) -> None:
|
| 126 |
+
"""
|
| 127 |
+
Subtract points from the participant's total.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
amount: Points to subtract (positive integer)
|
| 131 |
+
|
| 132 |
+
Raises:
|
| 133 |
+
ValueError: If amount is negative or would result in negative total
|
| 134 |
+
"""
|
| 135 |
+
if amount < 0:
|
| 136 |
+
raise ValueError("Cannot subtract negative points. Use add_points() instead.")
|
| 137 |
+
if self.total_points - amount < 0:
|
| 138 |
+
raise ValueError("Insufficient points. Cannot have negative total points.")
|
| 139 |
+
self.total_points -= amount
|
| 140 |
+
|
| 141 |
+
def add_pack(self, pack_tier: str) -> None:
|
| 142 |
+
"""
|
| 143 |
+
Add a pack to the participant's inventory.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
pack_tier: Pack tier (bronze, silver, gold, ultimate)
|
| 147 |
+
|
| 148 |
+
Raises:
|
| 149 |
+
ValueError: If pack_tier is invalid
|
| 150 |
+
"""
|
| 151 |
+
valid_tiers = ["bronze", "silver", "gold", "ultimate"]
|
| 152 |
+
if pack_tier not in valid_tiers:
|
| 153 |
+
raise ValueError(f"Invalid pack tier. Must be one of: {valid_tiers}")
|
| 154 |
+
|
| 155 |
+
# SQLAlchemy doesn't track JSON mutations automatically
|
| 156 |
+
# We need to create a new dict and reassign
|
| 157 |
+
packs = dict(self.current_packs)
|
| 158 |
+
packs[pack_tier] += 1
|
| 159 |
+
self.current_packs = packs
|
| 160 |
+
|
| 161 |
+
def remove_pack(self, pack_tier: str) -> None:
|
| 162 |
+
"""
|
| 163 |
+
Remove a pack from the participant's inventory.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
pack_tier: Pack tier (bronze, silver, gold, ultimate)
|
| 167 |
+
|
| 168 |
+
Raises:
|
| 169 |
+
ValueError: If pack_tier is invalid or participant has no packs of that tier
|
| 170 |
+
"""
|
| 171 |
+
valid_tiers = ["bronze", "silver", "gold", "ultimate"]
|
| 172 |
+
if pack_tier not in valid_tiers:
|
| 173 |
+
raise ValueError(f"Invalid pack tier. Must be one of: {valid_tiers}")
|
| 174 |
+
|
| 175 |
+
if self.current_packs[pack_tier] <= 0:
|
| 176 |
+
raise ValueError(f"No {pack_tier} packs available to remove")
|
| 177 |
+
|
| 178 |
+
# SQLAlchemy doesn't track JSON mutations automatically
|
| 179 |
+
packs = dict(self.current_packs)
|
| 180 |
+
packs[pack_tier] -= 1
|
| 181 |
+
self.current_packs = packs
|
| 182 |
+
|
| 183 |
+
def __repr__(self) -> str:
|
| 184 |
+
"""String representation of the participant."""
|
| 185 |
+
groom_badge = " (GROOM)" if self.is_groom else ""
|
| 186 |
+
return f"<Participant(id={self.id}, name='{self.name}', points={self.total_points}{groom_badge})>"
|
| 187 |
+
|
| 188 |
+
def __str__(self) -> str:
|
| 189 |
+
"""Human-readable string representation."""
|
| 190 |
+
return f"{self.name} - {self.total_points} pts"
|
backend/app/models/points_transaction.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Points Transaction database model for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Tracks all point changes for participants, providing a complete audit trail.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
| 8 |
+
from sqlalchemy.orm import relationship
|
| 9 |
+
from sqlalchemy.sql import func
|
| 10 |
+
from app.database import Base
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PointsTransaction(Base):
|
| 14 |
+
"""
|
| 15 |
+
Points transaction model for tracking all point changes.
|
| 16 |
+
|
| 17 |
+
Every time a participant gains or loses points, a transaction record
|
| 18 |
+
is created. This provides a complete audit trail and history.
|
| 19 |
+
|
| 20 |
+
Attributes:
|
| 21 |
+
id: Primary key, auto-incrementing
|
| 22 |
+
participant_id: ID of participant whose points changed
|
| 23 |
+
amount: Points amount (positive for gain, negative for loss)
|
| 24 |
+
reason: Human-readable reason for the transaction
|
| 25 |
+
challenge_id: Optional ID of challenge that caused this transaction
|
| 26 |
+
event_id: Optional ID of event that caused this transaction (Phase 2)
|
| 27 |
+
created_by: ID of admin who created the transaction (None for system)
|
| 28 |
+
created_at: Timestamp when transaction was created
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
__tablename__ = "points_transactions"
|
| 32 |
+
|
| 33 |
+
# Primary Key
|
| 34 |
+
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
| 35 |
+
|
| 36 |
+
# Foreign Keys
|
| 37 |
+
participant_id = Column(
|
| 38 |
+
Integer,
|
| 39 |
+
ForeignKey("participants.id", ondelete="CASCADE"),
|
| 40 |
+
nullable=False,
|
| 41 |
+
index=True,
|
| 42 |
+
comment="ID of participant whose points changed"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
challenge_id = Column(
|
| 46 |
+
Integer,
|
| 47 |
+
ForeignKey("challenges.id", ondelete="SET NULL"),
|
| 48 |
+
nullable=True,
|
| 49 |
+
index=True,
|
| 50 |
+
comment="Optional ID of challenge that caused this transaction"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Transaction Details
|
| 54 |
+
amount = Column(
|
| 55 |
+
Integer,
|
| 56 |
+
nullable=False,
|
| 57 |
+
comment="Points amount (positive for gain, negative for loss)"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
reason = Column(
|
| 61 |
+
String(500),
|
| 62 |
+
nullable=False,
|
| 63 |
+
comment="Human-readable reason for the transaction"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Metadata
|
| 67 |
+
created_by = Column(
|
| 68 |
+
Integer,
|
| 69 |
+
nullable=True,
|
| 70 |
+
comment="ID of admin who created the transaction (None for system)"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
created_at = Column(
|
| 74 |
+
DateTime(timezone=True),
|
| 75 |
+
server_default=func.now(),
|
| 76 |
+
nullable=False,
|
| 77 |
+
index=True,
|
| 78 |
+
comment="Timestamp when transaction was created"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
# ==========================================================================
|
| 82 |
+
# Relationships
|
| 83 |
+
# ==========================================================================
|
| 84 |
+
|
| 85 |
+
# Relationship to participant
|
| 86 |
+
participant = relationship(
|
| 87 |
+
"Participant",
|
| 88 |
+
back_populates="points_transactions"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Relationship to challenge (optional)
|
| 92 |
+
challenge = relationship(
|
| 93 |
+
"Challenge",
|
| 94 |
+
backref="points_transactions",
|
| 95 |
+
foreign_keys=[challenge_id]
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# ==========================================================================
|
| 99 |
+
# Class Methods
|
| 100 |
+
# ==========================================================================
|
| 101 |
+
|
| 102 |
+
@classmethod
|
| 103 |
+
def create_challenge_transaction(
|
| 104 |
+
cls,
|
| 105 |
+
participant_id: int,
|
| 106 |
+
challenge_id: int,
|
| 107 |
+
points: int,
|
| 108 |
+
admin_id: int
|
| 109 |
+
):
|
| 110 |
+
"""
|
| 111 |
+
Create a transaction for completing a challenge.
|
| 112 |
+
|
| 113 |
+
Args:
|
| 114 |
+
participant_id: ID of participant earning points
|
| 115 |
+
challenge_id: ID of completed challenge
|
| 116 |
+
points: Points earned
|
| 117 |
+
admin_id: ID of admin who validated the challenge
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
New PointsTransaction instance
|
| 121 |
+
"""
|
| 122 |
+
return cls(
|
| 123 |
+
participant_id=participant_id,
|
| 124 |
+
challenge_id=challenge_id,
|
| 125 |
+
amount=points,
|
| 126 |
+
reason=f"Completed challenge (ID: {challenge_id})",
|
| 127 |
+
created_by=admin_id
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
@classmethod
|
| 131 |
+
def create_manual_transaction(
|
| 132 |
+
cls,
|
| 133 |
+
participant_id: int,
|
| 134 |
+
amount: int,
|
| 135 |
+
reason: str,
|
| 136 |
+
admin_id: int
|
| 137 |
+
):
|
| 138 |
+
"""
|
| 139 |
+
Create a manual transaction (admin adjustment).
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
participant_id: ID of participant
|
| 143 |
+
amount: Points to add/subtract
|
| 144 |
+
reason: Reason for the adjustment
|
| 145 |
+
admin_id: ID of admin making the adjustment
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
New PointsTransaction instance
|
| 149 |
+
"""
|
| 150 |
+
return cls(
|
| 151 |
+
participant_id=participant_id,
|
| 152 |
+
amount=amount,
|
| 153 |
+
reason=reason,
|
| 154 |
+
created_by=admin_id
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
@classmethod
|
| 158 |
+
def create_penalty_transaction(
|
| 159 |
+
cls,
|
| 160 |
+
participant_id: int,
|
| 161 |
+
penalty_amount: int,
|
| 162 |
+
reason: str,
|
| 163 |
+
admin_id: int = None
|
| 164 |
+
):
|
| 165 |
+
"""
|
| 166 |
+
Create a penalty transaction (negative points).
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
participant_id: ID of participant being penalized
|
| 170 |
+
penalty_amount: Penalty points (will be made negative)
|
| 171 |
+
reason: Reason for the penalty
|
| 172 |
+
admin_id: Optional ID of admin applying the penalty
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
New PointsTransaction instance
|
| 176 |
+
"""
|
| 177 |
+
return cls(
|
| 178 |
+
participant_id=participant_id,
|
| 179 |
+
amount=-abs(penalty_amount), # Ensure it's negative
|
| 180 |
+
reason=f"Penalty: {reason}",
|
| 181 |
+
created_by=admin_id
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# ==========================================================================
|
| 185 |
+
# Instance Methods
|
| 186 |
+
# ==========================================================================
|
| 187 |
+
|
| 188 |
+
def is_positive(self) -> bool:
|
| 189 |
+
"""
|
| 190 |
+
Check if this transaction added points.
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
True if amount is positive, False otherwise
|
| 194 |
+
"""
|
| 195 |
+
return self.amount > 0
|
| 196 |
+
|
| 197 |
+
def is_negative(self) -> bool:
|
| 198 |
+
"""
|
| 199 |
+
Check if this transaction subtracted points.
|
| 200 |
+
|
| 201 |
+
Returns:
|
| 202 |
+
True if amount is negative, False otherwise
|
| 203 |
+
"""
|
| 204 |
+
return self.amount < 0
|
| 205 |
+
|
| 206 |
+
def is_from_challenge(self) -> bool:
|
| 207 |
+
"""
|
| 208 |
+
Check if this transaction is from a challenge completion.
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
True if challenge_id is set, False otherwise
|
| 212 |
+
"""
|
| 213 |
+
return self.challenge_id is not None
|
| 214 |
+
|
| 215 |
+
def is_manual_adjustment(self) -> bool:
|
| 216 |
+
"""
|
| 217 |
+
Check if this transaction is a manual adjustment by admin.
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
True if there's no associated challenge, False otherwise
|
| 221 |
+
"""
|
| 222 |
+
return self.challenge_id is None
|
| 223 |
+
|
| 224 |
+
def __repr__(self) -> str:
|
| 225 |
+
"""String representation of the transaction."""
|
| 226 |
+
sign = "+" if self.amount >= 0 else ""
|
| 227 |
+
return (
|
| 228 |
+
f"<PointsTransaction(id={self.id}, participant_id={self.participant_id}, "
|
| 229 |
+
f"amount={sign}{self.amount}, reason='{self.reason}')>"
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
def __str__(self) -> str:
|
| 233 |
+
"""Human-readable string representation."""
|
| 234 |
+
sign = "+" if self.amount >= 0 else ""
|
| 235 |
+
return f"{sign}{self.amount} pts - {self.reason}"
|
backend/app/routes/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API routes for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
This module exports all route routers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from app.routes.auth import router as auth_router
|
| 8 |
+
from app.routes.participants import router as participants_router
|
| 9 |
+
from app.routes.challenges import router as challenges_router
|
| 10 |
+
from app.routes.points import router as points_router
|
| 11 |
+
from app.routes.leaderboard import router as leaderboard_router
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"auth_router",
|
| 15 |
+
"participants_router",
|
| 16 |
+
"challenges_router",
|
| 17 |
+
"points_router",
|
| 18 |
+
"leaderboard_router",
|
| 19 |
+
]
|
backend/app/routes/auth.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication routes for EVG Ultimate Team API.
|
| 3 |
+
|
| 4 |
+
Handles participant and admin login endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from app.database import get_db
|
| 10 |
+
from app.schemas.auth import ParticipantLogin, AdminLogin, AuthToken
|
| 11 |
+
from app.schemas.common import APIResponse
|
| 12 |
+
from app.services import auth_service
|
| 13 |
+
from app.utils.exceptions import EVGException, format_exception_response
|
| 14 |
+
from app.utils.logger import logger
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.post("/login", response_model=APIResponse[AuthToken])
|
| 20 |
+
def participant_login(
|
| 21 |
+
login_data: ParticipantLogin,
|
| 22 |
+
db: Session = Depends(get_db)
|
| 23 |
+
):
|
| 24 |
+
"""
|
| 25 |
+
Participant login endpoint (simple username-only authentication).
|
| 26 |
+
|
| 27 |
+
No password required for Phase 1 MVP. Just provide your name.
|
| 28 |
+
|
| 29 |
+
**Request Body:**
|
| 30 |
+
- `username`: Your full name (case-insensitive)
|
| 31 |
+
|
| 32 |
+
**Returns:**
|
| 33 |
+
- Access token for authenticated requests
|
| 34 |
+
- User information (ID, username, is_groom status)
|
| 35 |
+
|
| 36 |
+
**Example:**
|
| 37 |
+
```json
|
| 38 |
+
{
|
| 39 |
+
"username": "Paul C."
|
| 40 |
+
}
|
| 41 |
+
```
|
| 42 |
+
"""
|
| 43 |
+
try:
|
| 44 |
+
token = auth_service.authenticate_participant(db, login_data)
|
| 45 |
+
return APIResponse(
|
| 46 |
+
success=True,
|
| 47 |
+
data=token,
|
| 48 |
+
message=f"Welcome, {token.username}!"
|
| 49 |
+
)
|
| 50 |
+
except EVGException as e:
|
| 51 |
+
logger.error(f"Login failed: {e.message}")
|
| 52 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post("/admin-login", response_model=APIResponse[AuthToken])
|
| 56 |
+
def admin_login(login_data: AdminLogin):
|
| 57 |
+
"""
|
| 58 |
+
Admin login endpoint (requires username and password).
|
| 59 |
+
|
| 60 |
+
For Clément (organizer) to access admin dashboard.
|
| 61 |
+
|
| 62 |
+
**Request Body:**
|
| 63 |
+
- `username`: Admin username
|
| 64 |
+
- `password`: Admin password
|
| 65 |
+
|
| 66 |
+
**Returns:**
|
| 67 |
+
- Access token with admin privileges
|
| 68 |
+
- Admin information
|
| 69 |
+
|
| 70 |
+
**Example:**
|
| 71 |
+
```json
|
| 72 |
+
{
|
| 73 |
+
"username": "clement",
|
| 74 |
+
"password": "evg2026_admin"
|
| 75 |
+
}
|
| 76 |
+
```
|
| 77 |
+
"""
|
| 78 |
+
try:
|
| 79 |
+
token = auth_service.authenticate_admin(login_data)
|
| 80 |
+
return APIResponse(
|
| 81 |
+
success=True,
|
| 82 |
+
data=token,
|
| 83 |
+
message=f"Admin login successful. Welcome, {token.username}!"
|
| 84 |
+
)
|
| 85 |
+
except EVGException as e:
|
| 86 |
+
logger.error(f"Admin login failed: {e.message}")
|
| 87 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
backend/app/routes/challenges.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Challenge routes for EVG Ultimate Team API.
|
| 3 |
+
|
| 4 |
+
Handles challenge CRUD operations and validation.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from typing import List
|
| 10 |
+
from app.database import get_db
|
| 11 |
+
from app.schemas.challenge import (
|
| 12 |
+
ChallengeResponse,
|
| 13 |
+
ChallengeCreate,
|
| 14 |
+
ChallengeUpdate,
|
| 15 |
+
ChallengeAttempt,
|
| 16 |
+
ChallengeValidation
|
| 17 |
+
)
|
| 18 |
+
from app.schemas.common import APIResponse, SuccessResponse
|
| 19 |
+
from app.services import challenge_service, points_service
|
| 20 |
+
from app.utils.dependencies import require_admin, get_current_user_payload, get_admin_id
|
| 21 |
+
from app.utils.exceptions import EVGException, format_exception_response
|
| 22 |
+
from app.utils.logger import logger
|
| 23 |
+
from app.models.challenge import ChallengeStatus
|
| 24 |
+
|
| 25 |
+
router = APIRouter(prefix="/challenges", tags=["Challenges"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _serialize_challenge(challenge) -> dict:
|
| 29 |
+
"""
|
| 30 |
+
Convert a Challenge ORM model to a dictionary with relationship IDs.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
challenge: Challenge ORM instance
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Dictionary with all challenge fields including serialized relationships
|
| 37 |
+
"""
|
| 38 |
+
return {
|
| 39 |
+
"id": challenge.id,
|
| 40 |
+
"title": challenge.title,
|
| 41 |
+
"description": challenge.description,
|
| 42 |
+
"type": challenge.type,
|
| 43 |
+
"points": challenge.points,
|
| 44 |
+
"status": challenge.status,
|
| 45 |
+
"assigned_to": [p.id for p in challenge.assigned_participants],
|
| 46 |
+
"completed_by": [p.id for p in challenge.completed_by_participants],
|
| 47 |
+
"validated_by": challenge.validated_by,
|
| 48 |
+
"created_at": challenge.created_at,
|
| 49 |
+
"completed_at": challenge.completed_at,
|
| 50 |
+
"updated_at": challenge.updated_at
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.get("", response_model=APIResponse[List[ChallengeResponse]])
|
| 55 |
+
def list_challenges(
|
| 56 |
+
skip: int = 0,
|
| 57 |
+
limit: int = 100,
|
| 58 |
+
db: Session = Depends(get_db)
|
| 59 |
+
):
|
| 60 |
+
"""
|
| 61 |
+
Get all challenges.
|
| 62 |
+
|
| 63 |
+
**Query Parameters:**
|
| 64 |
+
- `skip`: Number of records to skip (pagination)
|
| 65 |
+
- `limit`: Maximum number of records to return
|
| 66 |
+
|
| 67 |
+
**Returns:**
|
| 68 |
+
- List of all challenges
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
challenges = challenge_service.get_all_challenges(db, skip, limit)
|
| 72 |
+
challenge_responses = [_serialize_challenge(c) for c in challenges]
|
| 73 |
+
|
| 74 |
+
return APIResponse(
|
| 75 |
+
success=True,
|
| 76 |
+
data=challenge_responses,
|
| 77 |
+
message=f"Retrieved {len(challenges)} challenges"
|
| 78 |
+
)
|
| 79 |
+
except EVGException as e:
|
| 80 |
+
logger.error(f"Failed to list challenges: {e.message}")
|
| 81 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.get("/{challenge_id}", response_model=APIResponse[ChallengeResponse])
|
| 85 |
+
def get_challenge(
|
| 86 |
+
challenge_id: int,
|
| 87 |
+
db: Session = Depends(get_db)
|
| 88 |
+
):
|
| 89 |
+
"""
|
| 90 |
+
Get a specific challenge by ID.
|
| 91 |
+
|
| 92 |
+
**Path Parameters:**
|
| 93 |
+
- `challenge_id`: Challenge ID
|
| 94 |
+
|
| 95 |
+
**Returns:**
|
| 96 |
+
- Challenge details
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
challenge = challenge_service.get_challenge_by_id(db, challenge_id)
|
| 100 |
+
return APIResponse(
|
| 101 |
+
success=True,
|
| 102 |
+
data=_serialize_challenge(challenge),
|
| 103 |
+
message=f"Challenge retrieved: {challenge.title}"
|
| 104 |
+
)
|
| 105 |
+
except EVGException as e:
|
| 106 |
+
logger.error(f"Failed to get challenge {challenge_id}: {e.message}")
|
| 107 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@router.post("", response_model=APIResponse[ChallengeResponse])
|
| 111 |
+
def create_challenge(
|
| 112 |
+
challenge_data: ChallengeCreate,
|
| 113 |
+
db: Session = Depends(get_db),
|
| 114 |
+
admin_id: int = Depends(get_admin_id)
|
| 115 |
+
):
|
| 116 |
+
"""
|
| 117 |
+
Create a new challenge (admin only).
|
| 118 |
+
|
| 119 |
+
**Requires:** Admin authentication
|
| 120 |
+
|
| 121 |
+
**Request Body:**
|
| 122 |
+
- `title`: Challenge title
|
| 123 |
+
- `description`: Detailed description
|
| 124 |
+
- `type`: Challenge type (individual, team, secret)
|
| 125 |
+
- `points`: Points awarded for completion
|
| 126 |
+
- `assigned_to`: Optional list of participant IDs
|
| 127 |
+
|
| 128 |
+
**Returns:**
|
| 129 |
+
- Created challenge details
|
| 130 |
+
"""
|
| 131 |
+
try:
|
| 132 |
+
challenge = challenge_service.create_challenge(db, challenge_data, admin_id)
|
| 133 |
+
return APIResponse(
|
| 134 |
+
success=True,
|
| 135 |
+
data=_serialize_challenge(challenge),
|
| 136 |
+
message=f"Challenge created: {challenge.title}"
|
| 137 |
+
)
|
| 138 |
+
except EVGException as e:
|
| 139 |
+
logger.error(f"Failed to create challenge: {e.message}")
|
| 140 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.put("/{challenge_id}", response_model=APIResponse[ChallengeResponse])
|
| 144 |
+
def update_challenge(
|
| 145 |
+
challenge_id: int,
|
| 146 |
+
challenge_data: ChallengeUpdate,
|
| 147 |
+
db: Session = Depends(get_db),
|
| 148 |
+
admin_id: int = Depends(get_admin_id)
|
| 149 |
+
):
|
| 150 |
+
"""
|
| 151 |
+
Update a challenge (admin only).
|
| 152 |
+
|
| 153 |
+
**Requires:** Admin authentication
|
| 154 |
+
|
| 155 |
+
**Path Parameters:**
|
| 156 |
+
- `challenge_id`: Challenge ID to update
|
| 157 |
+
|
| 158 |
+
**Request Body:**
|
| 159 |
+
- Any field from ChallengeUpdate (all optional)
|
| 160 |
+
|
| 161 |
+
**Returns:**
|
| 162 |
+
- Updated challenge details
|
| 163 |
+
"""
|
| 164 |
+
try:
|
| 165 |
+
challenge = challenge_service.update_challenge(db, challenge_id, challenge_data, admin_id)
|
| 166 |
+
return APIResponse(
|
| 167 |
+
success=True,
|
| 168 |
+
data=_serialize_challenge(challenge),
|
| 169 |
+
message=f"Challenge updated: {challenge.title}"
|
| 170 |
+
)
|
| 171 |
+
except EVGException as e:
|
| 172 |
+
logger.error(f"Failed to update challenge {challenge_id}: {e.message}")
|
| 173 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@router.delete("/{challenge_id}", response_model=SuccessResponse)
|
| 177 |
+
def delete_challenge(
|
| 178 |
+
challenge_id: int,
|
| 179 |
+
db: Session = Depends(get_db),
|
| 180 |
+
admin_id: int = Depends(get_admin_id)
|
| 181 |
+
):
|
| 182 |
+
"""
|
| 183 |
+
Delete a challenge (admin only).
|
| 184 |
+
|
| 185 |
+
**Requires:** Admin authentication
|
| 186 |
+
|
| 187 |
+
**Path Parameters:**
|
| 188 |
+
- `challenge_id`: Challenge ID to delete
|
| 189 |
+
|
| 190 |
+
**Returns:**
|
| 191 |
+
- Success confirmation
|
| 192 |
+
"""
|
| 193 |
+
try:
|
| 194 |
+
challenge_service.delete_challenge(db, challenge_id, admin_id)
|
| 195 |
+
return SuccessResponse(
|
| 196 |
+
success=True,
|
| 197 |
+
message=f"Challenge {challenge_id} deleted successfully"
|
| 198 |
+
)
|
| 199 |
+
except EVGException as e:
|
| 200 |
+
logger.error(f"Failed to delete challenge {challenge_id}: {e.message}")
|
| 201 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@router.post("/{challenge_id}/attempt", response_model=APIResponse[ChallengeResponse])
|
| 205 |
+
def attempt_challenge(
|
| 206 |
+
challenge_id: int,
|
| 207 |
+
attempt_data: ChallengeAttempt,
|
| 208 |
+
db: Session = Depends(get_db),
|
| 209 |
+
current_user = Depends(get_current_user_payload)
|
| 210 |
+
):
|
| 211 |
+
"""
|
| 212 |
+
Mark a challenge as being attempted.
|
| 213 |
+
|
| 214 |
+
Changes challenge status to 'active'.
|
| 215 |
+
|
| 216 |
+
**Path Parameters:**
|
| 217 |
+
- `challenge_id`: Challenge ID
|
| 218 |
+
|
| 219 |
+
**Request Body:**
|
| 220 |
+
- `participant_id`: ID of participant attempting the challenge
|
| 221 |
+
|
| 222 |
+
**Returns:**
|
| 223 |
+
- Updated challenge details
|
| 224 |
+
"""
|
| 225 |
+
try:
|
| 226 |
+
challenge = challenge_service.mark_challenge_active(
|
| 227 |
+
db,
|
| 228 |
+
challenge_id,
|
| 229 |
+
attempt_data.participant_id
|
| 230 |
+
)
|
| 231 |
+
return APIResponse(
|
| 232 |
+
success=True,
|
| 233 |
+
data=_serialize_challenge(challenge),
|
| 234 |
+
message=f"Challenge marked as active: {challenge.title}"
|
| 235 |
+
)
|
| 236 |
+
except EVGException as e:
|
| 237 |
+
logger.error(f"Failed to attempt challenge {challenge_id}: {e.message}")
|
| 238 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@router.post("/{challenge_id}/validate", response_model=APIResponse[ChallengeResponse])
|
| 242 |
+
def validate_challenge(
|
| 243 |
+
challenge_id: int,
|
| 244 |
+
validation_data: ChallengeValidation,
|
| 245 |
+
db: Session = Depends(get_db),
|
| 246 |
+
admin = Depends(require_admin)
|
| 247 |
+
):
|
| 248 |
+
"""
|
| 249 |
+
Validate challenge completion (admin only).
|
| 250 |
+
|
| 251 |
+
Marks challenge as completed/failed and awards points if completed.
|
| 252 |
+
|
| 253 |
+
**Requires:** Admin authentication
|
| 254 |
+
|
| 255 |
+
**Path Parameters:**
|
| 256 |
+
- `challenge_id`: Challenge ID
|
| 257 |
+
|
| 258 |
+
**Request Body:**
|
| 259 |
+
- `participant_ids`: List of participant IDs who completed the challenge
|
| 260 |
+
- `status`: Validation result (completed or failed)
|
| 261 |
+
- `admin_id`: Admin ID validating the challenge
|
| 262 |
+
|
| 263 |
+
**Returns:**
|
| 264 |
+
- Updated challenge details
|
| 265 |
+
"""
|
| 266 |
+
try:
|
| 267 |
+
# Validate the challenge
|
| 268 |
+
challenge = challenge_service.validate_challenge_completion(
|
| 269 |
+
db,
|
| 270 |
+
challenge_id,
|
| 271 |
+
validation_data
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# If completed, award points to participants
|
| 275 |
+
if validation_data.status == ChallengeStatus.COMPLETED:
|
| 276 |
+
for participant_id in validation_data.participant_ids:
|
| 277 |
+
points_service.award_challenge_points(
|
| 278 |
+
db,
|
| 279 |
+
participant_id,
|
| 280 |
+
challenge_id,
|
| 281 |
+
validation_data.admin_id
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
return APIResponse(
|
| 285 |
+
success=True,
|
| 286 |
+
data=_serialize_challenge(challenge),
|
| 287 |
+
message=f"Challenge validated as {validation_data.status.value}: {challenge.title}"
|
| 288 |
+
)
|
| 289 |
+
except EVGException as e:
|
| 290 |
+
logger.error(f"Failed to validate challenge {challenge_id}: {e.message}")
|
| 291 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
@router.get("/participant/{participant_id}", response_model=APIResponse[dict])
|
| 295 |
+
def get_participant_challenges(
|
| 296 |
+
participant_id: int,
|
| 297 |
+
db: Session = Depends(get_db)
|
| 298 |
+
):
|
| 299 |
+
"""
|
| 300 |
+
Get all challenges for a specific participant.
|
| 301 |
+
|
| 302 |
+
**Path Parameters:**
|
| 303 |
+
- `participant_id`: Participant ID
|
| 304 |
+
|
| 305 |
+
**Returns:**
|
| 306 |
+
- Dictionary with 'assigned' and 'completed' challenge lists
|
| 307 |
+
"""
|
| 308 |
+
try:
|
| 309 |
+
challenges = challenge_service.get_participant_challenges(db, participant_id)
|
| 310 |
+
# Serialize the challenge lists
|
| 311 |
+
serialized_challenges = {
|
| 312 |
+
"assigned": [_serialize_challenge(c) for c in challenges["assigned"]],
|
| 313 |
+
"completed": [_serialize_challenge(c) for c in challenges["completed"]]
|
| 314 |
+
}
|
| 315 |
+
return APIResponse(
|
| 316 |
+
success=True,
|
| 317 |
+
data=serialized_challenges,
|
| 318 |
+
message=f"Retrieved challenges for participant {participant_id}"
|
| 319 |
+
)
|
| 320 |
+
except EVGException as e:
|
| 321 |
+
logger.error(f"Failed to get challenges for participant {participant_id}: {e.message}")
|
| 322 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
backend/app/routes/leaderboard.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Leaderboard routes for EVG Ultimate Team API.
|
| 3 |
+
|
| 4 |
+
Handles leaderboard and rankings.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from typing import List
|
| 10 |
+
from app.database import get_db
|
| 11 |
+
from app.schemas.participant import ParticipantWithRank
|
| 12 |
+
from app.schemas.common import APIResponse
|
| 13 |
+
from app.services import leaderboard_service
|
| 14 |
+
from app.utils.exceptions import EVGException, format_exception_response
|
| 15 |
+
from app.utils.logger import logger
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/leaderboard", tags=["Leaderboard"])
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("", response_model=APIResponse[List[ParticipantWithRank]])
|
| 21 |
+
def get_leaderboard(
|
| 22 |
+
include_today: bool = False,
|
| 23 |
+
db: Session = Depends(get_db)
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Get the current leaderboard with all participants ranked by points.
|
| 27 |
+
|
| 28 |
+
**Query Parameters:**
|
| 29 |
+
- `include_today`: Include points earned today (default: false)
|
| 30 |
+
|
| 31 |
+
**Returns:**
|
| 32 |
+
- List of participants ranked by total points
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
leaderboard = leaderboard_service.get_leaderboard(db, include_today_points=include_today)
|
| 36 |
+
return APIResponse(
|
| 37 |
+
success=True,
|
| 38 |
+
data=leaderboard,
|
| 39 |
+
message=f"Leaderboard with {len(leaderboard)} participants"
|
| 40 |
+
)
|
| 41 |
+
except EVGException as e:
|
| 42 |
+
logger.error(f"Failed to get leaderboard: {e.message}")
|
| 43 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("/top-3", response_model=APIResponse[List[ParticipantWithRank]])
|
| 47 |
+
def get_top_3(db: Session = Depends(get_db)):
|
| 48 |
+
"""
|
| 49 |
+
Get the top 3 participants (podium).
|
| 50 |
+
|
| 51 |
+
**Returns:**
|
| 52 |
+
- Top 3 participants ranked by total points
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
podium = leaderboard_service.get_top_3(db)
|
| 56 |
+
return APIResponse(
|
| 57 |
+
success=True,
|
| 58 |
+
data=podium,
|
| 59 |
+
message="Top 3 participants"
|
| 60 |
+
)
|
| 61 |
+
except EVGException as e:
|
| 62 |
+
logger.error(f"Failed to get top 3: {e.message}")
|
| 63 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("/daily", response_model=APIResponse[ParticipantWithRank])
|
| 67 |
+
def get_daily_leader(db: Session = Depends(get_db)):
|
| 68 |
+
"""
|
| 69 |
+
Get the current daily leader (participant with most points today).
|
| 70 |
+
|
| 71 |
+
According to the event rules, the daily leader chooses the next day's aperitif theme.
|
| 72 |
+
|
| 73 |
+
**Returns:**
|
| 74 |
+
- Daily leader with today's points
|
| 75 |
+
"""
|
| 76 |
+
try:
|
| 77 |
+
daily_leader = leaderboard_service.get_daily_leader(db)
|
| 78 |
+
return APIResponse(
|
| 79 |
+
success=True,
|
| 80 |
+
data=daily_leader,
|
| 81 |
+
message=f"Today's leader: {daily_leader.name if daily_leader else 'No leader yet'}"
|
| 82 |
+
)
|
| 83 |
+
except EVGException as e:
|
| 84 |
+
logger.error(f"Failed to get daily leader: {e.message}")
|
| 85 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.get("/rank/{participant_id}", response_model=APIResponse[dict])
|
| 89 |
+
def get_participant_rank(
|
| 90 |
+
participant_id: int,
|
| 91 |
+
db: Session = Depends(get_db)
|
| 92 |
+
):
|
| 93 |
+
"""
|
| 94 |
+
Get the current rank of a specific participant.
|
| 95 |
+
|
| 96 |
+
**Path Parameters:**
|
| 97 |
+
- `participant_id`: Participant ID
|
| 98 |
+
|
| 99 |
+
**Returns:**
|
| 100 |
+
- Current rank (1-based)
|
| 101 |
+
"""
|
| 102 |
+
try:
|
| 103 |
+
rank = leaderboard_service.get_participant_rank(db, participant_id)
|
| 104 |
+
return APIResponse(
|
| 105 |
+
success=True,
|
| 106 |
+
data={"participant_id": participant_id, "rank": rank},
|
| 107 |
+
message=f"Participant is ranked #{rank}"
|
| 108 |
+
)
|
| 109 |
+
except EVGException as e:
|
| 110 |
+
logger.error(f"Failed to get participant rank: {e.message}")
|
| 111 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@router.get("/stats", response_model=APIResponse[dict])
|
| 115 |
+
def get_leaderboard_stats(db: Session = Depends(get_db)):
|
| 116 |
+
"""
|
| 117 |
+
Get statistics about the leaderboard.
|
| 118 |
+
|
| 119 |
+
**Returns:**
|
| 120 |
+
- Statistics including average points, highest/lowest points, etc.
|
| 121 |
+
"""
|
| 122 |
+
try:
|
| 123 |
+
stats = leaderboard_service.get_leaderboard_stats(db)
|
| 124 |
+
return APIResponse(
|
| 125 |
+
success=True,
|
| 126 |
+
data=stats,
|
| 127 |
+
message="Leaderboard statistics"
|
| 128 |
+
)
|
| 129 |
+
except EVGException as e:
|
| 130 |
+
logger.error(f"Failed to get leaderboard stats: {e.message}")
|
| 131 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
backend/app/routes/participants.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Participant routes for EVG Ultimate Team API.
|
| 3 |
+
|
| 4 |
+
Handles participant CRUD operations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from typing import List
|
| 10 |
+
from app.database import get_db
|
| 11 |
+
from app.schemas.participant import ParticipantResponse, ParticipantCreate, ParticipantUpdate
|
| 12 |
+
from app.schemas.common import APIResponse, SuccessResponse
|
| 13 |
+
from app.services import participant_service
|
| 14 |
+
from app.utils.dependencies import require_admin, get_current_participant
|
| 15 |
+
from app.utils.exceptions import EVGException, format_exception_response
|
| 16 |
+
from app.utils.logger import logger
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/participants", tags=["Participants"])
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@router.get("", response_model=APIResponse[List[ParticipantResponse]])
|
| 22 |
+
def list_participants(
|
| 23 |
+
skip: int = 0,
|
| 24 |
+
limit: int = 100,
|
| 25 |
+
db: Session = Depends(get_db)
|
| 26 |
+
):
|
| 27 |
+
"""
|
| 28 |
+
Get all participants.
|
| 29 |
+
|
| 30 |
+
**Query Parameters:**
|
| 31 |
+
- `skip`: Number of records to skip (pagination)
|
| 32 |
+
- `limit`: Maximum number of records to return
|
| 33 |
+
|
| 34 |
+
**Returns:**
|
| 35 |
+
- List of all participants with their points and stats
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
participants = participant_service.get_all_participants(db, skip, limit)
|
| 39 |
+
return APIResponse(
|
| 40 |
+
success=True,
|
| 41 |
+
data=participants,
|
| 42 |
+
message=f"Retrieved {len(participants)} participants"
|
| 43 |
+
)
|
| 44 |
+
except EVGException as e:
|
| 45 |
+
logger.error(f"Failed to list participants: {e.message}")
|
| 46 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.get("/{participant_id}", response_model=APIResponse[ParticipantResponse])
|
| 50 |
+
def get_participant(
|
| 51 |
+
participant_id: int,
|
| 52 |
+
db: Session = Depends(get_db)
|
| 53 |
+
):
|
| 54 |
+
"""
|
| 55 |
+
Get a specific participant by ID.
|
| 56 |
+
|
| 57 |
+
**Path Parameters:**
|
| 58 |
+
- `participant_id`: Participant ID
|
| 59 |
+
|
| 60 |
+
**Returns:**
|
| 61 |
+
- Participant details including points, packs, and timestamps
|
| 62 |
+
"""
|
| 63 |
+
try:
|
| 64 |
+
participant = participant_service.get_participant_by_id(db, participant_id)
|
| 65 |
+
return APIResponse(
|
| 66 |
+
success=True,
|
| 67 |
+
data=participant,
|
| 68 |
+
message=f"Participant retrieved: {participant.name}"
|
| 69 |
+
)
|
| 70 |
+
except EVGException as e:
|
| 71 |
+
logger.error(f"Failed to get participant {participant_id}: {e.message}")
|
| 72 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.get("/me/profile", response_model=APIResponse[ParticipantResponse])
|
| 76 |
+
def get_my_profile(
|
| 77 |
+
current_participant = Depends(get_current_participant)
|
| 78 |
+
):
|
| 79 |
+
"""
|
| 80 |
+
Get the current authenticated participant's profile.
|
| 81 |
+
|
| 82 |
+
**Requires:** Valid participant authentication token
|
| 83 |
+
|
| 84 |
+
**Returns:**
|
| 85 |
+
- Current participant's details
|
| 86 |
+
"""
|
| 87 |
+
return APIResponse(
|
| 88 |
+
success=True,
|
| 89 |
+
data=current_participant,
|
| 90 |
+
message=f"Your profile: {current_participant.name}"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@router.post("", response_model=APIResponse[ParticipantResponse])
|
| 95 |
+
def create_participant(
|
| 96 |
+
participant_data: ParticipantCreate,
|
| 97 |
+
db: Session = Depends(get_db),
|
| 98 |
+
admin = Depends(require_admin)
|
| 99 |
+
):
|
| 100 |
+
"""
|
| 101 |
+
Create a new participant (admin only).
|
| 102 |
+
|
| 103 |
+
**Requires:** Admin authentication
|
| 104 |
+
|
| 105 |
+
**Request Body:**
|
| 106 |
+
- `name`: Participant's full name (unique)
|
| 107 |
+
- `avatar_url`: Optional avatar image URL
|
| 108 |
+
- `is_groom`: Whether this participant is the groom (default: false)
|
| 109 |
+
|
| 110 |
+
**Returns:**
|
| 111 |
+
- Created participant details
|
| 112 |
+
"""
|
| 113 |
+
try:
|
| 114 |
+
participant = participant_service.create_participant(db, participant_data)
|
| 115 |
+
return APIResponse(
|
| 116 |
+
success=True,
|
| 117 |
+
data=participant,
|
| 118 |
+
message=f"Participant created: {participant.name}"
|
| 119 |
+
)
|
| 120 |
+
except EVGException as e:
|
| 121 |
+
logger.error(f"Failed to create participant: {e.message}")
|
| 122 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@router.put("/{participant_id}", response_model=APIResponse[ParticipantResponse])
|
| 126 |
+
def update_participant(
|
| 127 |
+
participant_id: int,
|
| 128 |
+
participant_data: ParticipantUpdate,
|
| 129 |
+
db: Session = Depends(get_db),
|
| 130 |
+
admin = Depends(require_admin)
|
| 131 |
+
):
|
| 132 |
+
"""
|
| 133 |
+
Update a participant (admin only).
|
| 134 |
+
|
| 135 |
+
**Requires:** Admin authentication
|
| 136 |
+
|
| 137 |
+
**Path Parameters:**
|
| 138 |
+
- `participant_id`: Participant ID to update
|
| 139 |
+
|
| 140 |
+
**Request Body:**
|
| 141 |
+
- Any field from ParticipantUpdate (all optional)
|
| 142 |
+
|
| 143 |
+
**Returns:**
|
| 144 |
+
- Updated participant details
|
| 145 |
+
"""
|
| 146 |
+
try:
|
| 147 |
+
participant = participant_service.update_participant(db, participant_id, participant_data)
|
| 148 |
+
return APIResponse(
|
| 149 |
+
success=True,
|
| 150 |
+
data=participant,
|
| 151 |
+
message=f"Participant updated: {participant.name}"
|
| 152 |
+
)
|
| 153 |
+
except EVGException as e:
|
| 154 |
+
logger.error(f"Failed to update participant {participant_id}: {e.message}")
|
| 155 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.delete("/{participant_id}", response_model=SuccessResponse)
|
| 159 |
+
def delete_participant(
|
| 160 |
+
participant_id: int,
|
| 161 |
+
db: Session = Depends(get_db),
|
| 162 |
+
admin = Depends(require_admin)
|
| 163 |
+
):
|
| 164 |
+
"""
|
| 165 |
+
Delete a participant (admin only).
|
| 166 |
+
|
| 167 |
+
**WARNING:** This will cascade delete all associated data
|
| 168 |
+
(points transactions, challenge associations, etc.)
|
| 169 |
+
|
| 170 |
+
**Requires:** Admin authentication
|
| 171 |
+
|
| 172 |
+
**Path Parameters:**
|
| 173 |
+
- `participant_id`: Participant ID to delete
|
| 174 |
+
|
| 175 |
+
**Returns:**
|
| 176 |
+
- Success confirmation
|
| 177 |
+
"""
|
| 178 |
+
try:
|
| 179 |
+
participant_service.delete_participant(db, participant_id)
|
| 180 |
+
return SuccessResponse(
|
| 181 |
+
success=True,
|
| 182 |
+
message=f"Participant {participant_id} deleted successfully"
|
| 183 |
+
)
|
| 184 |
+
except EVGException as e:
|
| 185 |
+
logger.error(f"Failed to delete participant {participant_id}: {e.message}")
|
| 186 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
backend/app/routes/points.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Points routes for EVG Ultimate Team API.
|
| 3 |
+
|
| 4 |
+
Handles manual points adjustments and transaction history.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from typing import List
|
| 10 |
+
from app.database import get_db
|
| 11 |
+
from app.schemas.points import (
|
| 12 |
+
PointsAdd,
|
| 13 |
+
PointsSubtract,
|
| 14 |
+
PointsTransactionResponse,
|
| 15 |
+
PointsHistory
|
| 16 |
+
)
|
| 17 |
+
from app.schemas.common import APIResponse
|
| 18 |
+
from app.services import points_service, participant_service
|
| 19 |
+
from app.utils.dependencies import require_admin, get_admin_id
|
| 20 |
+
from app.utils.exceptions import EVGException, format_exception_response
|
| 21 |
+
from app.utils.logger import logger
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/points", tags=["Points"])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.post("/add", response_model=APIResponse[PointsTransactionResponse])
|
| 27 |
+
def add_points(
|
| 28 |
+
points_data: PointsAdd,
|
| 29 |
+
db: Session = Depends(get_db),
|
| 30 |
+
admin = Depends(require_admin)
|
| 31 |
+
):
|
| 32 |
+
"""
|
| 33 |
+
Manually add points to a participant (admin only).
|
| 34 |
+
|
| 35 |
+
**Requires:** Admin authentication
|
| 36 |
+
|
| 37 |
+
**Request Body:**
|
| 38 |
+
- `participant_id`: Participant to add points to
|
| 39 |
+
- `amount`: Points to add (must be positive)
|
| 40 |
+
- `reason`: Reason for adding points
|
| 41 |
+
- `admin_id`: Admin ID making the adjustment
|
| 42 |
+
|
| 43 |
+
**Returns:**
|
| 44 |
+
- Created transaction details
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
transaction = points_service.add_points_to_participant(db, points_data)
|
| 48 |
+
return APIResponse(
|
| 49 |
+
success=True,
|
| 50 |
+
data=transaction,
|
| 51 |
+
message=f"Added {points_data.amount} points"
|
| 52 |
+
)
|
| 53 |
+
except EVGException as e:
|
| 54 |
+
logger.error(f"Failed to add points: {e.message}")
|
| 55 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post("/subtract", response_model=APIResponse[PointsTransactionResponse])
|
| 59 |
+
def subtract_points(
|
| 60 |
+
points_data: PointsSubtract,
|
| 61 |
+
db: Session = Depends(get_db),
|
| 62 |
+
admin = Depends(require_admin)
|
| 63 |
+
):
|
| 64 |
+
"""
|
| 65 |
+
Manually subtract points from a participant (admin only).
|
| 66 |
+
|
| 67 |
+
**Requires:** Admin authentication
|
| 68 |
+
|
| 69 |
+
**Request Body:**
|
| 70 |
+
- `participant_id`: Participant to subtract points from
|
| 71 |
+
- `amount`: Points to subtract (must be positive)
|
| 72 |
+
- `reason`: Reason for subtracting points
|
| 73 |
+
- `admin_id`: Admin ID making the adjustment
|
| 74 |
+
|
| 75 |
+
**Returns:**
|
| 76 |
+
- Created transaction details
|
| 77 |
+
"""
|
| 78 |
+
try:
|
| 79 |
+
transaction = points_service.subtract_points_from_participant(db, points_data)
|
| 80 |
+
return APIResponse(
|
| 81 |
+
success=True,
|
| 82 |
+
data=transaction,
|
| 83 |
+
message=f"Subtracted {points_data.amount} points"
|
| 84 |
+
)
|
| 85 |
+
except EVGException as e:
|
| 86 |
+
logger.error(f"Failed to subtract points: {e.message}")
|
| 87 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/history/{participant_id}", response_model=APIResponse[PointsHistory])
|
| 91 |
+
def get_points_history(
|
| 92 |
+
participant_id: int,
|
| 93 |
+
skip: int = 0,
|
| 94 |
+
limit: int = 100,
|
| 95 |
+
db: Session = Depends(get_db)
|
| 96 |
+
):
|
| 97 |
+
"""
|
| 98 |
+
Get points transaction history for a participant.
|
| 99 |
+
|
| 100 |
+
**Path Parameters:**
|
| 101 |
+
- `participant_id`: Participant ID
|
| 102 |
+
|
| 103 |
+
**Query Parameters:**
|
| 104 |
+
- `skip`: Number of records to skip
|
| 105 |
+
- `limit`: Maximum number of records to return
|
| 106 |
+
|
| 107 |
+
**Returns:**
|
| 108 |
+
- Participant info and their transaction history
|
| 109 |
+
"""
|
| 110 |
+
try:
|
| 111 |
+
participant = participant_service.get_participant_by_id(db, participant_id)
|
| 112 |
+
transactions = points_service.get_participant_transactions(db, participant_id, skip, limit)
|
| 113 |
+
total_transactions = points_service.get_transaction_count(db)
|
| 114 |
+
|
| 115 |
+
history = PointsHistory(
|
| 116 |
+
participant_id=participant.id,
|
| 117 |
+
participant_name=participant.name,
|
| 118 |
+
current_points=participant.total_points,
|
| 119 |
+
transactions=transactions,
|
| 120 |
+
total_transactions=total_transactions
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return APIResponse(
|
| 124 |
+
success=True,
|
| 125 |
+
data=history,
|
| 126 |
+
message=f"Retrieved {len(transactions)} transactions"
|
| 127 |
+
)
|
| 128 |
+
except EVGException as e:
|
| 129 |
+
logger.error(f"Failed to get points history: {e.message}")
|
| 130 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.get("/recent", response_model=APIResponse[List[PointsTransactionResponse]])
|
| 134 |
+
def get_recent_transactions(
|
| 135 |
+
limit: int = 10,
|
| 136 |
+
db: Session = Depends(get_db)
|
| 137 |
+
):
|
| 138 |
+
"""
|
| 139 |
+
Get recent transactions across all participants.
|
| 140 |
+
|
| 141 |
+
Useful for activity feed.
|
| 142 |
+
|
| 143 |
+
**Query Parameters:**
|
| 144 |
+
- `limit`: Maximum number of transactions to return (default: 10)
|
| 145 |
+
|
| 146 |
+
**Returns:**
|
| 147 |
+
- List of recent transactions
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
transactions = points_service.get_recent_transactions(db, limit)
|
| 151 |
+
return APIResponse(
|
| 152 |
+
success=True,
|
| 153 |
+
data=transactions,
|
| 154 |
+
message=f"Retrieved {len(transactions)} recent transactions"
|
| 155 |
+
)
|
| 156 |
+
except EVGException as e:
|
| 157 |
+
logger.error(f"Failed to get recent transactions: {e.message}")
|
| 158 |
+
raise HTTPException(status_code=e.status_code, detail=format_exception_response(e))
|
backend/app/schemas/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for EVG Ultimate Team API.
|
| 3 |
+
|
| 4 |
+
This module exports all request and response schemas.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
# Common schemas
|
| 8 |
+
from app.schemas.common import (
|
| 9 |
+
APIResponse,
|
| 10 |
+
SuccessResponse,
|
| 11 |
+
ErrorResponse,
|
| 12 |
+
PaginationParams,
|
| 13 |
+
PaginatedResponse
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Participant schemas
|
| 17 |
+
from app.schemas.participant import (
|
| 18 |
+
ParticipantCreate,
|
| 19 |
+
ParticipantUpdate,
|
| 20 |
+
ParticipantResponse,
|
| 21 |
+
ParticipantSummary,
|
| 22 |
+
ParticipantWithRank
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Challenge schemas
|
| 26 |
+
from app.schemas.challenge import (
|
| 27 |
+
ChallengeCreate,
|
| 28 |
+
ChallengeUpdate,
|
| 29 |
+
ChallengeAttempt,
|
| 30 |
+
ChallengeValidation,
|
| 31 |
+
ChallengeResponse,
|
| 32 |
+
ChallengeSummary
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Points transaction schemas
|
| 36 |
+
from app.schemas.points import (
|
| 37 |
+
PointsAdd,
|
| 38 |
+
PointsSubtract,
|
| 39 |
+
PointsTransactionResponse,
|
| 40 |
+
PointsTransactionSummary,
|
| 41 |
+
PointsHistory
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Authentication schemas
|
| 45 |
+
from app.schemas.auth import (
|
| 46 |
+
ParticipantLogin,
|
| 47 |
+
AdminLogin,
|
| 48 |
+
AuthToken,
|
| 49 |
+
CurrentUser
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
__all__ = [
|
| 53 |
+
# Common
|
| 54 |
+
"APIResponse",
|
| 55 |
+
"SuccessResponse",
|
| 56 |
+
"ErrorResponse",
|
| 57 |
+
"PaginationParams",
|
| 58 |
+
"PaginatedResponse",
|
| 59 |
+
# Participant
|
| 60 |
+
"ParticipantCreate",
|
| 61 |
+
"ParticipantUpdate",
|
| 62 |
+
"ParticipantResponse",
|
| 63 |
+
"ParticipantSummary",
|
| 64 |
+
"ParticipantWithRank",
|
| 65 |
+
# Challenge
|
| 66 |
+
"ChallengeCreate",
|
| 67 |
+
"ChallengeUpdate",
|
| 68 |
+
"ChallengeAttempt",
|
| 69 |
+
"ChallengeValidation",
|
| 70 |
+
"ChallengeResponse",
|
| 71 |
+
"ChallengeSummary",
|
| 72 |
+
# Points
|
| 73 |
+
"PointsAdd",
|
| 74 |
+
"PointsSubtract",
|
| 75 |
+
"PointsTransactionResponse",
|
| 76 |
+
"PointsTransactionSummary",
|
| 77 |
+
"PointsHistory",
|
| 78 |
+
# Auth
|
| 79 |
+
"ParticipantLogin",
|
| 80 |
+
"AdminLogin",
|
| 81 |
+
"AuthToken",
|
| 82 |
+
"CurrentUser",
|
| 83 |
+
]
|
backend/app/schemas/auth.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for authentication.
|
| 3 |
+
|
| 4 |
+
Defines request and response schemas for authentication-related API endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import Optional
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# =============================================================================
|
| 12 |
+
# Request Schemas
|
| 13 |
+
# =============================================================================
|
| 14 |
+
|
| 15 |
+
class ParticipantLogin(BaseModel):
|
| 16 |
+
"""
|
| 17 |
+
Schema for participant login.
|
| 18 |
+
|
| 19 |
+
Simple username-only authentication (no password required for Phase 1).
|
| 20 |
+
Used in POST /api/auth/login
|
| 21 |
+
"""
|
| 22 |
+
username: str = Field(
|
| 23 |
+
...,
|
| 24 |
+
min_length=1,
|
| 25 |
+
max_length=100,
|
| 26 |
+
description="Participant's name (case-insensitive)"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
class Config:
|
| 30 |
+
"""Pydantic configuration."""
|
| 31 |
+
json_schema_extra = {
|
| 32 |
+
"example": {
|
| 33 |
+
"username": "Paul C."
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class AdminLogin(BaseModel):
|
| 39 |
+
"""
|
| 40 |
+
Schema for admin login.
|
| 41 |
+
|
| 42 |
+
Requires both username and password.
|
| 43 |
+
Used in POST /api/auth/admin-login
|
| 44 |
+
"""
|
| 45 |
+
username: str = Field(
|
| 46 |
+
...,
|
| 47 |
+
min_length=1,
|
| 48 |
+
max_length=100,
|
| 49 |
+
description="Admin username"
|
| 50 |
+
)
|
| 51 |
+
password: str = Field(
|
| 52 |
+
...,
|
| 53 |
+
min_length=1,
|
| 54 |
+
description="Admin password"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
class Config:
|
| 58 |
+
"""Pydantic configuration."""
|
| 59 |
+
json_schema_extra = {
|
| 60 |
+
"example": {
|
| 61 |
+
"username": "clement",
|
| 62 |
+
"password": "evg2026_admin"
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# =============================================================================
|
| 68 |
+
# Response Schemas
|
| 69 |
+
# =============================================================================
|
| 70 |
+
|
| 71 |
+
class AuthToken(BaseModel):
|
| 72 |
+
"""
|
| 73 |
+
Schema for authentication token response.
|
| 74 |
+
|
| 75 |
+
Contains access token and user information.
|
| 76 |
+
"""
|
| 77 |
+
access_token: str = Field(
|
| 78 |
+
...,
|
| 79 |
+
description="JWT access token for authenticated requests"
|
| 80 |
+
)
|
| 81 |
+
token_type: str = Field(
|
| 82 |
+
default="bearer",
|
| 83 |
+
description="Token type (always 'bearer')"
|
| 84 |
+
)
|
| 85 |
+
user_id: Optional[int] = Field(
|
| 86 |
+
None,
|
| 87 |
+
description="User ID (participant ID or admin ID)"
|
| 88 |
+
)
|
| 89 |
+
username: str = Field(
|
| 90 |
+
...,
|
| 91 |
+
description="Username"
|
| 92 |
+
)
|
| 93 |
+
is_admin: bool = Field(
|
| 94 |
+
default=False,
|
| 95 |
+
description="Whether this user is an admin"
|
| 96 |
+
)
|
| 97 |
+
is_groom: bool = Field(
|
| 98 |
+
default=False,
|
| 99 |
+
description="Whether this participant is the groom (not applicable for admins)"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
class Config:
|
| 103 |
+
"""Pydantic configuration."""
|
| 104 |
+
json_schema_extra = {
|
| 105 |
+
"example": {
|
| 106 |
+
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
| 107 |
+
"token_type": "bearer",
|
| 108 |
+
"user_id": 5,
|
| 109 |
+
"username": "Hugo F.",
|
| 110 |
+
"is_admin": False,
|
| 111 |
+
"is_groom": False
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class CurrentUser(BaseModel):
|
| 117 |
+
"""
|
| 118 |
+
Schema for current authenticated user information.
|
| 119 |
+
|
| 120 |
+
Used in GET /api/auth/me
|
| 121 |
+
"""
|
| 122 |
+
id: int = Field(..., description="User ID")
|
| 123 |
+
username: str = Field(..., description="Username")
|
| 124 |
+
is_admin: bool = Field(..., description="Whether this user is an admin")
|
| 125 |
+
is_groom: bool = Field(
|
| 126 |
+
default=False,
|
| 127 |
+
description="Whether this participant is the groom"
|
| 128 |
+
)
|
| 129 |
+
total_points: Optional[int] = Field(
|
| 130 |
+
None,
|
| 131 |
+
description="Total points (only for participants)"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
class Config:
|
| 135 |
+
"""Pydantic configuration."""
|
| 136 |
+
json_schema_extra = {
|
| 137 |
+
"example": {
|
| 138 |
+
"id": 5,
|
| 139 |
+
"username": "Hugo F.",
|
| 140 |
+
"is_admin": False,
|
| 141 |
+
"is_groom": False,
|
| 142 |
+
"total_points": 275
|
| 143 |
+
}
|
| 144 |
+
}
|
backend/app/schemas/challenge.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for Challenge model.
|
| 3 |
+
|
| 4 |
+
Defines request and response schemas for challenge-related API endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from app.models.challenge import ChallengeType, ChallengeStatus
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# =============================================================================
|
| 14 |
+
# Base Schemas
|
| 15 |
+
# =============================================================================
|
| 16 |
+
|
| 17 |
+
class ChallengeBase(BaseModel):
|
| 18 |
+
"""
|
| 19 |
+
Base schema with common challenge fields.
|
| 20 |
+
|
| 21 |
+
Used as a base for other challenge schemas.
|
| 22 |
+
"""
|
| 23 |
+
title: str = Field(
|
| 24 |
+
...,
|
| 25 |
+
min_length=1,
|
| 26 |
+
max_length=200,
|
| 27 |
+
description="Short title of the challenge",
|
| 28 |
+
examples=["Win a 1v1 FIFA match against Paul"]
|
| 29 |
+
)
|
| 30 |
+
description: str = Field(
|
| 31 |
+
...,
|
| 32 |
+
min_length=1,
|
| 33 |
+
max_length=1000,
|
| 34 |
+
description="Detailed description of the challenge"
|
| 35 |
+
)
|
| 36 |
+
type: ChallengeType = Field(
|
| 37 |
+
...,
|
| 38 |
+
description="Type of challenge (individual, team, secret)"
|
| 39 |
+
)
|
| 40 |
+
points: int = Field(
|
| 41 |
+
...,
|
| 42 |
+
ge=1,
|
| 43 |
+
description="Points awarded for completing the challenge",
|
| 44 |
+
examples=[50]
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# =============================================================================
|
| 49 |
+
# Request Schemas
|
| 50 |
+
# =============================================================================
|
| 51 |
+
|
| 52 |
+
class ChallengeCreate(ChallengeBase):
|
| 53 |
+
"""
|
| 54 |
+
Schema for creating a new challenge.
|
| 55 |
+
|
| 56 |
+
Used in POST /api/challenges (admin only)
|
| 57 |
+
"""
|
| 58 |
+
assigned_to: Optional[list[int]] = Field(
|
| 59 |
+
default=None,
|
| 60 |
+
description="List of participant IDs to assign this challenge to"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
class Config:
|
| 64 |
+
"""Pydantic configuration."""
|
| 65 |
+
json_schema_extra = {
|
| 66 |
+
"example": {
|
| 67 |
+
"title": "Win a 1v1 FIFA match against Paul",
|
| 68 |
+
"description": "Challenge Paul to a FIFA match and win. Best of 3 games.",
|
| 69 |
+
"type": "individual",
|
| 70 |
+
"points": 50,
|
| 71 |
+
"assigned_to": [2, 3, 4]
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class ChallengeUpdate(BaseModel):
|
| 77 |
+
"""
|
| 78 |
+
Schema for updating a challenge.
|
| 79 |
+
|
| 80 |
+
All fields are optional to allow partial updates.
|
| 81 |
+
Used in PUT /api/challenges/{id} (admin only)
|
| 82 |
+
"""
|
| 83 |
+
title: Optional[str] = Field(
|
| 84 |
+
None,
|
| 85 |
+
min_length=1,
|
| 86 |
+
max_length=200,
|
| 87 |
+
description="Short title of the challenge"
|
| 88 |
+
)
|
| 89 |
+
description: Optional[str] = Field(
|
| 90 |
+
None,
|
| 91 |
+
min_length=1,
|
| 92 |
+
max_length=1000,
|
| 93 |
+
description="Detailed description of the challenge"
|
| 94 |
+
)
|
| 95 |
+
type: Optional[ChallengeType] = Field(
|
| 96 |
+
None,
|
| 97 |
+
description="Type of challenge (individual, team, secret)"
|
| 98 |
+
)
|
| 99 |
+
points: Optional[int] = Field(
|
| 100 |
+
None,
|
| 101 |
+
ge=1,
|
| 102 |
+
description="Points awarded for completing the challenge"
|
| 103 |
+
)
|
| 104 |
+
status: Optional[ChallengeStatus] = Field(
|
| 105 |
+
None,
|
| 106 |
+
description="Challenge status (pending, active, completed, failed)"
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
class Config:
|
| 110 |
+
"""Pydantic configuration."""
|
| 111 |
+
json_schema_extra = {
|
| 112 |
+
"example": {
|
| 113 |
+
"points": 75,
|
| 114 |
+
"status": "active"
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class ChallengeAttempt(BaseModel):
|
| 120 |
+
"""
|
| 121 |
+
Schema for marking a challenge as being attempted.
|
| 122 |
+
|
| 123 |
+
Used in POST /api/challenges/{id}/attempt
|
| 124 |
+
"""
|
| 125 |
+
participant_id: int = Field(
|
| 126 |
+
...,
|
| 127 |
+
description="ID of participant attempting the challenge"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
class Config:
|
| 131 |
+
"""Pydantic configuration."""
|
| 132 |
+
json_schema_extra = {
|
| 133 |
+
"example": {
|
| 134 |
+
"participant_id": 5
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class ChallengeValidation(BaseModel):
|
| 140 |
+
"""
|
| 141 |
+
Schema for validating a challenge completion.
|
| 142 |
+
|
| 143 |
+
Used in POST /api/challenges/{id}/validate (admin only)
|
| 144 |
+
"""
|
| 145 |
+
participant_ids: list[int] = Field(
|
| 146 |
+
...,
|
| 147 |
+
min_length=1,
|
| 148 |
+
description="List of participant IDs who completed the challenge"
|
| 149 |
+
)
|
| 150 |
+
status: ChallengeStatus = Field(
|
| 151 |
+
...,
|
| 152 |
+
description="Validation result (completed or failed)"
|
| 153 |
+
)
|
| 154 |
+
admin_id: int = Field(
|
| 155 |
+
...,
|
| 156 |
+
description="ID of admin validating the challenge"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
class Config:
|
| 160 |
+
"""Pydantic configuration."""
|
| 161 |
+
json_schema_extra = {
|
| 162 |
+
"example": {
|
| 163 |
+
"participant_ids": [5],
|
| 164 |
+
"status": "completed",
|
| 165 |
+
"admin_id": 1
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# =============================================================================
|
| 171 |
+
# Response Schemas
|
| 172 |
+
# =============================================================================
|
| 173 |
+
|
| 174 |
+
class ChallengeResponse(ChallengeBase):
|
| 175 |
+
"""
|
| 176 |
+
Schema for challenge responses.
|
| 177 |
+
|
| 178 |
+
Includes all challenge data.
|
| 179 |
+
Used in GET requests and as part of other responses.
|
| 180 |
+
"""
|
| 181 |
+
id: int = Field(..., description="Unique challenge ID")
|
| 182 |
+
status: ChallengeStatus = Field(..., description="Current challenge status")
|
| 183 |
+
assigned_to: list[int] = Field(
|
| 184 |
+
...,
|
| 185 |
+
description="List of participant IDs assigned to this challenge"
|
| 186 |
+
)
|
| 187 |
+
completed_by: list[int] = Field(
|
| 188 |
+
...,
|
| 189 |
+
description="List of participant IDs who completed this challenge"
|
| 190 |
+
)
|
| 191 |
+
validated_by: Optional[int] = Field(
|
| 192 |
+
None,
|
| 193 |
+
description="ID of admin who validated the completion"
|
| 194 |
+
)
|
| 195 |
+
created_at: datetime = Field(..., description="Timestamp when challenge was created")
|
| 196 |
+
completed_at: Optional[datetime] = Field(
|
| 197 |
+
None,
|
| 198 |
+
description="Timestamp when challenge was completed"
|
| 199 |
+
)
|
| 200 |
+
updated_at: datetime = Field(..., description="Timestamp when challenge was last updated")
|
| 201 |
+
|
| 202 |
+
class Config:
|
| 203 |
+
"""Pydantic configuration."""
|
| 204 |
+
from_attributes = True
|
| 205 |
+
json_schema_extra = {
|
| 206 |
+
"example": {
|
| 207 |
+
"id": 1,
|
| 208 |
+
"title": "Win a 1v1 FIFA match against Paul",
|
| 209 |
+
"description": "Challenge Paul to a FIFA match and win. Best of 3 games.",
|
| 210 |
+
"type": "individual",
|
| 211 |
+
"points": 50,
|
| 212 |
+
"status": "completed",
|
| 213 |
+
"assigned_to": [2, 3, 4, 5],
|
| 214 |
+
"completed_by": [5],
|
| 215 |
+
"validated_by": 1,
|
| 216 |
+
"created_at": "2026-06-04T18:00:00Z",
|
| 217 |
+
"completed_at": "2026-06-05T16:30:00Z",
|
| 218 |
+
"updated_at": "2026-06-05T16:30:00Z"
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
class ChallengeSummary(BaseModel):
|
| 224 |
+
"""
|
| 225 |
+
Lightweight challenge schema for lists and summaries.
|
| 226 |
+
|
| 227 |
+
Contains only essential fields.
|
| 228 |
+
"""
|
| 229 |
+
id: int
|
| 230 |
+
title: str
|
| 231 |
+
type: ChallengeType
|
| 232 |
+
points: int
|
| 233 |
+
status: ChallengeStatus
|
| 234 |
+
|
| 235 |
+
class Config:
|
| 236 |
+
"""Pydantic configuration."""
|
| 237 |
+
from_attributes = True
|
| 238 |
+
json_schema_extra = {
|
| 239 |
+
"example": {
|
| 240 |
+
"id": 1,
|
| 241 |
+
"title": "Win a 1v1 FIFA match against Paul",
|
| 242 |
+
"type": "individual",
|
| 243 |
+
"points": 50,
|
| 244 |
+
"status": "completed"
|
| 245 |
+
}
|
| 246 |
+
}
|
backend/app/schemas/common.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Common Pydantic schemas for EVG Ultimate Team API.
|
| 3 |
+
|
| 4 |
+
Contains reusable schemas for API responses and common data structures.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from typing import Optional, Any, Generic, TypeVar
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# =============================================================================
|
| 12 |
+
# Generic API Response Schemas
|
| 13 |
+
# =============================================================================
|
| 14 |
+
|
| 15 |
+
DataT = TypeVar('DataT')
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class APIResponse(BaseModel, Generic[DataT]):
|
| 19 |
+
"""
|
| 20 |
+
Standard API response wrapper.
|
| 21 |
+
|
| 22 |
+
All API endpoints should return responses in this format for consistency.
|
| 23 |
+
|
| 24 |
+
Attributes:
|
| 25 |
+
success: Whether the operation was successful
|
| 26 |
+
data: Response data (type varies by endpoint)
|
| 27 |
+
message: Optional message for the user
|
| 28 |
+
error: Optional error message (only present when success=False)
|
| 29 |
+
detail: Optional detailed error information for debugging
|
| 30 |
+
|
| 31 |
+
Example success response:
|
| 32 |
+
{
|
| 33 |
+
"success": true,
|
| 34 |
+
"data": {"id": 1, "name": "Paul"},
|
| 35 |
+
"message": "Participant retrieved successfully"
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
Example error response:
|
| 39 |
+
{
|
| 40 |
+
"success": false,
|
| 41 |
+
"error": "Participant not found",
|
| 42 |
+
"detail": "No participant with ID 999 exists"
|
| 43 |
+
}
|
| 44 |
+
"""
|
| 45 |
+
success: bool
|
| 46 |
+
data: Optional[DataT] = None
|
| 47 |
+
message: Optional[str] = None
|
| 48 |
+
error: Optional[str] = None
|
| 49 |
+
detail: Optional[str] = None
|
| 50 |
+
|
| 51 |
+
class Config:
|
| 52 |
+
"""Pydantic configuration."""
|
| 53 |
+
json_schema_extra = {
|
| 54 |
+
"example": {
|
| 55 |
+
"success": True,
|
| 56 |
+
"data": {"id": 1},
|
| 57 |
+
"message": "Operation completed successfully"
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class SuccessResponse(BaseModel):
|
| 63 |
+
"""
|
| 64 |
+
Simple success response without data.
|
| 65 |
+
|
| 66 |
+
Used for operations that don't return data (e.g., DELETE operations).
|
| 67 |
+
"""
|
| 68 |
+
success: bool = True
|
| 69 |
+
message: str
|
| 70 |
+
|
| 71 |
+
class Config:
|
| 72 |
+
"""Pydantic configuration."""
|
| 73 |
+
json_schema_extra = {
|
| 74 |
+
"example": {
|
| 75 |
+
"success": True,
|
| 76 |
+
"message": "Resource deleted successfully"
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ErrorResponse(BaseModel):
|
| 82 |
+
"""
|
| 83 |
+
Error response schema.
|
| 84 |
+
|
| 85 |
+
Used when an operation fails.
|
| 86 |
+
"""
|
| 87 |
+
success: bool = False
|
| 88 |
+
error: str
|
| 89 |
+
detail: Optional[str] = None
|
| 90 |
+
|
| 91 |
+
class Config:
|
| 92 |
+
"""Pydantic configuration."""
|
| 93 |
+
json_schema_extra = {
|
| 94 |
+
"example": {
|
| 95 |
+
"success": False,
|
| 96 |
+
"error": "Invalid input",
|
| 97 |
+
"detail": "Points must be a positive integer"
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# =============================================================================
|
| 103 |
+
# Pagination Schemas
|
| 104 |
+
# =============================================================================
|
| 105 |
+
|
| 106 |
+
class PaginationParams(BaseModel):
|
| 107 |
+
"""
|
| 108 |
+
Query parameters for pagination.
|
| 109 |
+
|
| 110 |
+
Attributes:
|
| 111 |
+
skip: Number of records to skip (default: 0)
|
| 112 |
+
limit: Maximum number of records to return (default: 100)
|
| 113 |
+
"""
|
| 114 |
+
skip: int = 0
|
| 115 |
+
limit: int = 100
|
| 116 |
+
|
| 117 |
+
class Config:
|
| 118 |
+
"""Pydantic configuration."""
|
| 119 |
+
json_schema_extra = {
|
| 120 |
+
"example": {
|
| 121 |
+
"skip": 0,
|
| 122 |
+
"limit": 50
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class PaginatedResponse(BaseModel, Generic[DataT]):
|
| 128 |
+
"""
|
| 129 |
+
Paginated response wrapper.
|
| 130 |
+
|
| 131 |
+
Used for endpoints that return lists of items with pagination.
|
| 132 |
+
|
| 133 |
+
Attributes:
|
| 134 |
+
success: Whether the operation was successful
|
| 135 |
+
data: List of items
|
| 136 |
+
total: Total number of items available
|
| 137 |
+
skip: Number of items skipped
|
| 138 |
+
limit: Maximum number of items per page
|
| 139 |
+
"""
|
| 140 |
+
success: bool = True
|
| 141 |
+
data: list[DataT]
|
| 142 |
+
total: int
|
| 143 |
+
skip: int
|
| 144 |
+
limit: int
|
| 145 |
+
|
| 146 |
+
class Config:
|
| 147 |
+
"""Pydantic configuration."""
|
| 148 |
+
json_schema_extra = {
|
| 149 |
+
"example": {
|
| 150 |
+
"success": True,
|
| 151 |
+
"data": [{"id": 1}, {"id": 2}],
|
| 152 |
+
"total": 100,
|
| 153 |
+
"skip": 0,
|
| 154 |
+
"limit": 50
|
| 155 |
+
}
|
| 156 |
+
}
|
backend/app/schemas/participant.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for Participant model.
|
| 3 |
+
|
| 4 |
+
Defines request and response schemas for participant-related API endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# =============================================================================
|
| 13 |
+
# Base Schemas
|
| 14 |
+
# =============================================================================
|
| 15 |
+
|
| 16 |
+
class ParticipantBase(BaseModel):
|
| 17 |
+
"""
|
| 18 |
+
Base schema with common participant fields.
|
| 19 |
+
|
| 20 |
+
Used as a base for other participant schemas.
|
| 21 |
+
"""
|
| 22 |
+
name: str = Field(
|
| 23 |
+
...,
|
| 24 |
+
min_length=1,
|
| 25 |
+
max_length=100,
|
| 26 |
+
description="Participant's full name",
|
| 27 |
+
examples=["Paul C."]
|
| 28 |
+
)
|
| 29 |
+
avatar_url: Optional[str] = Field(
|
| 30 |
+
None,
|
| 31 |
+
max_length=500,
|
| 32 |
+
description="URL or path to participant's avatar image"
|
| 33 |
+
)
|
| 34 |
+
is_groom: bool = Field(
|
| 35 |
+
default=False,
|
| 36 |
+
description="Whether this participant is the groom"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# =============================================================================
|
| 41 |
+
# Request Schemas
|
| 42 |
+
# =============================================================================
|
| 43 |
+
|
| 44 |
+
class ParticipantCreate(ParticipantBase):
|
| 45 |
+
"""
|
| 46 |
+
Schema for creating a new participant.
|
| 47 |
+
|
| 48 |
+
Used in POST /api/participants
|
| 49 |
+
"""
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
class Config:
|
| 53 |
+
"""Pydantic configuration."""
|
| 54 |
+
json_schema_extra = {
|
| 55 |
+
"example": {
|
| 56 |
+
"name": "Paul C.",
|
| 57 |
+
"avatar_url": "https://example.com/avatars/paul.jpg",
|
| 58 |
+
"is_groom": True
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ParticipantUpdate(BaseModel):
|
| 64 |
+
"""
|
| 65 |
+
Schema for updating a participant.
|
| 66 |
+
|
| 67 |
+
All fields are optional to allow partial updates.
|
| 68 |
+
Used in PUT /api/participants/{id}
|
| 69 |
+
"""
|
| 70 |
+
name: Optional[str] = Field(
|
| 71 |
+
None,
|
| 72 |
+
min_length=1,
|
| 73 |
+
max_length=100,
|
| 74 |
+
description="Participant's full name"
|
| 75 |
+
)
|
| 76 |
+
avatar_url: Optional[str] = Field(
|
| 77 |
+
None,
|
| 78 |
+
max_length=500,
|
| 79 |
+
description="URL or path to participant's avatar image"
|
| 80 |
+
)
|
| 81 |
+
is_groom: Optional[bool] = Field(
|
| 82 |
+
None,
|
| 83 |
+
description="Whether this participant is the groom"
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
class Config:
|
| 87 |
+
"""Pydantic configuration."""
|
| 88 |
+
json_schema_extra = {
|
| 89 |
+
"example": {
|
| 90 |
+
"avatar_url": "https://example.com/avatars/paul_new.jpg"
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# =============================================================================
|
| 96 |
+
# Response Schemas
|
| 97 |
+
# =============================================================================
|
| 98 |
+
|
| 99 |
+
class ParticipantResponse(ParticipantBase):
|
| 100 |
+
"""
|
| 101 |
+
Schema for participant responses.
|
| 102 |
+
|
| 103 |
+
Includes all participant data including calculated fields.
|
| 104 |
+
Used in GET requests and as part of other responses.
|
| 105 |
+
"""
|
| 106 |
+
id: int = Field(..., description="Unique participant ID")
|
| 107 |
+
total_points: int = Field(..., description="Current total points")
|
| 108 |
+
current_packs: dict = Field(
|
| 109 |
+
...,
|
| 110 |
+
description="Pack counts for each tier (bronze, silver, gold, ultimate)"
|
| 111 |
+
)
|
| 112 |
+
created_at: datetime = Field(..., description="Timestamp when participant was created")
|
| 113 |
+
updated_at: datetime = Field(..., description="Timestamp when participant was last updated")
|
| 114 |
+
|
| 115 |
+
class Config:
|
| 116 |
+
"""Pydantic configuration."""
|
| 117 |
+
from_attributes = True # Allows creation from ORM models
|
| 118 |
+
json_schema_extra = {
|
| 119 |
+
"example": {
|
| 120 |
+
"id": 1,
|
| 121 |
+
"name": "Paul C.",
|
| 122 |
+
"avatar_url": "https://example.com/avatars/paul.jpg",
|
| 123 |
+
"is_groom": True,
|
| 124 |
+
"total_points": 350,
|
| 125 |
+
"current_packs": {
|
| 126 |
+
"bronze": 2,
|
| 127 |
+
"silver": 1,
|
| 128 |
+
"gold": 0,
|
| 129 |
+
"ultimate": 0
|
| 130 |
+
},
|
| 131 |
+
"created_at": "2026-06-04T18:00:00Z",
|
| 132 |
+
"updated_at": "2026-06-05T14:30:00Z"
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class ParticipantSummary(BaseModel):
|
| 138 |
+
"""
|
| 139 |
+
Lightweight participant schema for lists and summaries.
|
| 140 |
+
|
| 141 |
+
Contains only essential fields, used when full details aren't needed.
|
| 142 |
+
"""
|
| 143 |
+
id: int
|
| 144 |
+
name: str
|
| 145 |
+
avatar_url: Optional[str] = None
|
| 146 |
+
is_groom: bool
|
| 147 |
+
total_points: int
|
| 148 |
+
|
| 149 |
+
class Config:
|
| 150 |
+
"""Pydantic configuration."""
|
| 151 |
+
from_attributes = True
|
| 152 |
+
json_schema_extra = {
|
| 153 |
+
"example": {
|
| 154 |
+
"id": 1,
|
| 155 |
+
"name": "Paul C.",
|
| 156 |
+
"avatar_url": "https://example.com/avatars/paul.jpg",
|
| 157 |
+
"is_groom": True,
|
| 158 |
+
"total_points": 350
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class ParticipantWithRank(ParticipantSummary):
|
| 164 |
+
"""
|
| 165 |
+
Participant schema with ranking information.
|
| 166 |
+
|
| 167 |
+
Used for leaderboard displays.
|
| 168 |
+
"""
|
| 169 |
+
rank: int = Field(..., description="Current ranking position (1-based)")
|
| 170 |
+
points_today: Optional[int] = Field(
|
| 171 |
+
None,
|
| 172 |
+
description="Points earned today (optional)"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
class Config:
|
| 176 |
+
"""Pydantic configuration."""
|
| 177 |
+
from_attributes = True
|
| 178 |
+
json_schema_extra = {
|
| 179 |
+
"example": {
|
| 180 |
+
"id": 1,
|
| 181 |
+
"name": "Paul C.",
|
| 182 |
+
"avatar_url": "https://example.com/avatars/paul.jpg",
|
| 183 |
+
"is_groom": True,
|
| 184 |
+
"total_points": 350,
|
| 185 |
+
"rank": 1,
|
| 186 |
+
"points_today": 75
|
| 187 |
+
}
|
| 188 |
+
}
|
backend/app/schemas/points.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for Points Transaction model.
|
| 3 |
+
|
| 4 |
+
Defines request and response schemas for points-related API endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# =============================================================================
|
| 13 |
+
# Request Schemas
|
| 14 |
+
# =============================================================================
|
| 15 |
+
|
| 16 |
+
class PointsAdd(BaseModel):
|
| 17 |
+
"""
|
| 18 |
+
Schema for manually adding points to a participant.
|
| 19 |
+
|
| 20 |
+
Used in POST /api/points/add (admin only)
|
| 21 |
+
"""
|
| 22 |
+
participant_id: int = Field(
|
| 23 |
+
...,
|
| 24 |
+
description="ID of participant to add points to"
|
| 25 |
+
)
|
| 26 |
+
amount: int = Field(
|
| 27 |
+
...,
|
| 28 |
+
gt=0,
|
| 29 |
+
description="Amount of points to add (must be positive)"
|
| 30 |
+
)
|
| 31 |
+
reason: str = Field(
|
| 32 |
+
...,
|
| 33 |
+
min_length=1,
|
| 34 |
+
max_length=500,
|
| 35 |
+
description="Reason for adding points"
|
| 36 |
+
)
|
| 37 |
+
admin_id: int = Field(
|
| 38 |
+
...,
|
| 39 |
+
description="ID of admin adding the points"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
class Config:
|
| 43 |
+
"""Pydantic configuration."""
|
| 44 |
+
json_schema_extra = {
|
| 45 |
+
"example": {
|
| 46 |
+
"participant_id": 5,
|
| 47 |
+
"amount": 100,
|
| 48 |
+
"reason": "Bonus for best costume",
|
| 49 |
+
"admin_id": 1
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class PointsSubtract(BaseModel):
|
| 55 |
+
"""
|
| 56 |
+
Schema for manually subtracting points from a participant.
|
| 57 |
+
|
| 58 |
+
Used in POST /api/points/subtract (admin only)
|
| 59 |
+
"""
|
| 60 |
+
participant_id: int = Field(
|
| 61 |
+
...,
|
| 62 |
+
description="ID of participant to subtract points from"
|
| 63 |
+
)
|
| 64 |
+
amount: int = Field(
|
| 65 |
+
...,
|
| 66 |
+
gt=0,
|
| 67 |
+
description="Amount of points to subtract (must be positive)"
|
| 68 |
+
)
|
| 69 |
+
reason: str = Field(
|
| 70 |
+
...,
|
| 71 |
+
min_length=1,
|
| 72 |
+
max_length=500,
|
| 73 |
+
description="Reason for subtracting points"
|
| 74 |
+
)
|
| 75 |
+
admin_id: int = Field(
|
| 76 |
+
...,
|
| 77 |
+
description="ID of admin subtracting the points"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
class Config:
|
| 81 |
+
"""Pydantic configuration."""
|
| 82 |
+
json_schema_extra = {
|
| 83 |
+
"example": {
|
| 84 |
+
"participant_id": 7,
|
| 85 |
+
"amount": 20,
|
| 86 |
+
"reason": "Penalty for being last to wake up",
|
| 87 |
+
"admin_id": 1
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# =============================================================================
|
| 93 |
+
# Response Schemas
|
| 94 |
+
# =============================================================================
|
| 95 |
+
|
| 96 |
+
class PointsTransactionResponse(BaseModel):
|
| 97 |
+
"""
|
| 98 |
+
Schema for points transaction responses.
|
| 99 |
+
|
| 100 |
+
Includes all transaction data.
|
| 101 |
+
Used in GET requests and as part of other responses.
|
| 102 |
+
"""
|
| 103 |
+
id: int = Field(..., description="Unique transaction ID")
|
| 104 |
+
participant_id: int = Field(..., description="ID of participant")
|
| 105 |
+
amount: int = Field(..., description="Points amount (positive or negative)")
|
| 106 |
+
reason: str = Field(..., description="Reason for the transaction")
|
| 107 |
+
challenge_id: Optional[int] = Field(
|
| 108 |
+
None,
|
| 109 |
+
description="ID of challenge that caused this transaction"
|
| 110 |
+
)
|
| 111 |
+
created_by: Optional[int] = Field(
|
| 112 |
+
None,
|
| 113 |
+
description="ID of admin who created the transaction"
|
| 114 |
+
)
|
| 115 |
+
created_at: datetime = Field(..., description="Timestamp when transaction was created")
|
| 116 |
+
|
| 117 |
+
class Config:
|
| 118 |
+
"""Pydantic configuration."""
|
| 119 |
+
from_attributes = True
|
| 120 |
+
json_schema_extra = {
|
| 121 |
+
"example": {
|
| 122 |
+
"id": 42,
|
| 123 |
+
"participant_id": 5,
|
| 124 |
+
"amount": 50,
|
| 125 |
+
"reason": "Completed challenge (ID: 12)",
|
| 126 |
+
"challenge_id": 12,
|
| 127 |
+
"created_by": 1,
|
| 128 |
+
"created_at": "2026-06-05T16:30:00Z"
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class PointsTransactionSummary(BaseModel):
|
| 134 |
+
"""
|
| 135 |
+
Lightweight transaction schema for lists.
|
| 136 |
+
|
| 137 |
+
Contains only essential fields.
|
| 138 |
+
"""
|
| 139 |
+
id: int
|
| 140 |
+
amount: int
|
| 141 |
+
reason: str
|
| 142 |
+
created_at: datetime
|
| 143 |
+
|
| 144 |
+
class Config:
|
| 145 |
+
"""Pydantic configuration."""
|
| 146 |
+
from_attributes = True
|
| 147 |
+
json_schema_extra = {
|
| 148 |
+
"example": {
|
| 149 |
+
"id": 42,
|
| 150 |
+
"amount": 50,
|
| 151 |
+
"reason": "Completed challenge",
|
| 152 |
+
"created_at": "2026-06-05T16:30:00Z"
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class PointsHistory(BaseModel):
|
| 158 |
+
"""
|
| 159 |
+
Schema for participant's points history.
|
| 160 |
+
|
| 161 |
+
Includes participant info and their transactions.
|
| 162 |
+
"""
|
| 163 |
+
participant_id: int
|
| 164 |
+
participant_name: str
|
| 165 |
+
current_points: int
|
| 166 |
+
transactions: list[PointsTransactionResponse]
|
| 167 |
+
total_transactions: int
|
| 168 |
+
|
| 169 |
+
class Config:
|
| 170 |
+
"""Pydantic configuration."""
|
| 171 |
+
json_schema_extra = {
|
| 172 |
+
"example": {
|
| 173 |
+
"participant_id": 5,
|
| 174 |
+
"participant_name": "Hugo F.",
|
| 175 |
+
"current_points": 275,
|
| 176 |
+
"transactions": [
|
| 177 |
+
{
|
| 178 |
+
"id": 42,
|
| 179 |
+
"participant_id": 5,
|
| 180 |
+
"amount": 50,
|
| 181 |
+
"reason": "Completed challenge (ID: 12)",
|
| 182 |
+
"challenge_id": 12,
|
| 183 |
+
"created_by": 1,
|
| 184 |
+
"created_at": "2026-06-05T16:30:00Z"
|
| 185 |
+
}
|
| 186 |
+
],
|
| 187 |
+
"total_transactions": 8
|
| 188 |
+
}
|
| 189 |
+
}
|
backend/app/services/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Business logic services for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
This module exports all service functions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from app.services import auth_service
|
| 8 |
+
from app.services import participant_service
|
| 9 |
+
from app.services import challenge_service
|
| 10 |
+
from app.services import points_service
|
| 11 |
+
from app.services import leaderboard_service
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"auth_service",
|
| 15 |
+
"participant_service",
|
| 16 |
+
"challenge_service",
|
| 17 |
+
"points_service",
|
| 18 |
+
"leaderboard_service",
|
| 19 |
+
]
|
backend/app/services/auth_service.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication service for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Handles participant and admin authentication logic.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from app.models import Participant
|
| 10 |
+
from app.schemas.auth import ParticipantLogin, AdminLogin, AuthToken
|
| 11 |
+
from app.utils.security import (
|
| 12 |
+
verify_admin_credentials,
|
| 13 |
+
create_access_token,
|
| 14 |
+
create_participant_token_data,
|
| 15 |
+
create_admin_token_data
|
| 16 |
+
)
|
| 17 |
+
from app.utils.exceptions import InvalidCredentialsError
|
| 18 |
+
from app.utils.logger import log_auth_attempt
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# Participant Authentication
|
| 23 |
+
# =============================================================================
|
| 24 |
+
|
| 25 |
+
def authenticate_participant(
|
| 26 |
+
db: Session,
|
| 27 |
+
login_data: ParticipantLogin
|
| 28 |
+
) -> AuthToken:
|
| 29 |
+
"""
|
| 30 |
+
Authenticate a participant using simple username-only login.
|
| 31 |
+
|
| 32 |
+
For Phase 1, we use simple username matching (case-insensitive).
|
| 33 |
+
No password is required for participants.
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
db: Database session
|
| 37 |
+
login_data: Login request data with username
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
AuthToken with access token and user information
|
| 41 |
+
|
| 42 |
+
Raises:
|
| 43 |
+
InvalidCredentialsError: If participant not found
|
| 44 |
+
|
| 45 |
+
Example:
|
| 46 |
+
>>> login = ParticipantLogin(username="Paul C.")
|
| 47 |
+
>>> token = authenticate_participant(db, login)
|
| 48 |
+
>>> print(token.access_token)
|
| 49 |
+
"""
|
| 50 |
+
# Find participant by name (case-insensitive)
|
| 51 |
+
participant = db.query(Participant).filter(
|
| 52 |
+
Participant.name.ilike(login_data.username)
|
| 53 |
+
).first()
|
| 54 |
+
|
| 55 |
+
if not participant:
|
| 56 |
+
log_auth_attempt(login_data.username, success=False, is_admin=False)
|
| 57 |
+
raise InvalidCredentialsError(
|
| 58 |
+
detail=f"No participant found with username '{login_data.username}'"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Create token data
|
| 62 |
+
token_data = create_participant_token_data(
|
| 63 |
+
participant_id=participant.id,
|
| 64 |
+
username=participant.name,
|
| 65 |
+
is_groom=participant.is_groom
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
# Generate access token
|
| 69 |
+
access_token = create_access_token(token_data)
|
| 70 |
+
|
| 71 |
+
# Log successful authentication
|
| 72 |
+
log_auth_attempt(participant.name, success=True, is_admin=False)
|
| 73 |
+
|
| 74 |
+
return AuthToken(
|
| 75 |
+
access_token=access_token,
|
| 76 |
+
token_type="bearer",
|
| 77 |
+
user_id=participant.id,
|
| 78 |
+
username=participant.name,
|
| 79 |
+
is_admin=False,
|
| 80 |
+
is_groom=participant.is_groom
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# =============================================================================
|
| 85 |
+
# Admin Authentication
|
| 86 |
+
# =============================================================================
|
| 87 |
+
|
| 88 |
+
def authenticate_admin(
|
| 89 |
+
login_data: AdminLogin
|
| 90 |
+
) -> AuthToken:
|
| 91 |
+
"""
|
| 92 |
+
Authenticate an admin using username and password.
|
| 93 |
+
|
| 94 |
+
Verifies credentials against environment configuration.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
login_data: Login request data with username and password
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
AuthToken with access token and admin information
|
| 101 |
+
|
| 102 |
+
Raises:
|
| 103 |
+
InvalidCredentialsError: If credentials are invalid
|
| 104 |
+
|
| 105 |
+
Example:
|
| 106 |
+
>>> login = AdminLogin(username="clement", password="evg2026_admin")
|
| 107 |
+
>>> token = authenticate_admin(login)
|
| 108 |
+
>>> print(token.is_admin)
|
| 109 |
+
True
|
| 110 |
+
"""
|
| 111 |
+
# Verify admin credentials
|
| 112 |
+
if not verify_admin_credentials(login_data.username, login_data.password):
|
| 113 |
+
log_auth_attempt(login_data.username, success=False, is_admin=True)
|
| 114 |
+
raise InvalidCredentialsError(
|
| 115 |
+
detail="Invalid admin username or password"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# Create token data for admin
|
| 119 |
+
# Admin ID is 0 to distinguish from participant IDs
|
| 120 |
+
token_data = create_admin_token_data(
|
| 121 |
+
admin_id=0,
|
| 122 |
+
username=login_data.username
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Generate access token
|
| 126 |
+
access_token = create_access_token(token_data)
|
| 127 |
+
|
| 128 |
+
# Log successful authentication
|
| 129 |
+
log_auth_attempt(login_data.username, success=True, is_admin=True)
|
| 130 |
+
|
| 131 |
+
return AuthToken(
|
| 132 |
+
access_token=access_token,
|
| 133 |
+
token_type="bearer",
|
| 134 |
+
user_id=0,
|
| 135 |
+
username=login_data.username,
|
| 136 |
+
is_admin=True,
|
| 137 |
+
is_groom=False
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# =============================================================================
|
| 142 |
+
# Token Validation
|
| 143 |
+
# =============================================================================
|
| 144 |
+
|
| 145 |
+
def get_participant_by_id(db: Session, participant_id: int) -> Optional[Participant]:
|
| 146 |
+
"""
|
| 147 |
+
Get a participant by ID.
|
| 148 |
+
|
| 149 |
+
Helper function used by authentication middleware.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
db: Database session
|
| 153 |
+
participant_id: Participant ID from token
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
Participant instance or None if not found
|
| 157 |
+
|
| 158 |
+
Example:
|
| 159 |
+
>>> participant = get_participant_by_id(db, 5)
|
| 160 |
+
>>> if participant:
|
| 161 |
+
>>> print(participant.name)
|
| 162 |
+
"""
|
| 163 |
+
return db.query(Participant).filter(Participant.id == participant_id).first()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# =============================================================================
|
| 167 |
+
# Username Availability
|
| 168 |
+
# =============================================================================
|
| 169 |
+
|
| 170 |
+
def is_username_available(db: Session, username: str) -> bool:
|
| 171 |
+
"""
|
| 172 |
+
Check if a username is available (not taken by another participant).
|
| 173 |
+
|
| 174 |
+
Useful for participant registration (not used in Phase 1).
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
db: Database session
|
| 178 |
+
username: Username to check
|
| 179 |
+
|
| 180 |
+
Returns:
|
| 181 |
+
True if available, False if taken
|
| 182 |
+
|
| 183 |
+
Example:
|
| 184 |
+
>>> available = is_username_available(db, "New Participant")
|
| 185 |
+
>>> if available:
|
| 186 |
+
>>> # Create new participant
|
| 187 |
+
>>> pass
|
| 188 |
+
"""
|
| 189 |
+
existing = db.query(Participant).filter(
|
| 190 |
+
Participant.name.ilike(username)
|
| 191 |
+
).first()
|
| 192 |
+
return existing is None
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# =============================================================================
|
| 196 |
+
# Logout (Client-Side Only)
|
| 197 |
+
# =============================================================================
|
| 198 |
+
|
| 199 |
+
# Note: In JWT-based authentication, logout is typically handled client-side
|
| 200 |
+
# by removing the token from storage. The server doesn't need to track active tokens.
|
| 201 |
+
# For enhanced security in production, you could implement token blacklisting,
|
| 202 |
+
# but it's not necessary for this MVP.
|
backend/app/services/challenge_service.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Challenge service for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Handles all business logic related to challenges.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from app.models import Challenge, Participant, ChallengeStatus, ChallengeType
|
| 11 |
+
from app.schemas.challenge import ChallengeCreate, ChallengeUpdate, ChallengeValidation
|
| 12 |
+
from app.utils.exceptions import (
|
| 13 |
+
ChallengeNotFoundError,
|
| 14 |
+
ParticipantNotFoundError,
|
| 15 |
+
InvalidChallengeStatusError,
|
| 16 |
+
ChallengeAlreadyCompletedError
|
| 17 |
+
)
|
| 18 |
+
from app.utils.logger import logger, log_challenge_validation
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_all_challenges(db: Session, skip: int = 0, limit: int = 100) -> List[Challenge]:
|
| 22 |
+
"""Get all challenges with pagination."""
|
| 23 |
+
return db.query(Challenge).offset(skip).limit(limit).all()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_challenge_by_id(db: Session, challenge_id: int) -> Challenge:
|
| 27 |
+
"""Get a challenge by ID."""
|
| 28 |
+
challenge = db.query(Challenge).filter(Challenge.id == challenge_id).first()
|
| 29 |
+
if not challenge:
|
| 30 |
+
raise ChallengeNotFoundError(challenge_id)
|
| 31 |
+
return challenge
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_challenges_by_status(db: Session, status: ChallengeStatus) -> List[Challenge]:
|
| 35 |
+
"""Get all challenges with a specific status."""
|
| 36 |
+
return db.query(Challenge).filter(Challenge.status == status).all()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_challenges_by_type(db: Session, challenge_type: ChallengeType) -> List[Challenge]:
|
| 40 |
+
"""Get all challenges of a specific type."""
|
| 41 |
+
return db.query(Challenge).filter(Challenge.type == challenge_type).all()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def create_challenge(db: Session, challenge_data: ChallengeCreate, admin_id: int) -> Challenge:
|
| 45 |
+
"""Create a new challenge."""
|
| 46 |
+
challenge = Challenge(
|
| 47 |
+
title=challenge_data.title,
|
| 48 |
+
description=challenge_data.description,
|
| 49 |
+
type=challenge_data.type,
|
| 50 |
+
points=challenge_data.points,
|
| 51 |
+
status=ChallengeStatus.PENDING
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
db.add(challenge)
|
| 55 |
+
db.flush() # Get the challenge ID without committing
|
| 56 |
+
|
| 57 |
+
# Assign to participants if specified
|
| 58 |
+
if challenge_data.assigned_to:
|
| 59 |
+
for participant_id in challenge_data.assigned_to:
|
| 60 |
+
participant = db.query(Participant).filter(Participant.id == participant_id).first()
|
| 61 |
+
if participant:
|
| 62 |
+
challenge.assigned_participants.append(participant)
|
| 63 |
+
|
| 64 |
+
db.commit()
|
| 65 |
+
db.refresh(challenge)
|
| 66 |
+
|
| 67 |
+
logger.info(
|
| 68 |
+
f"Created challenge: {challenge.title}",
|
| 69 |
+
extra={
|
| 70 |
+
"challenge_id": challenge.id,
|
| 71 |
+
"type": challenge.type.value,
|
| 72 |
+
"points": challenge.points,
|
| 73 |
+
"admin_id": admin_id
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
return challenge
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def update_challenge(db: Session, challenge_id: int, challenge_data: ChallengeUpdate, admin_id: int) -> Challenge:
|
| 81 |
+
"""Update a challenge."""
|
| 82 |
+
challenge = get_challenge_by_id(db, challenge_id)
|
| 83 |
+
|
| 84 |
+
update_data = challenge_data.model_dump(exclude_unset=True)
|
| 85 |
+
for field, value in update_data.items():
|
| 86 |
+
setattr(challenge, field, value)
|
| 87 |
+
|
| 88 |
+
db.commit()
|
| 89 |
+
db.refresh(challenge)
|
| 90 |
+
|
| 91 |
+
logger.info(
|
| 92 |
+
f"Updated challenge: {challenge.title}",
|
| 93 |
+
extra={"challenge_id": challenge.id, "admin_id": admin_id}
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return challenge
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def delete_challenge(db: Session, challenge_id: int, admin_id: int) -> None:
|
| 100 |
+
"""Delete a challenge."""
|
| 101 |
+
challenge = get_challenge_by_id(db, challenge_id)
|
| 102 |
+
|
| 103 |
+
db.delete(challenge)
|
| 104 |
+
db.commit()
|
| 105 |
+
|
| 106 |
+
logger.warning(
|
| 107 |
+
f"Deleted challenge: {challenge.title}",
|
| 108 |
+
extra={"challenge_id": challenge_id, "admin_id": admin_id}
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def assign_challenge_to_participant(db: Session, challenge_id: int, participant_id: int) -> Challenge:
|
| 113 |
+
"""Assign a challenge to a participant."""
|
| 114 |
+
challenge = get_challenge_by_id(db, challenge_id)
|
| 115 |
+
participant = db.query(Participant).filter(Participant.id == participant_id).first()
|
| 116 |
+
|
| 117 |
+
if not participant:
|
| 118 |
+
raise ParticipantNotFoundError(participant_id)
|
| 119 |
+
|
| 120 |
+
if participant not in challenge.assigned_participants:
|
| 121 |
+
challenge.assigned_participants.append(participant)
|
| 122 |
+
db.commit()
|
| 123 |
+
db.refresh(challenge)
|
| 124 |
+
|
| 125 |
+
logger.info(
|
| 126 |
+
f"Assigned challenge to participant",
|
| 127 |
+
extra={
|
| 128 |
+
"challenge_id": challenge_id,
|
| 129 |
+
"participant_id": participant_id
|
| 130 |
+
}
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
return challenge
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def mark_challenge_active(db: Session, challenge_id: int, participant_id: int) -> Challenge:
|
| 137 |
+
"""Mark a challenge as active (being attempted)."""
|
| 138 |
+
challenge = get_challenge_by_id(db, challenge_id)
|
| 139 |
+
|
| 140 |
+
if challenge.status in [ChallengeStatus.COMPLETED, ChallengeStatus.FAILED]:
|
| 141 |
+
raise InvalidChallengeStatusError(
|
| 142 |
+
current_status=challenge.status.value,
|
| 143 |
+
attempted_status="active"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
challenge.status = ChallengeStatus.ACTIVE
|
| 147 |
+
db.commit()
|
| 148 |
+
db.refresh(challenge)
|
| 149 |
+
|
| 150 |
+
logger.info(
|
| 151 |
+
f"Challenge marked as active",
|
| 152 |
+
extra={
|
| 153 |
+
"challenge_id": challenge_id,
|
| 154 |
+
"participant_id": participant_id
|
| 155 |
+
}
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
return challenge
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def validate_challenge_completion(
|
| 162 |
+
db: Session,
|
| 163 |
+
challenge_id: int,
|
| 164 |
+
validation_data: ChallengeValidation
|
| 165 |
+
) -> Challenge:
|
| 166 |
+
"""
|
| 167 |
+
Validate challenge completion and award points.
|
| 168 |
+
|
| 169 |
+
This is called by admin to confirm a participant completed a challenge.
|
| 170 |
+
Points are awarded in the points_service.
|
| 171 |
+
"""
|
| 172 |
+
challenge = get_challenge_by_id(db, challenge_id)
|
| 173 |
+
|
| 174 |
+
if challenge.status == ChallengeStatus.COMPLETED:
|
| 175 |
+
raise ChallengeAlreadyCompletedError(challenge_id)
|
| 176 |
+
|
| 177 |
+
# Update challenge status
|
| 178 |
+
challenge.status = validation_data.status
|
| 179 |
+
challenge.validated_by = validation_data.admin_id
|
| 180 |
+
|
| 181 |
+
if validation_data.status == ChallengeStatus.COMPLETED:
|
| 182 |
+
challenge.completed_at = datetime.utcnow()
|
| 183 |
+
|
| 184 |
+
# Mark participants as having completed the challenge
|
| 185 |
+
for participant_id in validation_data.participant_ids:
|
| 186 |
+
participant = db.query(Participant).filter(Participant.id == participant_id).first()
|
| 187 |
+
if participant:
|
| 188 |
+
if participant not in challenge.completed_by_participants:
|
| 189 |
+
challenge.completed_by_participants.append(participant)
|
| 190 |
+
|
| 191 |
+
db.commit()
|
| 192 |
+
db.refresh(challenge)
|
| 193 |
+
|
| 194 |
+
log_challenge_validation(
|
| 195 |
+
challenge_id=challenge_id,
|
| 196 |
+
participant_ids=validation_data.participant_ids,
|
| 197 |
+
validated_by=validation_data.admin_id,
|
| 198 |
+
status=validation_data.status.value
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
return challenge
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def get_participant_challenges(db: Session, participant_id: int) -> dict:
|
| 205 |
+
"""Get all challenges for a participant, organized by status."""
|
| 206 |
+
participant = db.query(Participant).filter(Participant.id == participant_id).first()
|
| 207 |
+
|
| 208 |
+
if not participant:
|
| 209 |
+
raise ParticipantNotFoundError(participant_id)
|
| 210 |
+
|
| 211 |
+
return {
|
| 212 |
+
"assigned": list(participant.assigned_challenges.all()),
|
| 213 |
+
"completed": list(participant.completed_challenges.all())
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def get_challenge_count_by_status(db: Session) -> dict:
|
| 218 |
+
"""Get count of challenges by status."""
|
| 219 |
+
return {
|
| 220 |
+
"pending": db.query(Challenge).filter(Challenge.status == ChallengeStatus.PENDING).count(),
|
| 221 |
+
"active": db.query(Challenge).filter(Challenge.status == ChallengeStatus.ACTIVE).count(),
|
| 222 |
+
"completed": db.query(Challenge).filter(Challenge.status == ChallengeStatus.COMPLETED).count(),
|
| 223 |
+
"failed": db.query(Challenge).filter(Challenge.status == ChallengeStatus.FAILED).count(),
|
| 224 |
+
}
|
backend/app/services/leaderboard_service.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Leaderboard service for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Handles all business logic related to leaderboard and rankings.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from typing import List
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from app.models import Participant
|
| 11 |
+
from app.schemas.participant import ParticipantWithRank
|
| 12 |
+
from app.services.points_service import get_participant_points_today
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_leaderboard(db: Session, include_today_points: bool = False) -> List[ParticipantWithRank]:
|
| 16 |
+
"""
|
| 17 |
+
Get the current leaderboard with all participants ranked by points.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
db: Database session
|
| 21 |
+
include_today_points: Whether to include points earned today
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
List of ParticipantWithRank instances, ordered by rank
|
| 25 |
+
|
| 26 |
+
Example:
|
| 27 |
+
>>> leaderboard = get_leaderboard(db, include_today_points=True)
|
| 28 |
+
>>> for entry in leaderboard:
|
| 29 |
+
>>> print(f"{entry.rank}. {entry.name} - {entry.total_points} pts")
|
| 30 |
+
"""
|
| 31 |
+
# Get all participants ordered by points (descending)
|
| 32 |
+
participants = db.query(Participant).order_by(
|
| 33 |
+
Participant.total_points.desc()
|
| 34 |
+
).all()
|
| 35 |
+
|
| 36 |
+
# Build leaderboard with rankings
|
| 37 |
+
leaderboard = []
|
| 38 |
+
for rank, participant in enumerate(participants, start=1):
|
| 39 |
+
# Get today's points if requested
|
| 40 |
+
points_today = None
|
| 41 |
+
if include_today_points:
|
| 42 |
+
points_today = get_participant_points_today(db, participant.id)
|
| 43 |
+
|
| 44 |
+
leaderboard_entry = ParticipantWithRank(
|
| 45 |
+
id=participant.id,
|
| 46 |
+
name=participant.name,
|
| 47 |
+
avatar_url=participant.avatar_url,
|
| 48 |
+
is_groom=participant.is_groom,
|
| 49 |
+
total_points=participant.total_points,
|
| 50 |
+
rank=rank,
|
| 51 |
+
points_today=points_today
|
| 52 |
+
)
|
| 53 |
+
leaderboard.append(leaderboard_entry)
|
| 54 |
+
|
| 55 |
+
return leaderboard
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def get_top_3(db: Session) -> List[ParticipantWithRank]:
|
| 59 |
+
"""
|
| 60 |
+
Get the top 3 participants (podium).
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
db: Database session
|
| 64 |
+
|
| 65 |
+
Returns:
|
| 66 |
+
List of top 3 ParticipantWithRank instances
|
| 67 |
+
|
| 68 |
+
Example:
|
| 69 |
+
>>> podium = get_top_3(db)
|
| 70 |
+
>>> print(f"Winner: {podium[0].name}")
|
| 71 |
+
"""
|
| 72 |
+
leaderboard = get_leaderboard(db, include_today_points=False)
|
| 73 |
+
return leaderboard[:3]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_participant_rank(db: Session, participant_id: int) -> int:
|
| 77 |
+
"""
|
| 78 |
+
Get the current rank of a specific participant.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
db: Database session
|
| 82 |
+
participant_id: Participant ID
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Current rank (1-based)
|
| 86 |
+
|
| 87 |
+
Raises:
|
| 88 |
+
ParticipantNotFoundError: If participant not found
|
| 89 |
+
|
| 90 |
+
Example:
|
| 91 |
+
>>> rank = get_participant_rank(db, 5)
|
| 92 |
+
>>> print(f"You are ranked #{rank}")
|
| 93 |
+
"""
|
| 94 |
+
from app.utils.exceptions import ParticipantNotFoundError
|
| 95 |
+
|
| 96 |
+
participant = db.query(Participant).filter(
|
| 97 |
+
Participant.id == participant_id
|
| 98 |
+
).first()
|
| 99 |
+
|
| 100 |
+
if not participant:
|
| 101 |
+
raise ParticipantNotFoundError(participant_id)
|
| 102 |
+
|
| 103 |
+
# Count how many participants have more points
|
| 104 |
+
higher_ranked = db.query(Participant).filter(
|
| 105 |
+
Participant.total_points > participant.total_points
|
| 106 |
+
).count()
|
| 107 |
+
|
| 108 |
+
# Rank is number of higher ranked participants + 1
|
| 109 |
+
return higher_ranked + 1
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def get_daily_leader(db: Session) -> ParticipantWithRank:
|
| 113 |
+
"""
|
| 114 |
+
Get the current daily leader (participant with most points today).
|
| 115 |
+
|
| 116 |
+
According to specs, the daily leader chooses the next day's aperitif theme.
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
db: Database session
|
| 120 |
+
|
| 121 |
+
Returns:
|
| 122 |
+
ParticipantWithRank instance of daily leader
|
| 123 |
+
|
| 124 |
+
Example:
|
| 125 |
+
>>> daily_leader = get_daily_leader(db)
|
| 126 |
+
>>> print(f"Today's leader: {daily_leader.name}")
|
| 127 |
+
"""
|
| 128 |
+
# Get all participants
|
| 129 |
+
participants = db.query(Participant).all()
|
| 130 |
+
|
| 131 |
+
# Calculate today's points for each participant
|
| 132 |
+
daily_scores = []
|
| 133 |
+
for participant in participants:
|
| 134 |
+
points_today = get_participant_points_today(db, participant.id)
|
| 135 |
+
daily_scores.append({
|
| 136 |
+
"participant": participant,
|
| 137 |
+
"points_today": points_today
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
# Sort by points today (descending)
|
| 141 |
+
daily_scores.sort(key=lambda x: x["points_today"], reverse=True)
|
| 142 |
+
|
| 143 |
+
# Get the leader
|
| 144 |
+
if not daily_scores:
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
leader_data = daily_scores[0]
|
| 148 |
+
participant = leader_data["participant"]
|
| 149 |
+
|
| 150 |
+
return ParticipantWithRank(
|
| 151 |
+
id=participant.id,
|
| 152 |
+
name=participant.name,
|
| 153 |
+
avatar_url=participant.avatar_url,
|
| 154 |
+
is_groom=participant.is_groom,
|
| 155 |
+
total_points=participant.total_points,
|
| 156 |
+
rank=get_participant_rank(db, participant.id),
|
| 157 |
+
points_today=leader_data["points_today"]
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def get_leaderboard_stats(db: Session) -> dict:
|
| 162 |
+
"""
|
| 163 |
+
Get statistics about the leaderboard.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
db: Database session
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Dictionary with leaderboard statistics
|
| 170 |
+
|
| 171 |
+
Example:
|
| 172 |
+
>>> stats = get_leaderboard_stats(db)
|
| 173 |
+
>>> print(f"Average points: {stats['average_points']}")
|
| 174 |
+
"""
|
| 175 |
+
participants = db.query(Participant).all()
|
| 176 |
+
|
| 177 |
+
if not participants:
|
| 178 |
+
return {
|
| 179 |
+
"total_participants": 0,
|
| 180 |
+
"average_points": 0,
|
| 181 |
+
"highest_points": 0,
|
| 182 |
+
"lowest_points": 0,
|
| 183 |
+
"total_points_distributed": 0
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
points_list = [p.total_points for p in participants]
|
| 187 |
+
|
| 188 |
+
from app.services.points_service import get_total_points_distributed
|
| 189 |
+
|
| 190 |
+
return {
|
| 191 |
+
"total_participants": len(participants),
|
| 192 |
+
"average_points": sum(points_list) / len(points_list),
|
| 193 |
+
"highest_points": max(points_list),
|
| 194 |
+
"lowest_points": min(points_list),
|
| 195 |
+
"total_points_distributed": get_total_points_distributed(db)
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def get_participant_position_change(
|
| 200 |
+
db: Session,
|
| 201 |
+
participant_id: int,
|
| 202 |
+
previous_leaderboard: List[ParticipantWithRank]
|
| 203 |
+
) -> int:
|
| 204 |
+
"""
|
| 205 |
+
Calculate how much a participant's rank changed since the last leaderboard.
|
| 206 |
+
|
| 207 |
+
Useful for showing "up 2 positions" or "down 1 position" in the UI.
|
| 208 |
+
|
| 209 |
+
Args:
|
| 210 |
+
db: Database session
|
| 211 |
+
participant_id: Participant ID
|
| 212 |
+
previous_leaderboard: Previous leaderboard state
|
| 213 |
+
|
| 214 |
+
Returns:
|
| 215 |
+
Position change (positive = moved up, negative = moved down, 0 = no change)
|
| 216 |
+
|
| 217 |
+
Example:
|
| 218 |
+
>>> change = get_participant_position_change(db, 5, old_leaderboard)
|
| 219 |
+
>>> if change > 0:
|
| 220 |
+
>>> print(f"Moved up {change} positions!")
|
| 221 |
+
"""
|
| 222 |
+
# Get current rank
|
| 223 |
+
current_rank = get_participant_rank(db, participant_id)
|
| 224 |
+
|
| 225 |
+
# Find previous rank
|
| 226 |
+
previous_rank = None
|
| 227 |
+
for entry in previous_leaderboard:
|
| 228 |
+
if entry.id == participant_id:
|
| 229 |
+
previous_rank = entry.rank
|
| 230 |
+
break
|
| 231 |
+
|
| 232 |
+
if previous_rank is None:
|
| 233 |
+
return 0 # Participant wasn't in previous leaderboard
|
| 234 |
+
|
| 235 |
+
# Calculate change (note: lower rank number = better position)
|
| 236 |
+
# So if rank went from 5 to 3, that's +2 positions (improvement)
|
| 237 |
+
return previous_rank - current_rank
|
backend/app/services/participant_service.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Participant service for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Handles all business logic related to participants.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from app.models import Participant
|
| 10 |
+
from app.schemas.participant import ParticipantCreate, ParticipantUpdate
|
| 11 |
+
from app.utils.exceptions import ParticipantNotFoundError, DuplicateEntryError
|
| 12 |
+
from app.utils.logger import logger
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# =============================================================================
|
| 16 |
+
# CRUD Operations
|
| 17 |
+
# =============================================================================
|
| 18 |
+
|
| 19 |
+
def get_all_participants(
|
| 20 |
+
db: Session,
|
| 21 |
+
skip: int = 0,
|
| 22 |
+
limit: int = 100
|
| 23 |
+
) -> List[Participant]:
|
| 24 |
+
"""
|
| 25 |
+
Get all participants with pagination.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
db: Database session
|
| 29 |
+
skip: Number of records to skip
|
| 30 |
+
limit: Maximum number of records to return
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
List of Participant instances
|
| 34 |
+
|
| 35 |
+
Example:
|
| 36 |
+
>>> participants = get_all_participants(db, skip=0, limit=50)
|
| 37 |
+
>>> for p in participants:
|
| 38 |
+
>>> print(p.name, p.total_points)
|
| 39 |
+
"""
|
| 40 |
+
return db.query(Participant).offset(skip).limit(limit).all()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_participant_by_id(
|
| 44 |
+
db: Session,
|
| 45 |
+
participant_id: int
|
| 46 |
+
) -> Participant:
|
| 47 |
+
"""
|
| 48 |
+
Get a participant by ID.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
db: Database session
|
| 52 |
+
participant_id: Participant ID
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Participant instance
|
| 56 |
+
|
| 57 |
+
Raises:
|
| 58 |
+
ParticipantNotFoundError: If participant not found
|
| 59 |
+
|
| 60 |
+
Example:
|
| 61 |
+
>>> participant = get_participant_by_id(db, 5)
|
| 62 |
+
>>> print(participant.name)
|
| 63 |
+
"""
|
| 64 |
+
participant = db.query(Participant).filter(
|
| 65 |
+
Participant.id == participant_id
|
| 66 |
+
).first()
|
| 67 |
+
|
| 68 |
+
if not participant:
|
| 69 |
+
raise ParticipantNotFoundError(participant_id)
|
| 70 |
+
|
| 71 |
+
return participant
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def get_participant_by_name(
|
| 75 |
+
db: Session,
|
| 76 |
+
name: str
|
| 77 |
+
) -> Optional[Participant]:
|
| 78 |
+
"""
|
| 79 |
+
Get a participant by name (case-insensitive).
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
db: Database session
|
| 83 |
+
name: Participant name
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Participant instance or None if not found
|
| 87 |
+
|
| 88 |
+
Example:
|
| 89 |
+
>>> participant = get_participant_by_name(db, "Paul C.")
|
| 90 |
+
>>> if participant:
|
| 91 |
+
>>> print(participant.id)
|
| 92 |
+
"""
|
| 93 |
+
return db.query(Participant).filter(
|
| 94 |
+
Participant.name.ilike(name)
|
| 95 |
+
).first()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def create_participant(
|
| 99 |
+
db: Session,
|
| 100 |
+
participant_data: ParticipantCreate
|
| 101 |
+
) -> Participant:
|
| 102 |
+
"""
|
| 103 |
+
Create a new participant.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
db: Database session
|
| 107 |
+
participant_data: Participant creation data
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
Created Participant instance
|
| 111 |
+
|
| 112 |
+
Raises:
|
| 113 |
+
DuplicateEntryError: If participant with same name already exists
|
| 114 |
+
|
| 115 |
+
Example:
|
| 116 |
+
>>> data = ParticipantCreate(name="New Participant", is_groom=False)
|
| 117 |
+
>>> participant = create_participant(db, data)
|
| 118 |
+
>>> print(participant.id)
|
| 119 |
+
"""
|
| 120 |
+
# Check if participant with same name already exists
|
| 121 |
+
existing = get_participant_by_name(db, participant_data.name)
|
| 122 |
+
if existing:
|
| 123 |
+
raise DuplicateEntryError(
|
| 124 |
+
resource_type="Participant",
|
| 125 |
+
field="name",
|
| 126 |
+
value=participant_data.name
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
# Create new participant
|
| 130 |
+
participant = Participant(
|
| 131 |
+
name=participant_data.name,
|
| 132 |
+
avatar_url=participant_data.avatar_url,
|
| 133 |
+
is_groom=participant_data.is_groom,
|
| 134 |
+
total_points=0,
|
| 135 |
+
current_packs={"bronze": 0, "silver": 0, "gold": 0, "ultimate": 0}
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
db.add(participant)
|
| 139 |
+
db.commit()
|
| 140 |
+
db.refresh(participant)
|
| 141 |
+
|
| 142 |
+
logger.info(
|
| 143 |
+
f"Created participant: {participant.name}",
|
| 144 |
+
extra={"participant_id": participant.id, "is_groom": participant.is_groom}
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
return participant
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def update_participant(
|
| 151 |
+
db: Session,
|
| 152 |
+
participant_id: int,
|
| 153 |
+
participant_data: ParticipantUpdate
|
| 154 |
+
) -> Participant:
|
| 155 |
+
"""
|
| 156 |
+
Update a participant.
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
db: Database session
|
| 160 |
+
participant_id: Participant ID to update
|
| 161 |
+
participant_data: Participant update data
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
Updated Participant instance
|
| 165 |
+
|
| 166 |
+
Raises:
|
| 167 |
+
ParticipantNotFoundError: If participant not found
|
| 168 |
+
DuplicateEntryError: If new name conflicts with existing participant
|
| 169 |
+
|
| 170 |
+
Example:
|
| 171 |
+
>>> data = ParticipantUpdate(avatar_url="new_url.jpg")
|
| 172 |
+
>>> participant = update_participant(db, 5, data)
|
| 173 |
+
"""
|
| 174 |
+
# Get existing participant
|
| 175 |
+
participant = get_participant_by_id(db, participant_id)
|
| 176 |
+
|
| 177 |
+
# Check for name conflicts if name is being updated
|
| 178 |
+
if participant_data.name:
|
| 179 |
+
existing = get_participant_by_name(db, participant_data.name)
|
| 180 |
+
if existing and existing.id != participant_id:
|
| 181 |
+
raise DuplicateEntryError(
|
| 182 |
+
resource_type="Participant",
|
| 183 |
+
field="name",
|
| 184 |
+
value=participant_data.name
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Update fields
|
| 188 |
+
update_data = participant_data.model_dump(exclude_unset=True)
|
| 189 |
+
for field, value in update_data.items():
|
| 190 |
+
setattr(participant, field, value)
|
| 191 |
+
|
| 192 |
+
db.commit()
|
| 193 |
+
db.refresh(participant)
|
| 194 |
+
|
| 195 |
+
logger.info(
|
| 196 |
+
f"Updated participant: {participant.name}",
|
| 197 |
+
extra={"participant_id": participant.id}
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
return participant
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def delete_participant(
|
| 204 |
+
db: Session,
|
| 205 |
+
participant_id: int
|
| 206 |
+
) -> None:
|
| 207 |
+
"""
|
| 208 |
+
Delete a participant.
|
| 209 |
+
|
| 210 |
+
WARNING: This will cascade delete all associated records
|
| 211 |
+
(points transactions, challenge associations, etc.)
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
db: Database session
|
| 215 |
+
participant_id: Participant ID to delete
|
| 216 |
+
|
| 217 |
+
Raises:
|
| 218 |
+
ParticipantNotFoundError: If participant not found
|
| 219 |
+
|
| 220 |
+
Example:
|
| 221 |
+
>>> delete_participant(db, 5)
|
| 222 |
+
"""
|
| 223 |
+
participant = get_participant_by_id(db, participant_id)
|
| 224 |
+
|
| 225 |
+
db.delete(participant)
|
| 226 |
+
db.commit()
|
| 227 |
+
|
| 228 |
+
logger.warning(
|
| 229 |
+
f"Deleted participant: {participant.name}",
|
| 230 |
+
extra={"participant_id": participant_id}
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# =============================================================================
|
| 235 |
+
# Special Queries
|
| 236 |
+
# =============================================================================
|
| 237 |
+
|
| 238 |
+
def get_groom(db: Session) -> Optional[Participant]:
|
| 239 |
+
"""
|
| 240 |
+
Get the groom participant.
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
db: Database session
|
| 244 |
+
|
| 245 |
+
Returns:
|
| 246 |
+
Groom Participant instance or None if not found
|
| 247 |
+
|
| 248 |
+
Example:
|
| 249 |
+
>>> groom = get_groom(db)
|
| 250 |
+
>>> if groom:
|
| 251 |
+
>>> print(f"The groom is {groom.name}")
|
| 252 |
+
"""
|
| 253 |
+
return db.query(Participant).filter(Participant.is_groom == True).first()
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def get_participant_count(db: Session) -> int:
|
| 257 |
+
"""
|
| 258 |
+
Get total number of participants.
|
| 259 |
+
|
| 260 |
+
Args:
|
| 261 |
+
db: Database session
|
| 262 |
+
|
| 263 |
+
Returns:
|
| 264 |
+
Total participant count
|
| 265 |
+
|
| 266 |
+
Example:
|
| 267 |
+
>>> count = get_participant_count(db)
|
| 268 |
+
>>> print(f"Total participants: {count}")
|
| 269 |
+
"""
|
| 270 |
+
return db.query(Participant).count()
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def get_top_participants(
|
| 274 |
+
db: Session,
|
| 275 |
+
limit: int = 3
|
| 276 |
+
) -> List[Participant]:
|
| 277 |
+
"""
|
| 278 |
+
Get top participants by points.
|
| 279 |
+
|
| 280 |
+
Args:
|
| 281 |
+
db: Database session
|
| 282 |
+
limit: Number of top participants to return
|
| 283 |
+
|
| 284 |
+
Returns:
|
| 285 |
+
List of top Participant instances
|
| 286 |
+
|
| 287 |
+
Example:
|
| 288 |
+
>>> top_3 = get_top_participants(db, limit=3)
|
| 289 |
+
>>> for i, participant in enumerate(top_3, 1):
|
| 290 |
+
>>> print(f"{i}. {participant.name} - {participant.total_points} pts")
|
| 291 |
+
"""
|
| 292 |
+
return db.query(Participant).order_by(
|
| 293 |
+
Participant.total_points.desc()
|
| 294 |
+
).limit(limit).all()
|
backend/app/services/points_service.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Points service for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Handles all business logic related to points and transactions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
from typing import List
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from app.models import Participant, PointsTransaction, Challenge
|
| 11 |
+
from app.schemas.points import PointsAdd, PointsSubtract
|
| 12 |
+
from app.utils.exceptions import (
|
| 13 |
+
ParticipantNotFoundError,
|
| 14 |
+
InsufficientPointsError,
|
| 15 |
+
NegativePointsError
|
| 16 |
+
)
|
| 17 |
+
from app.utils.logger import logger, log_points_transaction
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def add_points_to_participant(
|
| 21 |
+
db: Session,
|
| 22 |
+
points_data: PointsAdd
|
| 23 |
+
) -> PointsTransaction:
|
| 24 |
+
"""
|
| 25 |
+
Manually add points to a participant (admin action).
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
db: Database session
|
| 29 |
+
points_data: Points addition data
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Created PointsTransaction instance
|
| 33 |
+
|
| 34 |
+
Raises:
|
| 35 |
+
ParticipantNotFoundError: If participant not found
|
| 36 |
+
"""
|
| 37 |
+
# Get participant
|
| 38 |
+
participant = db.query(Participant).filter(
|
| 39 |
+
Participant.id == points_data.participant_id
|
| 40 |
+
).first()
|
| 41 |
+
|
| 42 |
+
if not participant:
|
| 43 |
+
raise ParticipantNotFoundError(points_data.participant_id)
|
| 44 |
+
|
| 45 |
+
# Create transaction
|
| 46 |
+
transaction = PointsTransaction.create_manual_transaction(
|
| 47 |
+
participant_id=points_data.participant_id,
|
| 48 |
+
amount=points_data.amount,
|
| 49 |
+
reason=points_data.reason,
|
| 50 |
+
admin_id=points_data.admin_id
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Update participant points
|
| 54 |
+
participant.add_points(points_data.amount)
|
| 55 |
+
|
| 56 |
+
db.add(transaction)
|
| 57 |
+
db.commit()
|
| 58 |
+
db.refresh(transaction)
|
| 59 |
+
|
| 60 |
+
log_points_transaction(
|
| 61 |
+
participant_id=points_data.participant_id,
|
| 62 |
+
amount=points_data.amount,
|
| 63 |
+
reason=points_data.reason,
|
| 64 |
+
admin_id=points_data.admin_id
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return transaction
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def subtract_points_from_participant(
|
| 71 |
+
db: Session,
|
| 72 |
+
points_data: PointsSubtract
|
| 73 |
+
) -> PointsTransaction:
|
| 74 |
+
"""
|
| 75 |
+
Manually subtract points from a participant (admin action).
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
db: Database session
|
| 79 |
+
points_data: Points subtraction data
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
Created PointsTransaction instance
|
| 83 |
+
|
| 84 |
+
Raises:
|
| 85 |
+
ParticipantNotFoundError: If participant not found
|
| 86 |
+
InsufficientPointsError: If participant doesn't have enough points
|
| 87 |
+
"""
|
| 88 |
+
# Get participant
|
| 89 |
+
participant = db.query(Participant).filter(
|
| 90 |
+
Participant.id == points_data.participant_id
|
| 91 |
+
).first()
|
| 92 |
+
|
| 93 |
+
if not participant:
|
| 94 |
+
raise ParticipantNotFoundError(points_data.participant_id)
|
| 95 |
+
|
| 96 |
+
# Check if participant has enough points
|
| 97 |
+
if participant.total_points < points_data.amount:
|
| 98 |
+
raise InsufficientPointsError(
|
| 99 |
+
required_points=points_data.amount,
|
| 100 |
+
current_points=participant.total_points
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
# Create penalty transaction (negative amount)
|
| 104 |
+
transaction = PointsTransaction.create_penalty_transaction(
|
| 105 |
+
participant_id=points_data.participant_id,
|
| 106 |
+
penalty_amount=points_data.amount,
|
| 107 |
+
reason=points_data.reason,
|
| 108 |
+
admin_id=points_data.admin_id
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# Update participant points
|
| 112 |
+
participant.subtract_points(points_data.amount)
|
| 113 |
+
|
| 114 |
+
db.add(transaction)
|
| 115 |
+
db.commit()
|
| 116 |
+
db.refresh(transaction)
|
| 117 |
+
|
| 118 |
+
log_points_transaction(
|
| 119 |
+
participant_id=points_data.participant_id,
|
| 120 |
+
amount=-points_data.amount,
|
| 121 |
+
reason=points_data.reason,
|
| 122 |
+
admin_id=points_data.admin_id
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return transaction
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def award_challenge_points(
|
| 129 |
+
db: Session,
|
| 130 |
+
participant_id: int,
|
| 131 |
+
challenge_id: int,
|
| 132 |
+
admin_id: int
|
| 133 |
+
) -> PointsTransaction:
|
| 134 |
+
"""
|
| 135 |
+
Award points to a participant for completing a challenge.
|
| 136 |
+
|
| 137 |
+
This is called after a challenge is validated as completed.
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
db: Database session
|
| 141 |
+
participant_id: ID of participant who completed the challenge
|
| 142 |
+
challenge_id: ID of completed challenge
|
| 143 |
+
admin_id: ID of admin who validated the challenge
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Created PointsTransaction instance
|
| 147 |
+
|
| 148 |
+
Raises:
|
| 149 |
+
ParticipantNotFoundError: If participant not found
|
| 150 |
+
ChallengeNotFoundError: If challenge not found
|
| 151 |
+
"""
|
| 152 |
+
# Get participant
|
| 153 |
+
participant = db.query(Participant).filter(
|
| 154 |
+
Participant.id == participant_id
|
| 155 |
+
).first()
|
| 156 |
+
|
| 157 |
+
if not participant:
|
| 158 |
+
raise ParticipantNotFoundError(participant_id)
|
| 159 |
+
|
| 160 |
+
# Get challenge
|
| 161 |
+
challenge = db.query(Challenge).filter(Challenge.id == challenge_id).first()
|
| 162 |
+
|
| 163 |
+
if not challenge:
|
| 164 |
+
from app.utils.exceptions import ChallengeNotFoundError
|
| 165 |
+
raise ChallengeNotFoundError(challenge_id)
|
| 166 |
+
|
| 167 |
+
# Create transaction
|
| 168 |
+
transaction = PointsTransaction.create_challenge_transaction(
|
| 169 |
+
participant_id=participant_id,
|
| 170 |
+
challenge_id=challenge_id,
|
| 171 |
+
points=challenge.points,
|
| 172 |
+
admin_id=admin_id
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Update participant points
|
| 176 |
+
participant.add_points(challenge.points)
|
| 177 |
+
|
| 178 |
+
db.add(transaction)
|
| 179 |
+
db.commit()
|
| 180 |
+
db.refresh(transaction)
|
| 181 |
+
|
| 182 |
+
log_points_transaction(
|
| 183 |
+
participant_id=participant_id,
|
| 184 |
+
amount=challenge.points,
|
| 185 |
+
reason=f"Completed challenge: {challenge.title}",
|
| 186 |
+
admin_id=admin_id
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
return transaction
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def get_participant_transactions(
|
| 193 |
+
db: Session,
|
| 194 |
+
participant_id: int,
|
| 195 |
+
skip: int = 0,
|
| 196 |
+
limit: int = 100
|
| 197 |
+
) -> List[PointsTransaction]:
|
| 198 |
+
"""
|
| 199 |
+
Get all transactions for a participant.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
db: Database session
|
| 203 |
+
participant_id: Participant ID
|
| 204 |
+
skip: Number of records to skip
|
| 205 |
+
limit: Maximum number of records to return
|
| 206 |
+
|
| 207 |
+
Returns:
|
| 208 |
+
List of PointsTransaction instances
|
| 209 |
+
|
| 210 |
+
Raises:
|
| 211 |
+
ParticipantNotFoundError: If participant not found
|
| 212 |
+
"""
|
| 213 |
+
# Verify participant exists
|
| 214 |
+
participant = db.query(Participant).filter(
|
| 215 |
+
Participant.id == participant_id
|
| 216 |
+
).first()
|
| 217 |
+
|
| 218 |
+
if not participant:
|
| 219 |
+
raise ParticipantNotFoundError(participant_id)
|
| 220 |
+
|
| 221 |
+
# Get transactions ordered by most recent first
|
| 222 |
+
return db.query(PointsTransaction).filter(
|
| 223 |
+
PointsTransaction.participant_id == participant_id
|
| 224 |
+
).order_by(
|
| 225 |
+
PointsTransaction.created_at.desc()
|
| 226 |
+
).offset(skip).limit(limit).all()
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def get_participant_points_today(db: Session, participant_id: int) -> int:
|
| 230 |
+
"""
|
| 231 |
+
Get points earned by a participant today.
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
db: Database session
|
| 235 |
+
participant_id: Participant ID
|
| 236 |
+
|
| 237 |
+
Returns:
|
| 238 |
+
Total points earned today
|
| 239 |
+
"""
|
| 240 |
+
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
| 241 |
+
|
| 242 |
+
transactions = db.query(PointsTransaction).filter(
|
| 243 |
+
PointsTransaction.participant_id == participant_id,
|
| 244 |
+
PointsTransaction.created_at >= today_start,
|
| 245 |
+
PointsTransaction.amount > 0 # Only positive transactions
|
| 246 |
+
).all()
|
| 247 |
+
|
| 248 |
+
return sum(t.amount for t in transactions)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def get_total_points_distributed(db: Session) -> int:
|
| 252 |
+
"""
|
| 253 |
+
Get total points distributed across all participants.
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
db: Database session
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
Total points distributed
|
| 260 |
+
"""
|
| 261 |
+
transactions = db.query(PointsTransaction).filter(
|
| 262 |
+
PointsTransaction.amount > 0
|
| 263 |
+
).all()
|
| 264 |
+
|
| 265 |
+
return sum(t.amount for t in transactions)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def get_transaction_count(db: Session) -> int:
|
| 269 |
+
"""
|
| 270 |
+
Get total number of transactions.
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
db: Database session
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
Total transaction count
|
| 277 |
+
"""
|
| 278 |
+
return db.query(PointsTransaction).count()
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def get_recent_transactions(db: Session, limit: int = 10) -> List[PointsTransaction]:
|
| 282 |
+
"""
|
| 283 |
+
Get most recent transactions across all participants.
|
| 284 |
+
|
| 285 |
+
Useful for activity feed.
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
db: Database session
|
| 289 |
+
limit: Maximum number of transactions to return
|
| 290 |
+
|
| 291 |
+
Returns:
|
| 292 |
+
List of recent PointsTransaction instances
|
| 293 |
+
"""
|
| 294 |
+
return db.query(PointsTransaction).order_by(
|
| 295 |
+
PointsTransaction.created_at.desc()
|
| 296 |
+
).limit(limit).all()
|
backend/app/utils/__init__.py
ADDED
|
File without changes
|
backend/app/utils/dependencies.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPI dependencies for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Provides reusable dependency functions for route handlers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import Depends, HTTPException, status
|
| 8 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from app.database import get_db
|
| 12 |
+
from app.models import Participant
|
| 13 |
+
from app.utils.security import decode_access_token, is_admin_token
|
| 14 |
+
from app.utils.logger import logger
|
| 15 |
+
|
| 16 |
+
# Security scheme for JWT tokens
|
| 17 |
+
security = HTTPBearer()
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# =============================================================================
|
| 21 |
+
# Authentication Dependencies
|
| 22 |
+
# =============================================================================
|
| 23 |
+
|
| 24 |
+
def get_current_user_payload(
|
| 25 |
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
| 26 |
+
) -> dict:
|
| 27 |
+
"""
|
| 28 |
+
Extract and validate the current user from the JWT token.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
credentials: HTTP Authorization credentials containing the JWT token
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Decoded token payload
|
| 35 |
+
|
| 36 |
+
Raises:
|
| 37 |
+
HTTPException: If token is invalid or expired
|
| 38 |
+
|
| 39 |
+
Example:
|
| 40 |
+
>>> @app.get("/profile")
|
| 41 |
+
>>> def get_profile(payload: dict = Depends(get_current_user_payload)):
|
| 42 |
+
>>> user_id = payload["user_id"]
|
| 43 |
+
>>> return {"user_id": user_id}
|
| 44 |
+
"""
|
| 45 |
+
token = credentials.credentials
|
| 46 |
+
|
| 47 |
+
# Decode and verify token
|
| 48 |
+
payload = decode_access_token(token)
|
| 49 |
+
|
| 50 |
+
if payload is None:
|
| 51 |
+
logger.warning(f"Invalid or expired token attempted")
|
| 52 |
+
raise HTTPException(
|
| 53 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 54 |
+
detail="Invalid or expired token",
|
| 55 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
return payload
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_current_participant(
|
| 62 |
+
payload: dict = Depends(get_current_user_payload),
|
| 63 |
+
db: Session = Depends(get_db)
|
| 64 |
+
) -> Participant:
|
| 65 |
+
"""
|
| 66 |
+
Get the current authenticated participant from the database.
|
| 67 |
+
|
| 68 |
+
Args:
|
| 69 |
+
payload: Decoded JWT token payload
|
| 70 |
+
db: Database session
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
Participant model instance
|
| 74 |
+
|
| 75 |
+
Raises:
|
| 76 |
+
HTTPException: If participant not found or token is for an admin
|
| 77 |
+
|
| 78 |
+
Example:
|
| 79 |
+
>>> @app.get("/my-points")
|
| 80 |
+
>>> def get_my_points(participant: Participant = Depends(get_current_participant)):
|
| 81 |
+
>>> return {"points": participant.total_points}
|
| 82 |
+
"""
|
| 83 |
+
# Check if token is for a participant (not admin)
|
| 84 |
+
if payload.get("type") != "participant":
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 87 |
+
detail="This endpoint is for participants only"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
user_id = payload.get("user_id")
|
| 91 |
+
if user_id is None:
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 94 |
+
detail="Invalid token payload"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Get participant from database
|
| 98 |
+
participant = db.query(Participant).filter(Participant.id == user_id).first()
|
| 99 |
+
|
| 100 |
+
if participant is None:
|
| 101 |
+
logger.warning(f"Participant {user_id} not found for valid token")
|
| 102 |
+
raise HTTPException(
|
| 103 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 104 |
+
detail="Participant not found"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
return participant
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def require_admin(
|
| 111 |
+
payload: dict = Depends(get_current_user_payload)
|
| 112 |
+
) -> dict:
|
| 113 |
+
"""
|
| 114 |
+
Require that the current user is an admin.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
payload: Decoded JWT token payload
|
| 118 |
+
|
| 119 |
+
Returns:
|
| 120 |
+
Token payload if user is admin
|
| 121 |
+
|
| 122 |
+
Raises:
|
| 123 |
+
HTTPException: If user is not an admin
|
| 124 |
+
|
| 125 |
+
Example:
|
| 126 |
+
>>> @app.post("/admin/challenges")
|
| 127 |
+
>>> def create_challenge(
|
| 128 |
+
>>> admin: dict = Depends(require_admin),
|
| 129 |
+
>>> challenge: ChallengeCreate = ...
|
| 130 |
+
>>> ):
|
| 131 |
+
>>> # Only admins can access this endpoint
|
| 132 |
+
>>> pass
|
| 133 |
+
"""
|
| 134 |
+
if not is_admin_token(payload):
|
| 135 |
+
logger.warning(
|
| 136 |
+
f"Non-admin user attempted to access admin endpoint",
|
| 137 |
+
extra={"user_id": payload.get("user_id")}
|
| 138 |
+
)
|
| 139 |
+
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 141 |
+
detail="Admin privileges required"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
return payload
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def get_optional_current_user(
|
| 148 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
| 149 |
+
) -> Optional[dict]:
|
| 150 |
+
"""
|
| 151 |
+
Get the current user if authenticated, otherwise None.
|
| 152 |
+
|
| 153 |
+
Useful for endpoints that work with or without authentication.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
credentials: Optional HTTP Authorization credentials
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
Decoded token payload if authenticated, None otherwise
|
| 160 |
+
|
| 161 |
+
Example:
|
| 162 |
+
>>> @app.get("/challenges")
|
| 163 |
+
>>> def list_challenges(user: Optional[dict] = Depends(get_optional_current_user)):
|
| 164 |
+
>>> # Show all challenges, but mark completed ones if user is authenticated
|
| 165 |
+
>>> pass
|
| 166 |
+
"""
|
| 167 |
+
if credentials is None:
|
| 168 |
+
return None
|
| 169 |
+
|
| 170 |
+
token = credentials.credentials
|
| 171 |
+
return decode_access_token(token)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# =============================================================================
|
| 175 |
+
# Helper Dependencies
|
| 176 |
+
# =============================================================================
|
| 177 |
+
|
| 178 |
+
def get_current_user_id(
|
| 179 |
+
payload: dict = Depends(get_current_user_payload)
|
| 180 |
+
) -> int:
|
| 181 |
+
"""
|
| 182 |
+
Get the current user's ID from the token.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
payload: Decoded JWT token payload
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
User ID
|
| 189 |
+
|
| 190 |
+
Raises:
|
| 191 |
+
HTTPException: If user_id is not in payload
|
| 192 |
+
|
| 193 |
+
Example:
|
| 194 |
+
>>> @app.get("/my-data")
|
| 195 |
+
>>> def get_my_data(user_id: int = Depends(get_current_user_id)):
|
| 196 |
+
>>> return {"user_id": user_id}
|
| 197 |
+
"""
|
| 198 |
+
user_id = payload.get("user_id")
|
| 199 |
+
if user_id is None:
|
| 200 |
+
raise HTTPException(
|
| 201 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 202 |
+
detail="Invalid token: missing user_id"
|
| 203 |
+
)
|
| 204 |
+
return user_id
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def get_admin_id(
|
| 208 |
+
admin_payload: dict = Depends(require_admin)
|
| 209 |
+
) -> int:
|
| 210 |
+
"""
|
| 211 |
+
Get the current admin's ID.
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
admin_payload: Decoded admin JWT token payload
|
| 215 |
+
|
| 216 |
+
Returns:
|
| 217 |
+
Admin user ID
|
| 218 |
+
|
| 219 |
+
Example:
|
| 220 |
+
>>> @app.post("/admin/points/add")
|
| 221 |
+
>>> def add_points(admin_id: int = Depends(get_admin_id)):
|
| 222 |
+
>>> # admin_id can be used to track who made the change
|
| 223 |
+
>>> pass
|
| 224 |
+
"""
|
| 225 |
+
return admin_payload.get("user_id", 0)
|
backend/app/utils/exceptions.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Custom exception classes for EVG Ultimate Team backend.
|
| 3 |
+
|
| 4 |
+
This module defines custom exceptions for different error scenarios,
|
| 5 |
+
making error handling more explicit and maintainable throughout the application.
|
| 6 |
+
|
| 7 |
+
All custom exceptions inherit from EVGException base class and include
|
| 8 |
+
HTTP status codes for API responses.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class EVGException(Exception):
|
| 15 |
+
"""
|
| 16 |
+
Base exception class for all EVG Ultimate Team exceptions.
|
| 17 |
+
|
| 18 |
+
Attributes:
|
| 19 |
+
message: Human-readable error message
|
| 20 |
+
status_code: HTTP status code for API responses
|
| 21 |
+
detail: Optional additional details for debugging
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def __init__(
|
| 25 |
+
self,
|
| 26 |
+
message: str,
|
| 27 |
+
status_code: int = 500,
|
| 28 |
+
detail: Optional[str] = None
|
| 29 |
+
):
|
| 30 |
+
self.message = message
|
| 31 |
+
self.status_code = status_code
|
| 32 |
+
self.detail = detail
|
| 33 |
+
super().__init__(self.message)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# =============================================================================
|
| 37 |
+
# Authentication Exceptions
|
| 38 |
+
# =============================================================================
|
| 39 |
+
|
| 40 |
+
class AuthenticationError(EVGException):
|
| 41 |
+
"""Raised when authentication fails."""
|
| 42 |
+
|
| 43 |
+
def __init__(
|
| 44 |
+
self,
|
| 45 |
+
message: str = "Authentication failed",
|
| 46 |
+
detail: Optional[str] = None
|
| 47 |
+
):
|
| 48 |
+
super().__init__(message=message, status_code=401, detail=detail)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class InvalidCredentialsError(AuthenticationError):
|
| 52 |
+
"""Raised when login credentials are invalid."""
|
| 53 |
+
|
| 54 |
+
def __init__(self, detail: Optional[str] = None):
|
| 55 |
+
super().__init__(
|
| 56 |
+
message="Invalid username or password",
|
| 57 |
+
detail=detail
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class UnauthorizedError(EVGException):
|
| 62 |
+
"""Raised when user lacks permission for an action."""
|
| 63 |
+
|
| 64 |
+
def __init__(
|
| 65 |
+
self,
|
| 66 |
+
message: str = "You don't have permission to perform this action",
|
| 67 |
+
detail: Optional[str] = None
|
| 68 |
+
):
|
| 69 |
+
super().__init__(message=message, status_code=403, detail=detail)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class AdminRequiredError(UnauthorizedError):
|
| 73 |
+
"""Raised when admin privileges are required but not present."""
|
| 74 |
+
|
| 75 |
+
def __init__(self):
|
| 76 |
+
super().__init__(
|
| 77 |
+
message="Admin privileges required for this action",
|
| 78 |
+
detail="Only administrators can perform this operation"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# =============================================================================
|
| 83 |
+
# Resource Not Found Exceptions
|
| 84 |
+
# =============================================================================
|
| 85 |
+
|
| 86 |
+
class NotFoundError(EVGException):
|
| 87 |
+
"""Base class for resource not found errors."""
|
| 88 |
+
|
| 89 |
+
def __init__(
|
| 90 |
+
self,
|
| 91 |
+
resource_type: str,
|
| 92 |
+
resource_id: Optional[int] = None,
|
| 93 |
+
detail: Optional[str] = None
|
| 94 |
+
):
|
| 95 |
+
if resource_id:
|
| 96 |
+
message = f"{resource_type} with ID {resource_id} not found"
|
| 97 |
+
else:
|
| 98 |
+
message = f"{resource_type} not found"
|
| 99 |
+
|
| 100 |
+
super().__init__(message=message, status_code=404, detail=detail)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class ParticipantNotFoundError(NotFoundError):
|
| 104 |
+
"""Raised when a participant cannot be found."""
|
| 105 |
+
|
| 106 |
+
def __init__(self, participant_id: Optional[int] = None):
|
| 107 |
+
super().__init__(
|
| 108 |
+
resource_type="Participant",
|
| 109 |
+
resource_id=participant_id
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class ChallengeNotFoundError(NotFoundError):
|
| 114 |
+
"""Raised when a challenge cannot be found."""
|
| 115 |
+
|
| 116 |
+
def __init__(self, challenge_id: Optional[int] = None):
|
| 117 |
+
super().__init__(
|
| 118 |
+
resource_type="Challenge",
|
| 119 |
+
resource_id=challenge_id
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class PackNotFoundError(NotFoundError):
|
| 124 |
+
"""Raised when a pack cannot be found."""
|
| 125 |
+
|
| 126 |
+
def __init__(self, pack_id: Optional[int] = None):
|
| 127 |
+
super().__init__(
|
| 128 |
+
resource_type="Pack",
|
| 129 |
+
resource_id=pack_id
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class EventNotFoundError(NotFoundError):
|
| 134 |
+
"""Raised when an event card cannot be found."""
|
| 135 |
+
|
| 136 |
+
def __init__(self, event_id: Optional[int] = None):
|
| 137 |
+
super().__init__(
|
| 138 |
+
resource_type="Event",
|
| 139 |
+
resource_id=event_id
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# =============================================================================
|
| 144 |
+
# Validation Exceptions
|
| 145 |
+
# =============================================================================
|
| 146 |
+
|
| 147 |
+
class ValidationError(EVGException):
|
| 148 |
+
"""Raised when input validation fails."""
|
| 149 |
+
|
| 150 |
+
def __init__(
|
| 151 |
+
self,
|
| 152 |
+
message: str = "Validation error",
|
| 153 |
+
detail: Optional[str] = None
|
| 154 |
+
):
|
| 155 |
+
super().__init__(message=message, status_code=422, detail=detail)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class InsufficientPointsError(ValidationError):
|
| 159 |
+
"""Raised when participant doesn't have enough points for an action."""
|
| 160 |
+
|
| 161 |
+
def __init__(self, required_points: int, current_points: int):
|
| 162 |
+
super().__init__(
|
| 163 |
+
message="Insufficient points for this action",
|
| 164 |
+
detail=f"Required: {required_points} points, Current: {current_points} points"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class InvalidChallengeStatusError(ValidationError):
|
| 169 |
+
"""Raised when attempting an invalid challenge status transition."""
|
| 170 |
+
|
| 171 |
+
def __init__(self, current_status: str, attempted_status: str):
|
| 172 |
+
super().__init__(
|
| 173 |
+
message="Invalid challenge status transition",
|
| 174 |
+
detail=f"Cannot change from '{current_status}' to '{attempted_status}'"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class DuplicateEntryError(ValidationError):
|
| 179 |
+
"""Raised when attempting to create a duplicate entry."""
|
| 180 |
+
|
| 181 |
+
def __init__(
|
| 182 |
+
self,
|
| 183 |
+
resource_type: str,
|
| 184 |
+
field: str,
|
| 185 |
+
value: str
|
| 186 |
+
):
|
| 187 |
+
super().__init__(
|
| 188 |
+
message=f"Duplicate {resource_type} entry",
|
| 189 |
+
detail=f"A {resource_type} with {field}='{value}' already exists"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# =============================================================================
|
| 194 |
+
# Business Logic Exceptions
|
| 195 |
+
# =============================================================================
|
| 196 |
+
|
| 197 |
+
class BusinessLogicError(EVGException):
|
| 198 |
+
"""Base class for business logic violations."""
|
| 199 |
+
|
| 200 |
+
def __init__(
|
| 201 |
+
self,
|
| 202 |
+
message: str = "Business logic error",
|
| 203 |
+
detail: Optional[str] = None
|
| 204 |
+
):
|
| 205 |
+
super().__init__(message=message, status_code=400, detail=detail)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class PackWindowClosedError(BusinessLogicError):
|
| 209 |
+
"""Raised when trying to open packs outside allowed time windows."""
|
| 210 |
+
|
| 211 |
+
def __init__(self):
|
| 212 |
+
super().__init__(
|
| 213 |
+
message="Pack opening window is currently closed",
|
| 214 |
+
detail="Wait for the next scheduled pack opening time"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
class ChallengeAlreadyCompletedError(BusinessLogicError):
|
| 219 |
+
"""Raised when trying to complete an already completed challenge."""
|
| 220 |
+
|
| 221 |
+
def __init__(self, challenge_id: int):
|
| 222 |
+
super().__init__(
|
| 223 |
+
message="Challenge already completed",
|
| 224 |
+
detail=f"Challenge {challenge_id} has already been completed"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class NegativePointsError(BusinessLogicError):
|
| 229 |
+
"""Raised when an action would result in negative points."""
|
| 230 |
+
|
| 231 |
+
def __init__(self):
|
| 232 |
+
super().__init__(
|
| 233 |
+
message="Points cannot be negative",
|
| 234 |
+
detail="This action would result in a negative point balance"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# =============================================================================
|
| 239 |
+
# Database Exceptions
|
| 240 |
+
# =============================================================================
|
| 241 |
+
|
| 242 |
+
class DatabaseError(EVGException):
|
| 243 |
+
"""Raised when a database operation fails."""
|
| 244 |
+
|
| 245 |
+
def __init__(
|
| 246 |
+
self,
|
| 247 |
+
message: str = "Database operation failed",
|
| 248 |
+
detail: Optional[str] = None
|
| 249 |
+
):
|
| 250 |
+
super().__init__(message=message, status_code=500, detail=detail)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# =============================================================================
|
| 254 |
+
# Helper Functions
|
| 255 |
+
# =============================================================================
|
| 256 |
+
|
| 257 |
+
def format_exception_response(exc: EVGException) -> dict:
|
| 258 |
+
"""
|
| 259 |
+
Format an exception into a JSON-serializable response.
|
| 260 |
+
|
| 261 |
+
Args:
|
| 262 |
+
exc: The exception to format
|
| 263 |
+
|
| 264 |
+
Returns:
|
| 265 |
+
Dictionary with error details suitable for API response
|
| 266 |
+
|
| 267 |
+
Example:
|
| 268 |
+
>>> exc = ParticipantNotFoundError(participant_id=123)
|
| 269 |
+
>>> format_exception_response(exc)
|
| 270 |
+
{
|
| 271 |
+
"success": False,
|
| 272 |
+
"error": "Participant with ID 123 not found",
|
| 273 |
+
"detail": None,
|
| 274 |
+
"status_code": 404
|
| 275 |
+
}
|
| 276 |
+
"""
|
| 277 |
+
return {
|
| 278 |
+
"success": False,
|
| 279 |
+
"error": exc.message,
|
| 280 |
+
"detail": exc.detail,
|
| 281 |
+
"status_code": exc.status_code
|
| 282 |
+
}
|
backend/app/utils/logger.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized logging utility for EVG Ultimate Team backend.
|
| 3 |
+
|
| 4 |
+
This module provides a configured logger instance that can be imported
|
| 5 |
+
and used throughout the application for consistent logging.
|
| 6 |
+
|
| 7 |
+
Features:
|
| 8 |
+
- Structured logging with context (timestamps, levels, module names)
|
| 9 |
+
- Configurable log levels via environment variables
|
| 10 |
+
- Console and file output support
|
| 11 |
+
- JSON formatting for production environments
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Optional
|
| 18 |
+
import os
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def setup_logger(
|
| 22 |
+
name: str = "evg_ultimate_team",
|
| 23 |
+
log_level: Optional[str] = None,
|
| 24 |
+
log_file: Optional[str] = None
|
| 25 |
+
) -> logging.Logger:
|
| 26 |
+
"""
|
| 27 |
+
Configure and return a logger instance with consistent formatting.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
name: Logger name (typically module name or app name)
|
| 31 |
+
log_level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
| 32 |
+
If not provided, reads from LOG_LEVEL env var, defaults to INFO
|
| 33 |
+
log_file: Path to log file. If not provided, reads from LOG_FILE env var
|
| 34 |
+
If no file specified, logs only to console
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
Configured logger instance
|
| 38 |
+
|
| 39 |
+
Example:
|
| 40 |
+
>>> logger = setup_logger(__name__)
|
| 41 |
+
>>> logger.info("Application started", extra={"user_id": 123})
|
| 42 |
+
"""
|
| 43 |
+
# Create logger
|
| 44 |
+
logger = logging.getLogger(name)
|
| 45 |
+
|
| 46 |
+
# Determine log level
|
| 47 |
+
if log_level is None:
|
| 48 |
+
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
| 49 |
+
|
| 50 |
+
logger.setLevel(getattr(logging, log_level, logging.INFO))
|
| 51 |
+
|
| 52 |
+
# Prevent duplicate handlers if logger is reconfigured
|
| 53 |
+
if logger.handlers:
|
| 54 |
+
return logger
|
| 55 |
+
|
| 56 |
+
# Create formatters
|
| 57 |
+
# Detailed format with timestamp, level, module, and message
|
| 58 |
+
detailed_formatter = logging.Formatter(
|
| 59 |
+
fmt='%(asctime)s - %(name)s - %(levelname)s - %(module)s:%(lineno)d - %(message)s',
|
| 60 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
# Console handler (always enabled)
|
| 64 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 65 |
+
console_handler.setLevel(logging.DEBUG)
|
| 66 |
+
console_handler.setFormatter(detailed_formatter)
|
| 67 |
+
logger.addHandler(console_handler)
|
| 68 |
+
|
| 69 |
+
# File handler (optional)
|
| 70 |
+
if log_file is None:
|
| 71 |
+
log_file = os.getenv("LOG_FILE")
|
| 72 |
+
|
| 73 |
+
if log_file:
|
| 74 |
+
# Create log directory if it doesn't exist
|
| 75 |
+
log_path = Path(log_file)
|
| 76 |
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
|
| 78 |
+
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
| 79 |
+
file_handler.setLevel(logging.DEBUG)
|
| 80 |
+
file_handler.setFormatter(detailed_formatter)
|
| 81 |
+
logger.addHandler(file_handler)
|
| 82 |
+
|
| 83 |
+
return logger
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Create default application logger
|
| 87 |
+
logger = setup_logger()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# Convenience functions for common logging patterns
|
| 91 |
+
def log_request(method: str, path: str, user_id: Optional[int] = None) -> None:
|
| 92 |
+
"""
|
| 93 |
+
Log an incoming HTTP request.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
method: HTTP method (GET, POST, etc.)
|
| 97 |
+
path: Request path
|
| 98 |
+
user_id: Optional user ID making the request
|
| 99 |
+
"""
|
| 100 |
+
logger.info(
|
| 101 |
+
f"HTTP Request: {method} {path}",
|
| 102 |
+
extra={"method": method, "path": path, "user_id": user_id}
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def log_points_transaction(
|
| 107 |
+
participant_id: int,
|
| 108 |
+
amount: int,
|
| 109 |
+
reason: str,
|
| 110 |
+
admin_id: Optional[int] = None
|
| 111 |
+
) -> None:
|
| 112 |
+
"""
|
| 113 |
+
Log a points transaction event.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
participant_id: ID of participant receiving/losing points
|
| 117 |
+
amount: Points amount (positive or negative)
|
| 118 |
+
reason: Reason for the transaction
|
| 119 |
+
admin_id: Optional ID of admin who triggered the transaction
|
| 120 |
+
"""
|
| 121 |
+
action = "added to" if amount > 0 else "subtracted from"
|
| 122 |
+
logger.info(
|
| 123 |
+
f"Points {action} participant",
|
| 124 |
+
extra={
|
| 125 |
+
"participant_id": participant_id,
|
| 126 |
+
"points": amount,
|
| 127 |
+
"reason": reason,
|
| 128 |
+
"admin_id": admin_id
|
| 129 |
+
}
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def log_challenge_validation(
|
| 134 |
+
challenge_id: int,
|
| 135 |
+
participant_ids: list[int],
|
| 136 |
+
validated_by: int,
|
| 137 |
+
status: str
|
| 138 |
+
) -> None:
|
| 139 |
+
"""
|
| 140 |
+
Log a challenge validation event.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
challenge_id: ID of the challenge
|
| 144 |
+
participant_ids: List of participant IDs who completed the challenge
|
| 145 |
+
validated_by: Admin ID who validated the challenge
|
| 146 |
+
status: Validation status (completed, failed, etc.)
|
| 147 |
+
"""
|
| 148 |
+
logger.info(
|
| 149 |
+
f"Challenge validation: {status}",
|
| 150 |
+
extra={
|
| 151 |
+
"challenge_id": challenge_id,
|
| 152 |
+
"participant_ids": participant_ids,
|
| 153 |
+
"validated_by": validated_by,
|
| 154 |
+
"status": status
|
| 155 |
+
}
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def log_auth_attempt(username: str, success: bool, is_admin: bool = False) -> None:
|
| 160 |
+
"""
|
| 161 |
+
Log an authentication attempt.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
username: Username attempting to login
|
| 165 |
+
success: Whether login was successful
|
| 166 |
+
is_admin: Whether this is an admin login attempt
|
| 167 |
+
"""
|
| 168 |
+
level = logging.INFO if success else logging.WARNING
|
| 169 |
+
user_type = "admin" if is_admin else "participant"
|
| 170 |
+
result = "successful" if success else "failed"
|
| 171 |
+
|
| 172 |
+
logger.log(
|
| 173 |
+
level,
|
| 174 |
+
f"{user_type.capitalize()} login {result}",
|
| 175 |
+
extra={
|
| 176 |
+
"username": username,
|
| 177 |
+
"success": success,
|
| 178 |
+
"is_admin": is_admin
|
| 179 |
+
}
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def log_error(error: Exception, context: Optional[dict] = None) -> None:
|
| 184 |
+
"""
|
| 185 |
+
Log an error with optional context.
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
error: The exception that occurred
|
| 189 |
+
context: Optional dictionary with additional context
|
| 190 |
+
"""
|
| 191 |
+
logger.error(
|
| 192 |
+
f"Error occurred: {str(error)}",
|
| 193 |
+
exc_info=True,
|
| 194 |
+
extra=context or {}
|
| 195 |
+
)
|
backend/app/utils/security.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Security utilities for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Provides functions for password hashing, token generation, and authentication.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from jose import JWTError, jwt
|
| 10 |
+
from passlib.context import CryptContext
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
|
| 13 |
+
settings = get_settings()
|
| 14 |
+
|
| 15 |
+
# =============================================================================
|
| 16 |
+
# Password Hashing
|
| 17 |
+
# =============================================================================
|
| 18 |
+
|
| 19 |
+
# Password context for hashing and verification
|
| 20 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def hash_password(password: str) -> str:
|
| 24 |
+
"""
|
| 25 |
+
Hash a password using bcrypt.
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
password: Plain text password
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
Hashed password string
|
| 32 |
+
|
| 33 |
+
Example:
|
| 34 |
+
>>> hashed = hash_password("my_password")
|
| 35 |
+
>>> print(hashed)
|
| 36 |
+
$2b$12$...
|
| 37 |
+
"""
|
| 38 |
+
return pwd_context.hash(password)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 42 |
+
"""
|
| 43 |
+
Verify a password against a hashed password.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
plain_password: Plain text password to verify
|
| 47 |
+
hashed_password: Hashed password to compare against
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
True if password matches, False otherwise
|
| 51 |
+
|
| 52 |
+
Example:
|
| 53 |
+
>>> hashed = hash_password("my_password")
|
| 54 |
+
>>> verify_password("my_password", hashed)
|
| 55 |
+
True
|
| 56 |
+
>>> verify_password("wrong_password", hashed)
|
| 57 |
+
False
|
| 58 |
+
"""
|
| 59 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# =============================================================================
|
| 63 |
+
# JWT Token Generation and Verification
|
| 64 |
+
# =============================================================================
|
| 65 |
+
|
| 66 |
+
# Algorithm for JWT encoding/decoding
|
| 67 |
+
ALGORITHM = "HS256"
|
| 68 |
+
|
| 69 |
+
# Token expiration time (7 days for this event)
|
| 70 |
+
ACCESS_TOKEN_EXPIRE_DAYS = 7
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def create_access_token(
|
| 74 |
+
data: dict,
|
| 75 |
+
expires_delta: Optional[timedelta] = None
|
| 76 |
+
) -> str:
|
| 77 |
+
"""
|
| 78 |
+
Create a JWT access token.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
data: Dictionary of data to encode in the token
|
| 82 |
+
expires_delta: Optional custom expiration time
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Encoded JWT token string
|
| 86 |
+
|
| 87 |
+
Example:
|
| 88 |
+
>>> token = create_access_token({"sub": "user_5", "is_admin": False})
|
| 89 |
+
>>> print(token)
|
| 90 |
+
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
| 91 |
+
"""
|
| 92 |
+
to_encode = data.copy()
|
| 93 |
+
|
| 94 |
+
# Set expiration time
|
| 95 |
+
if expires_delta:
|
| 96 |
+
expire = datetime.utcnow() + expires_delta
|
| 97 |
+
else:
|
| 98 |
+
expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
|
| 99 |
+
|
| 100 |
+
to_encode.update({"exp": expire})
|
| 101 |
+
|
| 102 |
+
# Encode and return token
|
| 103 |
+
encoded_jwt = jwt.encode(
|
| 104 |
+
to_encode,
|
| 105 |
+
settings.secret_key,
|
| 106 |
+
algorithm=ALGORITHM
|
| 107 |
+
)
|
| 108 |
+
return encoded_jwt
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def decode_access_token(token: str) -> Optional[dict]:
|
| 112 |
+
"""
|
| 113 |
+
Decode and verify a JWT access token.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
token: JWT token string to decode
|
| 117 |
+
|
| 118 |
+
Returns:
|
| 119 |
+
Decoded token data as dictionary, or None if invalid
|
| 120 |
+
|
| 121 |
+
Example:
|
| 122 |
+
>>> token = create_access_token({"sub": "user_5"})
|
| 123 |
+
>>> payload = decode_access_token(token)
|
| 124 |
+
>>> print(payload["sub"])
|
| 125 |
+
user_5
|
| 126 |
+
"""
|
| 127 |
+
try:
|
| 128 |
+
payload = jwt.decode(
|
| 129 |
+
token,
|
| 130 |
+
settings.secret_key,
|
| 131 |
+
algorithms=[ALGORITHM]
|
| 132 |
+
)
|
| 133 |
+
return payload
|
| 134 |
+
except JWTError:
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def verify_token(token: str) -> dict:
|
| 139 |
+
"""
|
| 140 |
+
Verify a JWT access token and return payload.
|
| 141 |
+
|
| 142 |
+
Args:
|
| 143 |
+
token: JWT token string to verify
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Decoded token data as dictionary
|
| 147 |
+
|
| 148 |
+
Raises:
|
| 149 |
+
JWTError: If token is invalid or expired
|
| 150 |
+
|
| 151 |
+
Example:
|
| 152 |
+
>>> token = create_access_token({"sub": "user_5"})
|
| 153 |
+
>>> payload = verify_token(token)
|
| 154 |
+
>>> print(payload["sub"])
|
| 155 |
+
user_5
|
| 156 |
+
"""
|
| 157 |
+
payload = jwt.decode(
|
| 158 |
+
token,
|
| 159 |
+
settings.secret_key,
|
| 160 |
+
algorithms=[ALGORITHM]
|
| 161 |
+
)
|
| 162 |
+
return payload
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# =============================================================================
|
| 166 |
+
# Admin Authentication
|
| 167 |
+
# =============================================================================
|
| 168 |
+
|
| 169 |
+
def verify_admin_credentials(username: str, password: str) -> bool:
|
| 170 |
+
"""
|
| 171 |
+
Verify admin credentials against environment configuration.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
username: Admin username
|
| 175 |
+
password: Admin password
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
True if credentials are valid, False otherwise
|
| 179 |
+
|
| 180 |
+
Example:
|
| 181 |
+
>>> verify_admin_credentials("clement", "evg2026_admin")
|
| 182 |
+
True
|
| 183 |
+
>>> verify_admin_credentials("clement", "wrong_password")
|
| 184 |
+
False
|
| 185 |
+
"""
|
| 186 |
+
return (
|
| 187 |
+
username.lower() == settings.admin_username.lower() and
|
| 188 |
+
password == settings.admin_password
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# =============================================================================
|
| 193 |
+
# Token Payload Helpers
|
| 194 |
+
# =============================================================================
|
| 195 |
+
|
| 196 |
+
def create_participant_token_data(participant_id: int, username: str, is_groom: bool = False) -> dict:
|
| 197 |
+
"""
|
| 198 |
+
Create token payload for a participant.
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
participant_id: Participant's ID
|
| 202 |
+
username: Participant's username
|
| 203 |
+
is_groom: Whether participant is the groom
|
| 204 |
+
|
| 205 |
+
Returns:
|
| 206 |
+
Dictionary with token payload data
|
| 207 |
+
|
| 208 |
+
Example:
|
| 209 |
+
>>> data = create_participant_token_data(5, "Hugo F.")
|
| 210 |
+
>>> token = create_access_token(data)
|
| 211 |
+
"""
|
| 212 |
+
return {
|
| 213 |
+
"sub": f"participant_{participant_id}",
|
| 214 |
+
"user_id": participant_id,
|
| 215 |
+
"username": username,
|
| 216 |
+
"is_admin": False,
|
| 217 |
+
"is_groom": is_groom,
|
| 218 |
+
"type": "participant"
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def create_admin_token_data(admin_id: int, username: str) -> dict:
|
| 223 |
+
"""
|
| 224 |
+
Create token payload for an admin.
|
| 225 |
+
|
| 226 |
+
Args:
|
| 227 |
+
admin_id: Admin's ID (can be 0 for the main admin)
|
| 228 |
+
username: Admin's username
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
Dictionary with token payload data
|
| 232 |
+
|
| 233 |
+
Example:
|
| 234 |
+
>>> data = create_admin_token_data(0, "clement")
|
| 235 |
+
>>> token = create_access_token(data)
|
| 236 |
+
"""
|
| 237 |
+
return {
|
| 238 |
+
"sub": f"admin_{admin_id}",
|
| 239 |
+
"user_id": admin_id,
|
| 240 |
+
"username": username,
|
| 241 |
+
"is_admin": True,
|
| 242 |
+
"is_groom": False,
|
| 243 |
+
"type": "admin"
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def extract_user_id_from_payload(payload: dict) -> Optional[int]:
|
| 248 |
+
"""
|
| 249 |
+
Extract user ID from token payload.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
payload: Decoded JWT payload
|
| 253 |
+
|
| 254 |
+
Returns:
|
| 255 |
+
User ID if present, None otherwise
|
| 256 |
+
|
| 257 |
+
Example:
|
| 258 |
+
>>> payload = {"user_id": 5}
|
| 259 |
+
>>> extract_user_id_from_payload(payload)
|
| 260 |
+
5
|
| 261 |
+
"""
|
| 262 |
+
return payload.get("user_id")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def is_admin_token(payload: dict) -> bool:
|
| 266 |
+
"""
|
| 267 |
+
Check if token payload represents an admin user.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
payload: Decoded JWT payload
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
True if admin, False otherwise
|
| 274 |
+
|
| 275 |
+
Example:
|
| 276 |
+
>>> payload = {"is_admin": True}
|
| 277 |
+
>>> is_admin_token(payload)
|
| 278 |
+
True
|
| 279 |
+
"""
|
| 280 |
+
return payload.get("is_admin", False)
|
backend/app/websocket/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket handlers for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
This module exports WebSocket connection manager and endpoints.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from app.websocket.manager import manager, ConnectionManager
|
| 8 |
+
from app.websocket.leaderboard import (
|
| 9 |
+
leaderboard_websocket_endpoint,
|
| 10 |
+
broadcast_leaderboard_update
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
"manager",
|
| 15 |
+
"ConnectionManager",
|
| 16 |
+
"leaderboard_websocket_endpoint",
|
| 17 |
+
"broadcast_leaderboard_update",
|
| 18 |
+
]
|
backend/app/websocket/leaderboard.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket handler for real-time leaderboard updates.
|
| 3 |
+
|
| 4 |
+
Provides WebSocket endpoint for live leaderboard updates.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import WebSocket, WebSocketDisconnect
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from app.database import SessionLocal
|
| 11 |
+
from app.services import leaderboard_service
|
| 12 |
+
from app.websocket.manager import manager
|
| 13 |
+
from app.utils.logger import logger
|
| 14 |
+
from app.utils.security import verify_token
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def leaderboard_websocket_endpoint(
|
| 18 |
+
websocket: WebSocket
|
| 19 |
+
):
|
| 20 |
+
"""
|
| 21 |
+
WebSocket endpoint for real-time leaderboard updates.
|
| 22 |
+
|
| 23 |
+
Clients connect to this endpoint to receive live leaderboard updates
|
| 24 |
+
whenever points change.
|
| 25 |
+
|
| 26 |
+
**Connection:** ws://localhost:8000/ws/leaderboard?token=<jwt_token>
|
| 27 |
+
|
| 28 |
+
**Query Parameters:**
|
| 29 |
+
- `token`: JWT authentication token (required)
|
| 30 |
+
|
| 31 |
+
**Message Format (from server):**
|
| 32 |
+
```json
|
| 33 |
+
{
|
| 34 |
+
"type": "leaderboard_update",
|
| 35 |
+
"data": [
|
| 36 |
+
{
|
| 37 |
+
"id": 1,
|
| 38 |
+
"name": "Paul C.",
|
| 39 |
+
"total_points": 350,
|
| 40 |
+
"rank": 1,
|
| 41 |
+
...
|
| 42 |
+
},
|
| 43 |
+
...
|
| 44 |
+
],
|
| 45 |
+
"timestamp": "2026-06-05T16:30:00Z"
|
| 46 |
+
}
|
| 47 |
+
```
|
| 48 |
+
"""
|
| 49 |
+
# Accept the WebSocket connection first
|
| 50 |
+
await websocket.accept()
|
| 51 |
+
|
| 52 |
+
# Extract token from query parameters
|
| 53 |
+
query_params = dict(websocket.query_params)
|
| 54 |
+
token = query_params.get("token")
|
| 55 |
+
|
| 56 |
+
# Verify authentication token
|
| 57 |
+
if not token:
|
| 58 |
+
await websocket.close(code=1008, reason="Missing authentication token")
|
| 59 |
+
logger.warning("WebSocket connection rejected: missing token")
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
payload = verify_token(token)
|
| 64 |
+
logger.info(f"WebSocket authenticated: user_id={payload.get('user_id')}, is_admin={payload.get('is_admin')}")
|
| 65 |
+
except Exception as e:
|
| 66 |
+
await websocket.close(code=1008, reason="Invalid authentication token")
|
| 67 |
+
logger.warning(f"WebSocket connection rejected: invalid token - {str(e)}")
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
# Register the connection with the manager
|
| 71 |
+
manager.active_connections.setdefault("leaderboard", []).append(websocket)
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
# Send initial leaderboard data
|
| 75 |
+
# Create a new DB session for this operation only
|
| 76 |
+
db = SessionLocal()
|
| 77 |
+
try:
|
| 78 |
+
leaderboard = leaderboard_service.get_leaderboard(db, include_today_points=True)
|
| 79 |
+
await manager.send_personal_message(
|
| 80 |
+
{
|
| 81 |
+
"type": "leaderboard_initial",
|
| 82 |
+
"data": [entry.model_dump() for entry in leaderboard],
|
| 83 |
+
"message": "Connected to leaderboard updates"
|
| 84 |
+
},
|
| 85 |
+
websocket
|
| 86 |
+
)
|
| 87 |
+
finally:
|
| 88 |
+
db.close()
|
| 89 |
+
|
| 90 |
+
# Keep connection alive and handle incoming messages
|
| 91 |
+
while True:
|
| 92 |
+
# Wait for messages from client (ping/pong for connection keep-alive)
|
| 93 |
+
data = await websocket.receive_text()
|
| 94 |
+
|
| 95 |
+
# Handle different message types
|
| 96 |
+
if data == "ping":
|
| 97 |
+
await websocket.send_text("pong")
|
| 98 |
+
elif data == "refresh":
|
| 99 |
+
# Client requested a leaderboard refresh
|
| 100 |
+
# Create a new DB session for this operation only
|
| 101 |
+
db = SessionLocal()
|
| 102 |
+
try:
|
| 103 |
+
leaderboard = leaderboard_service.get_leaderboard(db, include_today_points=True)
|
| 104 |
+
await manager.send_personal_message(
|
| 105 |
+
{
|
| 106 |
+
"type": "leaderboard_update",
|
| 107 |
+
"data": [entry.model_dump() for entry in leaderboard]
|
| 108 |
+
},
|
| 109 |
+
websocket
|
| 110 |
+
)
|
| 111 |
+
finally:
|
| 112 |
+
db.close()
|
| 113 |
+
|
| 114 |
+
except WebSocketDisconnect:
|
| 115 |
+
manager.disconnect(websocket, "leaderboard")
|
| 116 |
+
logger.info("Leaderboard WebSocket disconnected")
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"WebSocket error: {str(e)}")
|
| 119 |
+
manager.disconnect(websocket, "leaderboard")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
async def broadcast_leaderboard_update(db: Session):
|
| 123 |
+
"""
|
| 124 |
+
Broadcast updated leaderboard to all connected clients.
|
| 125 |
+
|
| 126 |
+
This function should be called whenever points change
|
| 127 |
+
(after challenge validation, manual points adjustment, etc.)
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
db: Database session
|
| 131 |
+
|
| 132 |
+
Example:
|
| 133 |
+
>>> # After awarding points
|
| 134 |
+
>>> await broadcast_leaderboard_update(db)
|
| 135 |
+
"""
|
| 136 |
+
from datetime import datetime
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
leaderboard = leaderboard_service.get_leaderboard(db, include_today_points=True)
|
| 140 |
+
|
| 141 |
+
await manager.broadcast(
|
| 142 |
+
{
|
| 143 |
+
"type": "leaderboard_update",
|
| 144 |
+
"data": [entry.model_dump() for entry in leaderboard],
|
| 145 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 146 |
+
},
|
| 147 |
+
connection_type="leaderboard"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
logger.info("Broadcasted leaderboard update to all clients")
|
| 151 |
+
|
| 152 |
+
except Exception as e:
|
| 153 |
+
logger.error(f"Failed to broadcast leaderboard update: {str(e)}")
|
backend/app/websocket/manager.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket connection manager for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Manages WebSocket connections for real-time features.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from fastapi import WebSocket
|
| 8 |
+
from typing import List, Dict
|
| 9 |
+
from app.utils.logger import logger
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ConnectionManager:
|
| 13 |
+
"""
|
| 14 |
+
Manages WebSocket connections for real-time communication.
|
| 15 |
+
|
| 16 |
+
Supports multiple connection types (leaderboard, notifications, etc.)
|
| 17 |
+
and allows broadcasting messages to all connected clients or specific groups.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
"""Initialize the connection manager with empty connection lists."""
|
| 22 |
+
# Store active connections by type
|
| 23 |
+
self.active_connections: Dict[str, List[WebSocket]] = {
|
| 24 |
+
"leaderboard": [],
|
| 25 |
+
"notifications": []
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async def connect(self, websocket: WebSocket, connection_type: str = "leaderboard"):
|
| 29 |
+
"""
|
| 30 |
+
Accept a new WebSocket connection and add it to the active connections.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
websocket: WebSocket connection to accept
|
| 34 |
+
connection_type: Type of connection (leaderboard, notifications, etc.)
|
| 35 |
+
"""
|
| 36 |
+
await websocket.accept()
|
| 37 |
+
|
| 38 |
+
if connection_type not in self.active_connections:
|
| 39 |
+
self.active_connections[connection_type] = []
|
| 40 |
+
|
| 41 |
+
self.active_connections[connection_type].append(websocket)
|
| 42 |
+
|
| 43 |
+
logger.info(
|
| 44 |
+
f"WebSocket connected",
|
| 45 |
+
extra={
|
| 46 |
+
"connection_type": connection_type,
|
| 47 |
+
"active_count": len(self.active_connections[connection_type])
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def disconnect(self, websocket: WebSocket, connection_type: str = "leaderboard"):
|
| 52 |
+
"""
|
| 53 |
+
Remove a WebSocket connection from active connections.
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
websocket: WebSocket connection to remove
|
| 57 |
+
connection_type: Type of connection
|
| 58 |
+
"""
|
| 59 |
+
if connection_type in self.active_connections:
|
| 60 |
+
if websocket in self.active_connections[connection_type]:
|
| 61 |
+
self.active_connections[connection_type].remove(websocket)
|
| 62 |
+
|
| 63 |
+
logger.info(
|
| 64 |
+
f"WebSocket disconnected",
|
| 65 |
+
extra={
|
| 66 |
+
"connection_type": connection_type,
|
| 67 |
+
"active_count": len(self.active_connections[connection_type])
|
| 68 |
+
}
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
async def send_personal_message(self, message: dict, websocket: WebSocket):
|
| 72 |
+
"""
|
| 73 |
+
Send a message to a specific WebSocket connection.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
message: Message data to send (will be JSON encoded)
|
| 77 |
+
websocket: Target WebSocket connection
|
| 78 |
+
"""
|
| 79 |
+
try:
|
| 80 |
+
await websocket.send_json(message)
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"Failed to send personal message: {str(e)}")
|
| 83 |
+
|
| 84 |
+
async def broadcast(self, message: dict, connection_type: str = "leaderboard"):
|
| 85 |
+
"""
|
| 86 |
+
Broadcast a message to all connections of a specific type.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
message: Message data to send (will be JSON encoded)
|
| 90 |
+
connection_type: Type of connections to broadcast to
|
| 91 |
+
|
| 92 |
+
Example:
|
| 93 |
+
>>> manager = ConnectionManager()
|
| 94 |
+
>>> await manager.broadcast(
|
| 95 |
+
>>> {"type": "leaderboard_update", "data": leaderboard},
|
| 96 |
+
>>> connection_type="leaderboard"
|
| 97 |
+
>>> )
|
| 98 |
+
"""
|
| 99 |
+
if connection_type not in self.active_connections:
|
| 100 |
+
return
|
| 101 |
+
|
| 102 |
+
# Get list of connections to avoid modification during iteration
|
| 103 |
+
connections = list(self.active_connections[connection_type])
|
| 104 |
+
|
| 105 |
+
# Send to all active connections
|
| 106 |
+
disconnected = []
|
| 107 |
+
for connection in connections:
|
| 108 |
+
try:
|
| 109 |
+
await connection.send_json(message)
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Failed to broadcast to connection: {str(e)}")
|
| 112 |
+
disconnected.append(connection)
|
| 113 |
+
|
| 114 |
+
# Remove disconnected connections
|
| 115 |
+
for connection in disconnected:
|
| 116 |
+
self.disconnect(connection, connection_type)
|
| 117 |
+
|
| 118 |
+
def get_connection_count(self, connection_type: str = None) -> int:
|
| 119 |
+
"""
|
| 120 |
+
Get the number of active connections.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
connection_type: Specific connection type, or None for total
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Number of active connections
|
| 127 |
+
"""
|
| 128 |
+
if connection_type:
|
| 129 |
+
return len(self.active_connections.get(connection_type, []))
|
| 130 |
+
else:
|
| 131 |
+
return sum(len(conns) for conns in self.active_connections.values())
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# Global connection manager instance
|
| 135 |
+
manager = ConnectionManager()
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# EVG ULTIMATE TEAM - Backend Dependencies
|
| 3 |
+
# =============================================================================
|
| 4 |
+
|
| 5 |
+
# -----------------------------------------------------------------------------
|
| 6 |
+
# Core Framework
|
| 7 |
+
# -----------------------------------------------------------------------------
|
| 8 |
+
fastapi==0.104.1 # Modern, fast web framework for building APIs
|
| 9 |
+
uvicorn[standard]==0.24.0 # ASGI server for running FastAPI
|
| 10 |
+
python-multipart==0.0.6 # Required for form data and file uploads
|
| 11 |
+
|
| 12 |
+
# -----------------------------------------------------------------------------
|
| 13 |
+
# Database & ORM
|
| 14 |
+
# -----------------------------------------------------------------------------
|
| 15 |
+
sqlalchemy==2.0.23 # SQL toolkit and Object-Relational Mapping
|
| 16 |
+
alembic==1.12.1 # Database migration tool for SQLAlchemy
|
| 17 |
+
aiosqlite==0.19.0 # Async SQLite support for SQLAlchemy
|
| 18 |
+
|
| 19 |
+
# -----------------------------------------------------------------------------
|
| 20 |
+
# Data Validation & Serialization
|
| 21 |
+
# -----------------------------------------------------------------------------
|
| 22 |
+
pydantic==2.5.0 # Data validation using Python type annotations
|
| 23 |
+
pydantic-settings==2.1.0 # Settings management using Pydantic
|
| 24 |
+
|
| 25 |
+
# -----------------------------------------------------------------------------
|
| 26 |
+
# Authentication & Security
|
| 27 |
+
# -----------------------------------------------------------------------------
|
| 28 |
+
python-jose[cryptography]==3.3.0 # JWT token generation and verification
|
| 29 |
+
passlib[bcrypt]==1.7.4 # Password hashing utilities
|
| 30 |
+
python-dotenv==1.0.0 # Load environment variables from .env file
|
| 31 |
+
|
| 32 |
+
# -----------------------------------------------------------------------------
|
| 33 |
+
# WebSocket Support
|
| 34 |
+
# -----------------------------------------------------------------------------
|
| 35 |
+
websockets==12.0 # WebSocket client and server implementation
|
| 36 |
+
|
| 37 |
+
# -----------------------------------------------------------------------------
|
| 38 |
+
# Utilities
|
| 39 |
+
# -----------------------------------------------------------------------------
|
| 40 |
+
python-dateutil==2.8.2 # Extensions to the standard datetime module
|
| 41 |
+
|
| 42 |
+
# -----------------------------------------------------------------------------
|
| 43 |
+
# Development & Testing (optional, install with: pip install -r requirements.txt -r requirements-dev.txt)
|
| 44 |
+
# -----------------------------------------------------------------------------
|
| 45 |
+
# pytest==7.4.3 # Testing framework
|
| 46 |
+
# pytest-asyncio==0.21.1 # Async support for pytest
|
| 47 |
+
# httpx==0.25.2 # HTTP client for testing FastAPI
|
| 48 |
+
# black==23.11.0 # Code formatter
|
| 49 |
+
# flake8==6.1.0 # Code linter
|
| 50 |
+
# mypy==1.7.1 # Static type checker
|
backend/seed_database.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database seeding script for EVG Ultimate Team.
|
| 3 |
+
|
| 4 |
+
Populates the database with the 13 participants and sample challenges
|
| 5 |
+
for Paul's bachelor party.
|
| 6 |
+
|
| 7 |
+
Run this script once to initialize the database with test data.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python seed_database.py
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from app.database import SessionLocal, init_db, reset_db
|
| 14 |
+
from app.models import Participant, Challenge, ChallengeType, ChallengeStatus
|
| 15 |
+
from app.utils.logger import logger
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def seed_participants(db):
|
| 19 |
+
"""
|
| 20 |
+
Seed the database with all 13 participants.
|
| 21 |
+
|
| 22 |
+
Participants (from the user's specification):
|
| 23 |
+
1. Paul C. - The groom
|
| 24 |
+
2. Clément P. - Admin and wedding witness
|
| 25 |
+
3. Paul J. - Wedding witness
|
| 26 |
+
4. Hugo F. - Wedding witness
|
| 27 |
+
5. Théo C. - Groom's brother and wedding witness
|
| 28 |
+
6. Antonin M. - Groom's cousin and wedding witness
|
| 29 |
+
7. Philippe C. - Groom's cousin and wedding witness
|
| 30 |
+
8. Lancelot M. - Wedding witness
|
| 31 |
+
9. Vianney D.
|
| 32 |
+
10. Thomas S.
|
| 33 |
+
11. Martin L.
|
| 34 |
+
12. Guillaume V.
|
| 35 |
+
13. Adrien M.
|
| 36 |
+
"""
|
| 37 |
+
logger.info("Seeding participants...")
|
| 38 |
+
|
| 39 |
+
participants_data = [
|
| 40 |
+
{"name": "Paul C.", "is_groom": True, "avatar_url": None},
|
| 41 |
+
{"name": "Clément P.", "is_groom": False, "avatar_url": None}, # Admin
|
| 42 |
+
{"name": "Paul J.", "is_groom": False, "avatar_url": None},
|
| 43 |
+
{"name": "Hugo F.", "is_groom": False, "avatar_url": None},
|
| 44 |
+
{"name": "Théo C.", "is_groom": False, "avatar_url": None},
|
| 45 |
+
{"name": "Antonin M.", "is_groom": False, "avatar_url": None},
|
| 46 |
+
{"name": "Philippe C.", "is_groom": False, "avatar_url": None},
|
| 47 |
+
{"name": "Lancelot M.", "is_groom": False, "avatar_url": None},
|
| 48 |
+
{"name": "Vianney D.", "is_groom": False, "avatar_url": None},
|
| 49 |
+
{"name": "Thomas S.", "is_groom": False, "avatar_url": None},
|
| 50 |
+
{"name": "Martin L.", "is_groom": False, "avatar_url": None},
|
| 51 |
+
{"name": "Guillaume V.", "is_groom": False, "avatar_url": None},
|
| 52 |
+
{"name": "Adrien M.", "is_groom": False, "avatar_url": None},
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
for data in participants_data:
|
| 56 |
+
participant = Participant(
|
| 57 |
+
name=data["name"],
|
| 58 |
+
is_groom=data["is_groom"],
|
| 59 |
+
avatar_url=data["avatar_url"],
|
| 60 |
+
total_points=0,
|
| 61 |
+
current_packs={"bronze": 0, "silver": 0, "gold": 0, "ultimate": 0}
|
| 62 |
+
)
|
| 63 |
+
db.add(participant)
|
| 64 |
+
|
| 65 |
+
db.commit()
|
| 66 |
+
logger.info(f"✓ Created {len(participants_data)} participants")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def seed_challenges(db):
|
| 70 |
+
"""
|
| 71 |
+
Seed the database with sample challenges based on CLAUDE.md specifications.
|
| 72 |
+
"""
|
| 73 |
+
logger.info("Seeding challenges...")
|
| 74 |
+
|
| 75 |
+
challenges_data = [
|
| 76 |
+
# Individual Challenges (20-50 pts)
|
| 77 |
+
{
|
| 78 |
+
"title": "Convince a stranger you're a Red Bull sales rep",
|
| 79 |
+
"description": "Approach a stranger and successfully convince them you work for Red Bull. Must last at least 2 minutes.",
|
| 80 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 81 |
+
"points": 30,
|
| 82 |
+
"status": ChallengeStatus.PENDING
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
"title": "Win a 1v1 FIFA match against Paul",
|
| 86 |
+
"description": "Challenge Paul to a FIFA match and win. Best of 3 games.",
|
| 87 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 88 |
+
"points": 50,
|
| 89 |
+
"status": ChallengeStatus.PENDING
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"title": "Complete a rugby transformation",
|
| 93 |
+
"description": "Score a try in the touch rugby game with proper technique.",
|
| 94 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 95 |
+
"points": 40,
|
| 96 |
+
"status": ChallengeStatus.PENDING
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"title": "Finish first in go-kart racing",
|
| 100 |
+
"description": "Win the go-kart race against all other participants.",
|
| 101 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 102 |
+
"points": 50,
|
| 103 |
+
"status": ChallengeStatus.PENDING
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"title": "Give a 2-minute speech about why Paul is the best groom",
|
| 107 |
+
"description": "Deliver an impromptu 2-minute speech praising Paul. Must be heartfelt and entertaining.",
|
| 108 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 109 |
+
"points": 35,
|
| 110 |
+
"status": ChallengeStatus.PENDING
|
| 111 |
+
},
|
| 112 |
+
{
|
| 113 |
+
"title": "Order a shot mimicking Paul's accent",
|
| 114 |
+
"description": "Successfully order a shot at the bar using Paul's accent. Bartender must not notice.",
|
| 115 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 116 |
+
"points": 25,
|
| 117 |
+
"status": ChallengeStatus.PENDING
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"title": "Pitch an absurd item to a stranger",
|
| 121 |
+
"description": "Use Paul's commercial skills to pitch a ridiculous product to a stranger (e.g., invisible socks). Must last 2 minutes.",
|
| 122 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 123 |
+
"points": 30,
|
| 124 |
+
"status": ChallengeStatus.PENDING
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"title": "Negotiate a discount at the restaurant",
|
| 128 |
+
"description": "Successfully negotiate at least a 10% discount on the bill using your charm.",
|
| 129 |
+
"type": ChallengeType.INDIVIDUAL,
|
| 130 |
+
"points": 45,
|
| 131 |
+
"status": ChallengeStatus.PENDING
|
| 132 |
+
},
|
| 133 |
+
|
| 134 |
+
# Team Challenges (100 pts shared)
|
| 135 |
+
{
|
| 136 |
+
"title": "Win the padel tournament",
|
| 137 |
+
"description": "Your team must win the padel tournament on Saturday afternoon.",
|
| 138 |
+
"type": ChallengeType.TEAM,
|
| 139 |
+
"points": 100,
|
| 140 |
+
"status": ChallengeStatus.PENDING
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"title": "Win the football match",
|
| 144 |
+
"description": "Your team must win the football match on Saturday afternoon.",
|
| 145 |
+
"type": ChallengeType.TEAM,
|
| 146 |
+
"points": 100,
|
| 147 |
+
"status": ChallengeStatus.PENDING
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"title": "Finish champagne bottle under 5 minutes",
|
| 151 |
+
"description": "Your team of 4 must finish a full champagne bottle in under 5 minutes.",
|
| 152 |
+
"type": ChallengeType.TEAM,
|
| 153 |
+
"points": 100,
|
| 154 |
+
"status": ChallengeStatus.PENDING
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"title": "Complete a 5-person karaoke",
|
| 158 |
+
"description": "Get 5 people to perform a full karaoke song together. Must be a classic.",
|
| 159 |
+
"type": ChallengeType.TEAM,
|
| 160 |
+
"points": 100,
|
| 161 |
+
"status": ChallengeStatus.PENDING
|
| 162 |
+
},
|
| 163 |
+
|
| 164 |
+
# Secret Challenges (50-100 pts)
|
| 165 |
+
{
|
| 166 |
+
"title": "Make Paul laugh during dinner",
|
| 167 |
+
"description": "SECRET: Next person to make Paul genuinely laugh during dinner wins. Admin will reveal this at dinner.",
|
| 168 |
+
"type": ChallengeType.SECRET,
|
| 169 |
+
"points": 50,
|
| 170 |
+
"status": ChallengeStatus.PENDING
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
"title": "Spot the reference",
|
| 174 |
+
"description": "SECRET: First person to notice and mention the hidden Toulouse Stade reference wins.",
|
| 175 |
+
"type": ChallengeType.SECRET,
|
| 176 |
+
"points": 75,
|
| 177 |
+
"status": ChallengeStatus.PENDING
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
"title": "Midnight champion",
|
| 181 |
+
"description": "SECRET: Last person awake on Friday night wins bonus points.",
|
| 182 |
+
"type": ChallengeType.SECRET,
|
| 183 |
+
"points": 100,
|
| 184 |
+
"status": ChallengeStatus.PENDING
|
| 185 |
+
},
|
| 186 |
+
|
| 187 |
+
# Penalties (would be created by admin as needed)
|
| 188 |
+
# These are just examples - admin creates them on the fly
|
| 189 |
+
]
|
| 190 |
+
|
| 191 |
+
for data in challenges_data:
|
| 192 |
+
challenge = Challenge(
|
| 193 |
+
title=data["title"],
|
| 194 |
+
description=data["description"],
|
| 195 |
+
type=data["type"],
|
| 196 |
+
points=data["points"],
|
| 197 |
+
status=data["status"]
|
| 198 |
+
)
|
| 199 |
+
db.add(challenge)
|
| 200 |
+
|
| 201 |
+
db.commit()
|
| 202 |
+
logger.info(f"✓ Created {len(challenges_data)} challenges")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def main():
|
| 206 |
+
"""
|
| 207 |
+
Main seeding function.
|
| 208 |
+
|
| 209 |
+
WARNING: This will reset the entire database!
|
| 210 |
+
"""
|
| 211 |
+
logger.info("=" * 80)
|
| 212 |
+
logger.info("EVG ULTIMATE TEAM - Database Seeding")
|
| 213 |
+
logger.info("=" * 80)
|
| 214 |
+
logger.warning("This will RESET the database and create fresh data!")
|
| 215 |
+
logger.info("")
|
| 216 |
+
|
| 217 |
+
# Confirm before proceeding
|
| 218 |
+
response = input("Continue? (yes/no): ")
|
| 219 |
+
if response.lower() not in ['yes', 'y']:
|
| 220 |
+
logger.info("Seeding cancelled")
|
| 221 |
+
return
|
| 222 |
+
|
| 223 |
+
# Reset database (drop all tables and recreate)
|
| 224 |
+
logger.info("\nResetting database...")
|
| 225 |
+
reset_db()
|
| 226 |
+
|
| 227 |
+
# Create database session
|
| 228 |
+
db = SessionLocal()
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
# Seed data
|
| 232 |
+
seed_participants(db)
|
| 233 |
+
seed_challenges(db)
|
| 234 |
+
|
| 235 |
+
logger.info("")
|
| 236 |
+
logger.info("=" * 80)
|
| 237 |
+
logger.info("✓ Database seeding completed successfully!")
|
| 238 |
+
logger.info("=" * 80)
|
| 239 |
+
logger.info("")
|
| 240 |
+
logger.info("Summary:")
|
| 241 |
+
logger.info(" - 13 participants created (Paul C. is the groom)")
|
| 242 |
+
logger.info(" - 15 sample challenges created")
|
| 243 |
+
logger.info("")
|
| 244 |
+
logger.info("Admin credentials (from .env):")
|
| 245 |
+
logger.info(" Username: clement")
|
| 246 |
+
logger.info(" Password: evg2026_admin")
|
| 247 |
+
logger.info("")
|
| 248 |
+
logger.info("You can now start the backend server:")
|
| 249 |
+
logger.info(" python -m app.main")
|
| 250 |
+
logger.info(" OR")
|
| 251 |
+
logger.info(" uvicorn app.main:app --reload")
|
| 252 |
+
logger.info("=" * 80)
|
| 253 |
+
|
| 254 |
+
except Exception as e:
|
| 255 |
+
logger.error(f"Error during seeding: {str(e)}")
|
| 256 |
+
db.rollback()
|
| 257 |
+
raise
|
| 258 |
+
finally:
|
| 259 |
+
db.close()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
if __name__ == "__main__":
|
| 263 |
+
main()
|
backend/tests/__init__.py
ADDED
|
File without changes
|
frontend/.env.example
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# EVG ULTIMATE TEAM - Frontend Environment Configuration
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# Copy this file to .env and update with your actual values
|
| 5 |
+
# NEVER commit .env to version control
|
| 6 |
+
|
| 7 |
+
# -----------------------------------------------------------------------------
|
| 8 |
+
# API Configuration
|
| 9 |
+
# -----------------------------------------------------------------------------
|
| 10 |
+
# Backend API base URL
|
| 11 |
+
# Development: http://localhost:8000
|
| 12 |
+
# Production: https://your-backend-api.com
|
| 13 |
+
VITE_API_URL=http://localhost:8000
|
| 14 |
+
|
| 15 |
+
# WebSocket URL (usually same as API URL but with ws:// or wss://)
|
| 16 |
+
# Development: ws://localhost:8000
|
| 17 |
+
# Production: wss://your-backend-api.com
|
| 18 |
+
VITE_WS_URL=ws://localhost:8000
|
| 19 |
+
|
| 20 |
+
# -----------------------------------------------------------------------------
|
| 21 |
+
# Application Configuration
|
| 22 |
+
# -----------------------------------------------------------------------------
|
| 23 |
+
# Application environment: development, staging, production
|
| 24 |
+
VITE_ENVIRONMENT=development
|
| 25 |
+
|
| 26 |
+
# Enable debug mode (shows detailed error messages)
|
| 27 |
+
VITE_DEBUG=true
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# EVG ULTIMATE TEAM - Frontend .gitignore
|
| 3 |
+
# =============================================================================
|
| 4 |
+
|
| 5 |
+
# -----------------------------------------------------------------------------
|
| 6 |
+
# Environment & Secrets
|
| 7 |
+
# -----------------------------------------------------------------------------
|
| 8 |
+
.env
|
| 9 |
+
.env.local
|
| 10 |
+
.env.*.local
|
| 11 |
+
*.env
|
| 12 |
+
|
| 13 |
+
# -----------------------------------------------------------------------------
|
| 14 |
+
# Dependencies
|
| 15 |
+
# -----------------------------------------------------------------------------
|
| 16 |
+
node_modules/
|
| 17 |
+
package-lock.json
|
| 18 |
+
yarn.lock
|
| 19 |
+
pnpm-lock.yaml
|
| 20 |
+
|
| 21 |
+
# -----------------------------------------------------------------------------
|
| 22 |
+
# Build Output
|
| 23 |
+
# -----------------------------------------------------------------------------
|
| 24 |
+
dist/
|
| 25 |
+
build/
|
| 26 |
+
.vite/
|
| 27 |
+
*.local
|
| 28 |
+
|
| 29 |
+
# -----------------------------------------------------------------------------
|
| 30 |
+
# Logs
|
| 31 |
+
# -----------------------------------------------------------------------------
|
| 32 |
+
logs/
|
| 33 |
+
*.log
|
| 34 |
+
npm-debug.log*
|
| 35 |
+
yarn-debug.log*
|
| 36 |
+
yarn-error.log*
|
| 37 |
+
pnpm-debug.log*
|
| 38 |
+
|
| 39 |
+
# -----------------------------------------------------------------------------
|
| 40 |
+
# IDE & Editors
|
| 41 |
+
# -----------------------------------------------------------------------------
|
| 42 |
+
.vscode/
|
| 43 |
+
.idea/
|
| 44 |
+
*.swp
|
| 45 |
+
*.swo
|
| 46 |
+
*~
|
| 47 |
+
.DS_Store
|
| 48 |
+
|
| 49 |
+
# -----------------------------------------------------------------------------
|
| 50 |
+
# Testing
|
| 51 |
+
# -----------------------------------------------------------------------------
|
| 52 |
+
coverage/
|
| 53 |
+
.nyc_output/
|
| 54 |
+
|
| 55 |
+
# -----------------------------------------------------------------------------
|
| 56 |
+
# Other
|
| 57 |
+
# -----------------------------------------------------------------------------
|
| 58 |
+
*.bak
|
| 59 |
+
*.tmp
|
| 60 |
+
.eslintcache
|
frontend/README.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# EVG Ultimate Team - Frontend
|
| 2 |
+
|
| 3 |
+
React + TypeScript frontend for Paul's bachelor party gamification app.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- 🎨 **PSG/FIFA Theme** - Custom Tailwind design with Paris Saint-Germain and FIFA Ultimate Team colors
|
| 8 |
+
- 🔐 **Simple Authentication** - Username-only login with secure token management
|
| 9 |
+
- 📊 **Live Leaderboard** - Real-time updates via WebSocket
|
| 10 |
+
- 🎯 **Challenge Tracking** - View and track all challenges with status
|
| 11 |
+
- 👑 **Admin Dashboard** - Full control panel for Clément (organizer)
|
| 12 |
+
- 📱 **Mobile-First** - Responsive design optimized for phones
|
| 13 |
+
|
| 14 |
+
## Tech Stack
|
| 15 |
+
|
| 16 |
+
- **Framework**: React 18 + TypeScript
|
| 17 |
+
- **Styling**: TailwindCSS with custom PSG/FIFA theme
|
| 18 |
+
- **Routing**: React Router v6
|
| 19 |
+
- **HTTP Client**: Axios with interceptors
|
| 20 |
+
- **State Management**: React Context + Custom Hooks
|
| 21 |
+
- **Real-time**: WebSocket for live updates
|
| 22 |
+
- **Build Tool**: Vite
|
| 23 |
+
- **Date Formatting**: date-fns
|
| 24 |
+
|
| 25 |
+
## Project Structure
|
| 26 |
+
|
| 27 |
+
```
|
| 28 |
+
frontend/src/
|
| 29 |
+
├── components/
|
| 30 |
+
│ └── common/ # Reusable UI components
|
| 31 |
+
├── pages/ # Page components
|
| 32 |
+
├── services/ # API client & services
|
| 33 |
+
├── hooks/ # Custom React hooks
|
| 34 |
+
├── context/ # React Context (Auth)
|
| 35 |
+
├── types/ # TypeScript type definitions
|
| 36 |
+
├── utils/ # Utility functions
|
| 37 |
+
├── styles/ # Global styles
|
| 38 |
+
├── App.tsx # Main app with routing
|
| 39 |
+
└── main.tsx # Entry point
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## Setup Instructions
|
| 43 |
+
|
| 44 |
+
### 1. Prerequisites
|
| 45 |
+
|
| 46 |
+
- Node.js 18+ and npm 9+
|
| 47 |
+
- Backend server running on http://localhost:8000
|
| 48 |
+
|
| 49 |
+
### 2. Installation
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
# Navigate to frontend directory
|
| 53 |
+
cd frontend
|
| 54 |
+
|
| 55 |
+
# Install dependencies
|
| 56 |
+
npm install
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### 3. Environment Configuration
|
| 60 |
+
|
| 61 |
+
The `.env` file is already configured for local development:
|
| 62 |
+
|
| 63 |
+
```env
|
| 64 |
+
VITE_API_URL=http://localhost:8000
|
| 65 |
+
VITE_WS_URL=ws://localhost:8000
|
| 66 |
+
VITE_ENVIRONMENT=development
|
| 67 |
+
VITE_DEBUG=true
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
### 4. Run Development Server
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
# Start development server
|
| 74 |
+
npm run dev
|
| 75 |
+
|
| 76 |
+
# Server will start at http://localhost:5173
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
### 5. Build for Production
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
# Create production build
|
| 83 |
+
npm run build
|
| 84 |
+
|
| 85 |
+
# Preview production build
|
| 86 |
+
npm run preview
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
## Available Scripts
|
| 90 |
+
|
| 91 |
+
- `npm run dev` - Start development server with hot reload
|
| 92 |
+
- `npm run build` - Create production build
|
| 93 |
+
- `npm run preview` - Preview production build
|
| 94 |
+
- `npm run lint` - Run ESLint
|
| 95 |
+
- `npm run format` - Format code with Prettier
|
| 96 |
+
|
| 97 |
+
## Usage
|
| 98 |
+
|
| 99 |
+
### Participant Login
|
| 100 |
+
|
| 101 |
+
1. Navigate to http://localhost:5173
|
| 102 |
+
2. Enter your name (one of the 13 participants)
|
| 103 |
+
3. Click "Login"
|
| 104 |
+
|
| 105 |
+
**Example Participants:**
|
| 106 |
+
- Paul C. (The Groom)
|
| 107 |
+
- Clément P.
|
| 108 |
+
- Hugo F.
|
| 109 |
+
- ...and 10 more
|
| 110 |
+
|
| 111 |
+
### Admin Login
|
| 112 |
+
|
| 113 |
+
1. Click "Admin login" link
|
| 114 |
+
2. Enter credentials:
|
| 115 |
+
- Username: `clement`
|
| 116 |
+
- Password: `evg2026_admin`
|
| 117 |
+
3. Click "Admin Login"
|
| 118 |
+
|
| 119 |
+
## Features Guide
|
| 120 |
+
|
| 121 |
+
### Home Page
|
| 122 |
+
- Your current points
|
| 123 |
+
- Top 3 podium
|
| 124 |
+
- Today's leader
|
| 125 |
+
- Quick navigation
|
| 126 |
+
|
| 127 |
+
### Leaderboard
|
| 128 |
+
- Real-time rankings (updates automatically)
|
| 129 |
+
- Live connection indicator
|
| 130 |
+
- Podium highlighting (gold/silver/bronze)
|
| 131 |
+
- Points earned today
|
| 132 |
+
|
| 133 |
+
### Challenges
|
| 134 |
+
- View all available challenges
|
| 135 |
+
- See active challenges
|
| 136 |
+
- Check completed challenges
|
| 137 |
+
- Points for each challenge
|
| 138 |
+
|
| 139 |
+
### Admin Dashboard (Admin Only)
|
| 140 |
+
- Validate challenge completions
|
| 141 |
+
- Add/subtract points manually
|
| 142 |
+
- Manage all participants
|
| 143 |
+
|
| 144 |
+
## Design System
|
| 145 |
+
|
| 146 |
+
### Colors
|
| 147 |
+
|
| 148 |
+
**PSG Colors:**
|
| 149 |
+
- Blue: `#004170`
|
| 150 |
+
- Red: `#DA291C`
|
| 151 |
+
- Navy: `#001E41`
|
| 152 |
+
|
| 153 |
+
**FIFA Colors:**
|
| 154 |
+
- Gold: `#D4AF37`
|
| 155 |
+
- Silver: `#C0C0C0`
|
| 156 |
+
- Bronze: `#CD7F32`
|
| 157 |
+
- Green: `#00FF41`
|
| 158 |
+
|
| 159 |
+
### Components
|
| 160 |
+
|
| 161 |
+
All components use the custom Tailwind theme:
|
| 162 |
+
|
| 163 |
+
```tsx
|
| 164 |
+
import { Button, Card, Input } from '@components/common';
|
| 165 |
+
|
| 166 |
+
<Button variant="primary">Click me</Button>
|
| 167 |
+
<Card>Content</Card>
|
| 168 |
+
<Input label="Name" placeholder="Enter name" />
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
## WebSocket Connection
|
| 172 |
+
|
| 173 |
+
The app connects to WebSocket for real-time leaderboard updates:
|
| 174 |
+
|
| 175 |
+
- **Endpoint**: `ws://localhost:8000/ws/leaderboard`
|
| 176 |
+
- **Auto-reconnect**: Yes (every 3 seconds)
|
| 177 |
+
- **Live indicator**: Shows connection status
|
| 178 |
+
|
| 179 |
+
## Troubleshooting
|
| 180 |
+
|
| 181 |
+
### Port Already in Use
|
| 182 |
+
|
| 183 |
+
```bash
|
| 184 |
+
# Change port in vite.config.ts or use:
|
| 185 |
+
npm run dev -- --port 3000
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### API Connection Failed
|
| 189 |
+
|
| 190 |
+
1. Ensure backend is running on http://localhost:8000
|
| 191 |
+
2. Check `.env` file has correct `VITE_API_URL`
|
| 192 |
+
3. Check browser console for errors
|
| 193 |
+
|
| 194 |
+
### WebSocket Not Connecting
|
| 195 |
+
|
| 196 |
+
1. Ensure backend WebSocket endpoint is running
|
| 197 |
+
2. Check `VITE_WS_URL` in `.env`
|
| 198 |
+
3. Check browser console for WebSocket errors
|
| 199 |
+
|
| 200 |
+
### Build Errors
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
# Clear node_modules and reinstall
|
| 204 |
+
rm -rf node_modules package-lock.json
|
| 205 |
+
npm install
|
| 206 |
+
|
| 207 |
+
# Or try with --force
|
| 208 |
+
npm install --force
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
## Development Tips
|
| 212 |
+
|
| 213 |
+
### Adding New Pages
|
| 214 |
+
|
| 215 |
+
1. Create component in `src/pages/`
|
| 216 |
+
2. Add route in `src/App.tsx`
|
| 217 |
+
3. Add navigation link in Layout component
|
| 218 |
+
|
| 219 |
+
### Adding New API Endpoints
|
| 220 |
+
|
| 221 |
+
1. Add function to appropriate service in `src/services/`
|
| 222 |
+
2. Use in components with useState/useEffect or custom hooks
|
| 223 |
+
|
| 224 |
+
### Creating Custom Hooks
|
| 225 |
+
|
| 226 |
+
1. Create file in `src/hooks/`
|
| 227 |
+
2. Follow existing patterns (useLeaderboard, useChallenges)
|
| 228 |
+
3. Export from component that needs it
|
| 229 |
+
|
| 230 |
+
## Production Deployment
|
| 231 |
+
|
| 232 |
+
### Build and Deploy
|
| 233 |
+
|
| 234 |
+
```bash
|
| 235 |
+
# Build for production
|
| 236 |
+
npm run build
|
| 237 |
+
|
| 238 |
+
# Deploy dist/ folder to:
|
| 239 |
+
# - Vercel: vercel --prod
|
| 240 |
+
# - Netlify: netlify deploy --prod
|
| 241 |
+
# - Any static hosting service
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
### Environment Variables for Production
|
| 245 |
+
|
| 246 |
+
Update `.env` for production:
|
| 247 |
+
|
| 248 |
+
```env
|
| 249 |
+
VITE_API_URL=https://your-backend-api.com
|
| 250 |
+
VITE_WS_URL=wss://your-backend-api.com
|
| 251 |
+
VITE_ENVIRONMENT=production
|
| 252 |
+
VITE_DEBUG=false
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
## Browser Support
|
| 256 |
+
|
| 257 |
+
- Chrome/Edge (latest)
|
| 258 |
+
- Firefox (latest)
|
| 259 |
+
- Safari (latest)
|
| 260 |
+
- Mobile browsers (iOS Safari, Chrome Mobile)
|
| 261 |
+
|
| 262 |
+
## Performance
|
| 263 |
+
|
| 264 |
+
- Initial load: < 2s
|
| 265 |
+
- Route changes: < 100ms
|
| 266 |
+
- WebSocket latency: < 50ms
|
| 267 |
+
- Lighthouse score: 90+
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
**Built for Paul's Bachelor Party (June 4-6, 2026)**
|
| 272 |
+
*PSG colors, FIFA vibes, legendary times!* 🏆⚽🎮
|
frontend/index.html
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="description" content="EVG Ultimate Team - Gamification app for Paul's bachelor party" />
|
| 8 |
+
<title>EVG Ultimate Team</title>
|
| 9 |
+
|
| 10 |
+
<!-- Preconnect to API -->
|
| 11 |
+
<link rel="preconnect" href="http://localhost:8000" />
|
| 12 |
+
|
| 13 |
+
<!-- Google Fonts: Bebas Neue for headings -->
|
| 14 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 15 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 16 |
+
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 17 |
+
</head>
|
| 18 |
+
<body>
|
| 19 |
+
<div id="root"></div>
|
| 20 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 21 |
+
</body>
|
| 22 |
+
</html>
|
frontend/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "evg-ultimate-team-frontend",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "EVG Ultimate Team - Gamification app for Paul's bachelor party",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "tsc && vite build",
|
| 9 |
+
"preview": "vite preview",
|
| 10 |
+
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
| 11 |
+
"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
|
| 12 |
+
},
|
| 13 |
+
"dependencies": {
|
| 14 |
+
"react": "^18.2.0",
|
| 15 |
+
"react-dom": "^18.2.0",
|
| 16 |
+
"react-router-dom": "^6.20.0",
|
| 17 |
+
"axios": "^1.6.2",
|
| 18 |
+
"zustand": "^4.4.7",
|
| 19 |
+
"clsx": "^2.0.0",
|
| 20 |
+
"date-fns": "^3.0.0"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@types/react": "^18.2.43",
|
| 24 |
+
"@types/react-dom": "^18.2.17",
|
| 25 |
+
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
| 26 |
+
"@typescript-eslint/parser": "^6.14.0",
|
| 27 |
+
"@vitejs/plugin-react": "^4.2.1",
|
| 28 |
+
"autoprefixer": "^10.4.16",
|
| 29 |
+
"eslint": "^8.55.0",
|
| 30 |
+
"eslint-plugin-react-hooks": "^4.6.0",
|
| 31 |
+
"eslint-plugin-react-refresh": "^0.4.5",
|
| 32 |
+
"postcss": "^8.4.32",
|
| 33 |
+
"prettier": "^3.1.1",
|
| 34 |
+
"tailwindcss": "^3.3.6",
|
| 35 |
+
"typescript": "^5.3.3",
|
| 36 |
+
"vite": "^5.0.8"
|
| 37 |
+
},
|
| 38 |
+
"engines": {
|
| 39 |
+
"node": ">=18.0.0",
|
| 40 |
+
"npm": ">=9.0.0"
|
| 41 |
+
}
|
| 42 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/src/App.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
|
| 3 |
+
import { AuthProvider, useAuth } from '@context/AuthContext';
|
| 4 |
+
import { LoginPage } from '@pages/LoginPage';
|
| 5 |
+
import { HomePage } from '@pages/HomePage';
|
| 6 |
+
import { LeaderboardPage } from '@pages/LeaderboardPage';
|
| 7 |
+
import { ChallengesPage } from '@pages/ChallengesPage';
|
| 8 |
+
import { AdminDashboard } from '@pages/AdminDashboard';
|
| 9 |
+
import { Button } from '@components/common/Button';
|
| 10 |
+
|
| 11 |
+
// Protected Route Component
|
| 12 |
+
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
| 13 |
+
const { isAuthenticated, isLoading } = useAuth();
|
| 14 |
+
|
| 15 |
+
if (isLoading) {
|
| 16 |
+
return (
|
| 17 |
+
<div className="min-h-screen flex items-center justify-center">
|
| 18 |
+
<div className="loading-spinner"></div>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
// Layout Component
|
| 27 |
+
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
| 28 |
+
const { user, logout } = useAuth();
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="min-h-screen">
|
| 32 |
+
{/* Navigation */}
|
| 33 |
+
<nav className="bg-psg-navy/50 backdrop-blur-sm border-b border-gray-800 sticky top-0 z-50">
|
| 34 |
+
<div className="container mx-auto px-4">
|
| 35 |
+
<div className="flex items-center justify-between h-16">
|
| 36 |
+
<Link to="/" className="text-2xl font-heading text-gradient-psg">
|
| 37 |
+
EVG ULTIMATE TEAM
|
| 38 |
+
</Link>
|
| 39 |
+
|
| 40 |
+
<div className="flex items-center gap-4">
|
| 41 |
+
<Link to="/" className="text-gray-300 hover:text-white transition-colors">
|
| 42 |
+
Home
|
| 43 |
+
</Link>
|
| 44 |
+
<Link to="/leaderboard" className="text-gray-300 hover:text-white transition-colors">
|
| 45 |
+
Leaderboard
|
| 46 |
+
</Link>
|
| 47 |
+
<Link to="/challenges" className="text-gray-300 hover:text-white transition-colors">
|
| 48 |
+
Challenges
|
| 49 |
+
</Link>
|
| 50 |
+
{user?.is_admin && (
|
| 51 |
+
<Link to="/admin" className="text-gray-300 hover:text-white transition-colors">
|
| 52 |
+
Admin
|
| 53 |
+
</Link>
|
| 54 |
+
)}
|
| 55 |
+
<div className="flex items-center gap-2 pl-4 border-l border-gray-700">
|
| 56 |
+
<span className="text-sm text-gray-400">{user?.username}</span>
|
| 57 |
+
<Button
|
| 58 |
+
variant="secondary"
|
| 59 |
+
className="text-sm py-1 px-3"
|
| 60 |
+
onClick={logout}
|
| 61 |
+
>
|
| 62 |
+
Logout
|
| 63 |
+
</Button>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</nav>
|
| 69 |
+
|
| 70 |
+
{/* Main Content */}
|
| 71 |
+
<main className="container mx-auto px-4 py-8">
|
| 72 |
+
{children}
|
| 73 |
+
</main>
|
| 74 |
+
|
| 75 |
+
{/* Footer */}
|
| 76 |
+
<footer className="text-center py-8 text-gray-500 text-sm">
|
| 77 |
+
<p>EVG Ultimate Team - Paul's Bachelor Party June 4-6, 2026</p>
|
| 78 |
+
<p className="mt-1">🏆⚽🎮</p>
|
| 79 |
+
</footer>
|
| 80 |
+
</div>
|
| 81 |
+
);
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
// Main App Component
|
| 85 |
+
const AppContent: React.FC = () => {
|
| 86 |
+
return (
|
| 87 |
+
<Routes>
|
| 88 |
+
<Route path="/login" element={<LoginPage />} />
|
| 89 |
+
<Route
|
| 90 |
+
path="/"
|
| 91 |
+
element={
|
| 92 |
+
<ProtectedRoute>
|
| 93 |
+
<Layout>
|
| 94 |
+
<HomePage />
|
| 95 |
+
</Layout>
|
| 96 |
+
</ProtectedRoute>
|
| 97 |
+
}
|
| 98 |
+
/>
|
| 99 |
+
<Route
|
| 100 |
+
path="/leaderboard"
|
| 101 |
+
element={
|
| 102 |
+
<ProtectedRoute>
|
| 103 |
+
<Layout>
|
| 104 |
+
<LeaderboardPage />
|
| 105 |
+
</Layout>
|
| 106 |
+
</ProtectedRoute>
|
| 107 |
+
}
|
| 108 |
+
/>
|
| 109 |
+
<Route
|
| 110 |
+
path="/challenges"
|
| 111 |
+
element={
|
| 112 |
+
<ProtectedRoute>
|
| 113 |
+
<Layout>
|
| 114 |
+
<ChallengesPage />
|
| 115 |
+
</Layout>
|
| 116 |
+
</ProtectedRoute>
|
| 117 |
+
}
|
| 118 |
+
/>
|
| 119 |
+
<Route
|
| 120 |
+
path="/admin"
|
| 121 |
+
element={
|
| 122 |
+
<ProtectedRoute>
|
| 123 |
+
<Layout>
|
| 124 |
+
<AdminDashboard />
|
| 125 |
+
</Layout>
|
| 126 |
+
</ProtectedRoute>
|
| 127 |
+
}
|
| 128 |
+
/>
|
| 129 |
+
<Route path="*" element={<Navigate to="/" replace />} />
|
| 130 |
+
</Routes>
|
| 131 |
+
);
|
| 132 |
+
};
|
| 133 |
+
|
| 134 |
+
const App: React.FC = () => {
|
| 135 |
+
return (
|
| 136 |
+
<BrowserRouter>
|
| 137 |
+
<AuthProvider>
|
| 138 |
+
<AppContent />
|
| 139 |
+
</AuthProvider>
|
| 140 |
+
</BrowserRouter>
|
| 141 |
+
);
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
export default App;
|