A backend-first football data & analytics platform
ScoutLens is a portfolio-level backend project demonstrating Python backend engineering, API-first architecture, PostgreSQL relational modeling, and clean service-layer design.
- Tech Stack
- Project Structure
- Getting Started
- API Reference
- Analytics Metrics
- Data Model
- Architecture
- Testing
- Database Migrations
| Layer | Technology |
|---|---|
| Language | Python 3.11 |
| Framework | FastAPI |
| ORM | SQLAlchemy 2.x |
| Validation | Pydantic v2 |
| Database | PostgreSQL 15 |
| Migrations | Alembic |
| Testing | pytest + httpx |
| Containers | Docker / Docker Compose |
ScoutLens/
├── app/
│ ├── main.py # App factory, lifespan, router registration
│ ├── database.py # Engine, session factory, Base, get_db
│ ├── models/ # SQLAlchemy ORM models
│ │ ├── team.py
│ │ ├── player.py
│ │ ├── match.py
│ │ └── player_match_stat.py
│ ├── schemas/ # Pydantic v2 schemas (Create / Update / Response)
│ │ ├── team.py
│ │ ├── player.py
│ │ ├── match.py
│ │ └── player_match_stat.py
│ ├── routers/ # Thin route handlers
│ │ ├── teams.py
│ │ ├── players.py
│ │ ├── matches.py
│ │ └── stats.py
│ └── services/ # Business logic & database queries
│ ├── team_service.py
│ ├── player_service.py
│ ├── match_service.py
│ ├── stat_service.py
│ └── metrics_service.py # Analytics: per-90s, rating, performance index
├── alembic/ # Database migration scripts
│ └── versions/
├── tests/ # pytest suite
│ ├── conftest.py # Shared fixtures (SQLite in-memory)
│ ├── test_teams.py
│ ├── test_players.py
│ ├── test_matches.py
│ ├── test_stats.py
│ └── test_metrics.py
├── scripts/
│ └── seed.py # Demo data loader
├── Dockerfile
├── docker-compose.yml
├── alembic.ini
├── requirements.txt
└── .env.example
- Docker and Docker Compose
cp .env.example .envdocker compose up --build| Service | URL |
|---|---|
| API | http://localhost:8000 |
| Swagger UI | http://localhost:8000/docs |
| ReDoc | http://localhost:8000/redoc |
| PostgreSQL | localhost:5432 |
docker compose exec web python scripts/seed.pyInserts 3 teams, 6 players, 4 matches, and 12 player match stats.
All endpoints are prefixed with /api/v1.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/teams |
List all teams |
GET |
/api/v1/teams/{id} |
Get team by ID |
POST |
/api/v1/teams |
Create a team |
PATCH |
/api/v1/teams/{id} |
Partial update |
DELETE |
/api/v1/teams/{id} |
Delete a team |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/players |
List all players (?team_id= filter) |
GET |
/api/v1/players/{id} |
Get player by ID |
POST |
/api/v1/players |
Create a player |
PATCH |
/api/v1/players/{id} |
Partial update |
DELETE |
/api/v1/players/{id} |
Delete a player |
GET |
/api/v1/players/{id}/metrics |
Analytics metrics |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/matches |
List all matches (?team_id= filter) |
GET |
/api/v1/matches/{id} |
Get match by ID |
POST |
/api/v1/matches |
Create a match |
PATCH |
/api/v1/matches/{id} |
Partial update |
DELETE |
/api/v1/matches/{id} |
Delete a match |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/stats |
List all player match stats |
GET |
/api/v1/stats/{id} |
Get stat record by ID |
POST |
/api/v1/stats |
Create a stat record |
PATCH |
/api/v1/stats/{id} |
Partial update |
DELETE |
/api/v1/stats/{id} |
Delete a stat record |
GET /api/v1/players/{id}/metrics
Aggregates performance data across all recorded appearances:
| Metric | Formula |
|---|---|
goals_per_90 |
SUM(goals) / SUM(minutes_played) × 90 |
assists_per_90 |
SUM(assists) / SUM(minutes_played) × 90 |
avg_rating |
AVG(rating) — rated appearances only |
performance_index |
(g90 × 0.4) + (a90 × 0.3) + (avg_rating / 10 × 0.3) |
Example response:
{
"player_id": 1,
"goals_per_90": 1.20,
"assists_per_90": 0.60,
"avg_rating": 8.50,
"performance_index": 0.99
}┌──────────┐ ┌──────────┐
│ Team │◄────────│ Player │
└──────────┘ └──────────┘
│ │
│ (home/away) │
▼ ▼
┌──────────┐ ┌──────────────────┐
│ Match │◄──────│ PlayerMatchStat │
└──────────┘ └──────────────────┘
ScoutLens follows a strict layered architecture:
Request → Router → Service → ORM Model → PostgreSQL
↑
Schema (Pydantic)
| Layer | Responsibility |
|---|---|
| Routers | HTTP routing, input validation, response shaping — no ORM logic |
| Services | All business logic, CRUD, and aggregate queries |
| Models | SQLAlchemy ORM table definitions with indexes |
| Schemas | Pydantic v2 request/response contracts (Create / Update / Response) |
| Database | Engine, session factory, get_db dependency |
Key conventions:
- All
Updateschemas use fully optional fields (PATCH semantics) - Services use
model_dump(exclude_unset=True)for partial updates - 404s are raised in routers, not services
- Database sessions injected via
Depends(get_db)
Tests run against an in-memory SQLite database — no Postgres required.
# Install dev dependencies
pip install -r requirements.txt
# Run the full test suite
pytest
# With coverage
pytest --cov=app --cov-report=term-missingTest structure mirrors the service/router split:
| File | Coverage |
|---|---|
tests/test_teams.py |
Team CRUD endpoints |
tests/test_players.py |
Player CRUD + team filter |
tests/test_matches.py |
Match CRUD + team filter |
tests/test_stats.py |
Stat CRUD endpoints |
tests/test_metrics.py |
Analytics metric calculations |
ScoutLens uses Alembic for schema versioning.
# Apply all migrations
alembic upgrade head
# Create a new migration after model changes
alembic revision --autogenerate -m "describe the change"
# Downgrade one step
alembic downgrade -1
# View migration history
alembic history --verboseNote: The
DATABASE_URLenvironment variable must be set before running Alembic commands. See.env.example.
This project intentionally excludes: frontend, authentication, data scraping, machine learning, payments, and multi-tenancy.
See ROADMAP.md for planned future phases.