A full-stack football analytics application for managing teams, players, matches, and statistics with flexible metric tracking and advanced analytics.
- Framework: FastAPI (Python)
- Database: PostgreSQL 16
- ORM: SQLAlchemy 2.0
- Migrations: Alembic
- API Docs: OpenAPI/Swagger (auto-generated at
/docs)
- Framework: React 18
- Build Tool: Vite
- Routing: React Router
- Charts: Recharts
- HTTP Client: Axios
- ✅ Flexible EAV (Entity-Attribute-Value) metrics model
- ✅ Raw and derived metrics (computed on-demand)
- ✅ Team and player statistics tracking
- ✅ Analytics dashboard with KPIs, time series, and radar charts
- ✅ CRUD operations for seasons, teams, players, and matches
- ✅ Bulk data entry for metrics and participations
- ✅ Validation (percentages 0-100, no storing derived metrics)
- ✅ Docker Compose setup for easy deployment
- ✅ Match summary endpoint providing a single, Excel-like payload for frontend and export
- Docker & Docker Compose
- Node.js 18+ (for local frontend development)
- Python 3.11+ (for local backend development)
cd PSG
cp .env.example .env
# Edit .env if needed (default values work for docker-compose)# Build and start all services (PostgreSQL + API)
docker compose up -d --build
# Check logs
docker compose logs -f api
# The API will be available at http://localhost:8000
# API docs at http://localhost:8000/docsThe startup script automatically:
- Waits for PostgreSQL to be ready
- Runs Alembic migrations
- Seeds metric definitions
- Starts the API server
cd frontend
npm install
npm run dev
# Frontend will be available at http://localhost:5173seasons
├── id, label, start_date, end_date
teams
├── id, name
players
├── id, team_id, first_name, last_name
├── main_position, secondary_positions
matches
├── id, team_id, season_id, date
├── opponent_name, is_home, match_type
├── competition, score_for, score_against
└── veo_title, veo_url, veo_duration, veo_camera
match_player_participations
├── id, match_id, player_id
├── is_starter, is_captain
└── minutes_played, position_played
metric_definitions
├── id, slug, label_fr, description_fr
├── scope (TEAM/PLAYER)
├── category (POSSESSION/PASSES/EVENTS/COMBINATIONS/GENERAL)
├── datatype (INT/FLOAT/PERCENT)
├── unit, side (OWN/OPPONENT/NONE)
└── is_derived, formula
team_match_metric_values
├── id, match_id, metric_id, side
└── value_number
UNIQUE(match_id, metric_id, side)
player_match_metric_values
├── id, match_id, player_id, metric_id
└── value_number
UNIQUE(match_id, player_id, metric_id)
# Seasons
GET /seasons
POST /seasons
GET /seasons/{id}
# Teams
GET /teams
POST /teams
GET /teams/{id}
# Players
GET /players?team_id={id}
POST /players
GET /players/{id}
PATCH /players/{id}
DELETE /players/{id}
# Matches
GET /matches?team_id={id}&season_id={id}&from={date}&to={date}
POST /matches
GET /matches/{id}
PATCH /matches/{id}
DELETE /matches/{id}
# Participations
GET /matches/{id}/participations
PUT /matches/{id}/participations
POST /matches/{id}/duplicate-participations/{source_id}# Metric Definitions
GET /metrics?scope={TEAM|PLAYER}&category={...}&is_derived={bool}
GET /metrics/{id}
# Team Metrics (per match)
GET /metrics/matches/{id}/team-metrics
PUT /metrics/matches/{id}/team-metrics
# Player Metrics (per match)
GET /metrics/matches/{id}/player-metrics
PUT /metrics/matches/{id}/player-metrics# Team KPIs (aggregated)
GET /analytics/team/kpis
?team_id={id}
&metrics=slug1,slug2,slug3
&season_id={id}
&from={date}&to={date}
&compute_delta={bool}
# Time Series (last N matches)
GET /analytics/team/timeseries
?team_id={id}
&metric={slug}
&last_n={10}
# Radar Chart (compare periods)
GET /analytics/team/radar
?team_id={id}
&metrics=slug1,slug2,...slug6
&fromA={date}&toA={date}
&fromB={date}&toB={date}
# Player Leaderboard
GET /analytics/players/leaderboard
?team_id={id}
&metric={slug}
&season_id={id}
&top_n={10}GET /matches/{id}/summaryThis endpoint returns a single, structured payload containing:
- match metadata (including VEO fields)
- player participations
- team metrics (OWN / OPPONENT)
- player metrics as an Excel-like grid (players × metrics)
It is designed to:
- replace manual Excel aggregation
- serve as the canonical payload for the frontend (Phase 2)
- act as the base for CSV/Excel export (Phase 3)
No derived metrics are computed here — only raw data.
curl -X POST http://localhost:8000/seasons \
-H "Content-Type: application/json" \
-d '{
"label": "2024-2025",
"start_date": "2024-08-01",
"end_date": "2025-06-30"
}'curl -X POST http://localhost:8000/teams \
-H "Content-Type: application/json" \
-d '{
"name": "Paris Saint-Germain U17"
}'curl -X POST http://localhost:8000/players \
-H "Content-Type: application/json" \
-d '{
"team_id": 1,
"first_name": "Kylian",
"last_name": "Mbappé",
"main_position": "Attaquant",
"secondary_positions": "Ailier gauche, Ailier droit"
}'curl -X POST http://localhost:8000/matches \
-H "Content-Type: application/json" \
-d '{
"team_id": 1,
"season_id": 1,
"date": "2024-09-15",
"opponent_name": "Olympique de Marseille",
"is_home": true,
"match_type": "LEAGUE",
"competition": "Ligue 1",
"score_for": 3,
"score_against": 1
}'curl -X PUT http://localhost:8000/matches/1/participations \
-H "Content-Type: application/json" \
-d '{
"participations": [
{
"player_id": 1,
"is_starter": true,
"is_captain": true,
"minutes_played": 90,
"position_played": "Attaquant"
},
{
"player_id": 2,
"is_starter": true,
"is_captain": false,
"minutes_played": 75,
"position_played": "Milieu offensif"
},
{
"player_id": 3,
"is_starter": false,
"is_captain": false,
"minutes_played": 15,
"position_played": "Attaquant"
}
]
}'curl -X PUT http://localhost:8000/metrics/matches/1/team-metrics \
-H "Content-Type: application/json" \
-d '{
"values": [
{
"metric_slug": "team_possession_pct",
"side": "OWN",
"value": 62.5
},
{
"metric_slug": "team_goals_scored",
"side": "OWN",
"value": 3
},
{
"metric_slug": "team_goals_conceded",
"side": "OPPONENT",
"value": 1
},
{
"metric_slug": "team_shots",
"side": "OWN",
"value": 15
},
{
"metric_slug": "team_shots_conceded",
"side": "OPPONENT",
"value": 8
},
{
"metric_slug": "team_passes_completed",
"side": "OWN",
"value": 542
}
]
}'curl -X PUT http://localhost:8000/metrics/matches/1/player-metrics \
-H "Content-Type: application/json" \
-d '{
"values": [
{
"player_id": 1,
"metric_slug": "player_goals",
"value": 2
},
{
"player_id": 1,
"metric_slug": "player_shots",
"value": 5
},
{
"player_id": 1,
"metric_slug": "player_goal_assists",
"value": 1
},
{
"player_id": 2,
"metric_slug": "player_goals",
"value": 1
},
{
"player_id": 2,
"metric_slug": "player_shots",
"value": 3
},
{
"player_id": 2,
"metric_slug": "player_goal_assists",
"value": 1
}
]
}'curl "http://localhost:8000/analytics/team/kpis?team_id=1&metrics=team_possession_pct,team_goals_scored,team_conversion_rate,team_shots&season_id=1&compute_delta=true"curl "http://localhost:8000/analytics/players/leaderboard?team_id=1&metric=player_goals&season_id=1&top_n=10"- Season and date range selector
- 4 customizable KPI cards with delta indicators
- Bar chart showing last 10 matches for selected metric
- Radar chart comparing two time periods
- List all players for selected team
- Create/edit player with positions
- Delete players
- Inline editing support
- List matches with filters (team, season, date range)
- Create new match
- Match detail page with 3 tabs:
- Participations: Set starters, captain, minutes, positions
- Team Stats: Dynamic form for all team metrics
- Player Stats: Table with players × metrics grid
# Run tests
pytest
# Run with coverage
pytest --cov=app tests/
# Run specific test file
pytest tests/test_analytics.py -vThe test suite includes:
- ✅ Player derived metrics (attempts, conversion rate)
- ✅ Team KPI aggregation across matches
- ✅ Win rate calculation
- ✅ Zero-division handling
- ✅ Time series queries
GENERAL (4 raw)
player_matches- Matchsplayer_starts- Titulaireplayer_captaincies- Capitanatsplayer_motm- Meilleur joueur du match
EVENTS (9 raw)
player_total_events- Nombre total d'événementsplayer_goals- Butsplayer_shots- Tirsplayer_corners- Cornersplayer_free_kicks- Coups francsplayer_goal_kicks- Coups de pied de butplayer_penalties- Penaltysplayer_goal_assists- Goal assistsplayer_throw_ins- Throw-ins
COMBINATIONS (3 derived - computed on demand)
player_attempts= goals + shotsplayer_conversion_rate= (goals / attempts) × 100player_goal_involvements= goals + assists
POSSESSION (6 raw)
team_possession_pct- Possession (%)team_possession_minutes- Possession (minutes)team_possession_won- Possessions gagnéesteam_possession_third_def_pct- Possession tiers défensif (%)team_possession_third_mid_pct- Possession tiers milieu (%)team_possession_third_att_pct- Possession tiers attaque (%)
PASSES (7 raw)
team_pass_zone_def_pct- Passes zone défensive (%)team_pass_zone_mid_pct- Passes zone milieu (%)team_pass_zone_att_pct- Passes zone attaque (%)team_passes_completed- Passes réussiesteam_sequences_3_5- Séquences 3-5 passesteam_sequences_6_plus- Séquences 6+ passesteam_longest_sequence- Séquence la plus longue
EVENTS (10 raw)
team_goals_scored- Buts marqués (OWN)team_goals_conceded- Buts encaissés (OPPONENT)team_free_kicks- Coups francsteam_shots- Tirsteam_shots_conceded- Tirs encaissésteam_corners- Cornersteam_goal_kicks- Coups de pied de butteam_throw_ins- Throw-ins
COMBINATIONS (6 derived)
team_attempts= goals_scored + shotsteam_conversion_rate= (goals_scored / attempts) × 100team_attempts_conceded= goals_conceded + shots_concededteam_offensive_events= goals_scored + corners + free_kicks + shotsteam_defensive_events= goals_conceded + shots_concededteam_win_rate= (wins / total_matches) × 100
- Percentage values: Must be between 0 and 100
- Derived metrics: Cannot be stored manually (computed server-side)
- Metric scope: Team metrics only for team endpoints, player metrics for player endpoints
- Participations: All players must belong to match's team
- 🔐 JWT authentication with role-based access (admin/coach/player-readonly)
- 📊 Export to CSV/Excel
- 📸 Veo API integration for automatic video parsing
- 📱 Mobile responsive design
- 🌍 Multi-language support (English/French)
- 🎥 Video clips linked to events
- 📈 Season-over-season comparisons
- 🏆 Multi-team support with league standings
# Create virtual environment
python -m venv venv
source venv/bin/activate # or `venv\Scripts\activate` on Windows
# Install dependencies
pip install -r requirements.txt
# Set environment variables
export DATABASE_URL="postgresql+psycopg://veo_user:veo_password@postgres:5432/veo_db"
# Run migrations
alembic upgrade head
# Seed data
python -m app.seed
# Start server
uvicorn app.main:app --reload --port 8000alembic revision --autogenerate -m "description of changes"
alembic upgrade headEdit app/seed.py and add to PLAYER_METRICS or TEAM_METRICS list, then run:
python -m app.seedCette section décrit les choix structurants du module VEO V1, ainsi que les garanties mises en place côté base de données et API.
Le module VEO V1 est conçu pour :
- Remplacer la saisie Excel par une saisie manuelle structurée
- Stocker uniquement des données brutes (raw) en base
- Calculer les métriques dérivées à la demande côté backend (analytics)
- Garantir la cohérence des données, même en cas d’accès direct à la base
➡️ Principe fondamental :
Les métriques dérivées ne sont jamais stockées en base.
- SGBD : PostgreSQL 16+
- ORM : SQLAlchemy 2.0
- Migrations : Alembic
- Mode d’exécution : Docker-first
seasons,teams,players,matchesmatch_player_participationsmetric_definitionsteam_match_metric_valuesplayer_match_metric_values
Le modèle repose sur un schéma EAV maîtrisé, avec :
- définitions de métriques centralisées (
metric_definitions) - valeurs stockées par match / joueur / équipe
- support OWN / OPPONENT pour les stats équipe
Même si l’API applique déjà des validations, des protections supplémentaires existent au niveau PostgreSQL.
Un trigger PostgreSQL empêche toute insertion ou mise à jour d’une métrique marquée is_derived = true.
✔️ Protège contre :
- insertions SQL manuelles
- bugs applicatifs futurs
- mauvaises migrations
Fonction utilisée :
prevent_derived_metric_values()
Triggers actifs :
trg_prevent_derived_team_valuestrg_prevent_derived_player_values
Un trigger PostgreSQL dédié empêche toute valeur hors plage [0, 100] pour les métriques de type PERCENT.
✔️ Validation assurée :
- côté backend (API)
- et côté base (DB hardening)
Fonction utilisée :
enforce_percent_range()
Triggers actifs :
trg_enforce_percent_team_valuestrg_enforce_percent_player_values
Des indexes spécifiques ont été ajoutés pour les cas d’usage analytics :
matches(team_id, season_id, date)players(team_id)match_player_participations(match_id)match_player_participations(player_id)team_match_metric_values(metric_id, match_id, side)player_match_metric_values(metric_id, match_id)player_match_metric_values(player_id, match_id)
Un index partiel PostgreSQL existe pour accélérer les dashboards :
team_match_metric_values(metric_id, match_id) WHERE side = 'OWN'
➡️ Optimisé pour les lectures analytics courantes (stats équipe).
Un script E2E reproductible valide le fonctionnement complet du module VEO :
- création saison / équipe / joueur
- création match avec métadonnées VEO
- saisie participations
- saisie métriques équipe & joueur
- lecture analytics avec métriques dérivées
- vérification que les dérivées ne sont pas stockées
📄 Script :
scripts/e2e_veo_v1.shCe script est conçu pour :
- être rejouable localement
- servir de base pour une future CI
- garantir que la règle “raw-only” est respectée
✔️ Base de données prête ✔️ API fonctionnelle ✔️ Règles métier sécurisées (API + DB) ✔️ Analytics calculées à la demande ✔️ Remplacement Excel techniquement validé
➡️ Le module est prêt pour la saisie manuelle frontend et peut déjà être utilisé comme source unique de vérité pour remplacer les fichiers Excel existants.
This project is part of PSG's internal analytics system.
For questions or issues, contact the development team.