Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ schemas/ — SQLAlchemy ORM models (database schema) [data laye
databases/ — async SQLAlchemy session setup
models/ — Pydantic models for request/response validation
storage/ — SQLite database file (players-sqlite3.db, pre-seeded)
scripts/ — shell scripts for Docker (entrypoint.sh, healthcheck.sh)
tools/ — standalone seed scripts (run manually, not via Alembic)
tests/ — pytest integration tests
```

Expand All @@ -37,6 +39,8 @@ tests/ — pytest integration tests
- **Type hints**: Required everywhere — functions, variables, return types
- **Async**: All routes and service functions must be `async def`; use `AsyncSession` (never `Session`); use `aiosqlite` (never `sqlite3`); use SQLAlchemy 2.0 `select()` (never `session.query()`)
- **API contract**: camelCase JSON via Pydantic `alias_generator=to_camel`; Python internals stay snake_case
- **Models**: `PlayerRequestModel` (no `id`, used for POST/PUT) and `PlayerResponseModel` (includes `id: UUID`, used for GET/POST responses); never use the removed `PlayerModel`
- **Primary key**: UUID surrogate key (`id`) — opaque, internal, used for all CRUD operations. UUID v4 for API-created records; UUID v5 (deterministic) for migration-seeded records. `squad_number` is the natural key — human-readable, domain-meaningful, preferred lookup for external consumers
- **Caching**: cache key `"players"` (hardcoded); clear on POST/PUT/DELETE; `X-Cache` header (HIT/MISS)
- **Errors**: Catch specific exceptions with rollback in services; Pydantic validation returns 422 (not 400)
- **Logging**: `logging` module only; never `print()`
Expand Down Expand Up @@ -90,7 +94,7 @@ Example: `feat(api): add player stats endpoint (#42)`

### Ask before changing

- Database schema (`schemas/player_schema.py` — no Alembic, manual process)
- Database schema (`schemas/player_schema.py` — no Alembic, use tools/ seed scripts manually)
- Dependencies (`requirements*.txt`)
- CI/CD configuration (`.github/workflows/`)
- Docker setup
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ cover/
local_settings.py
db.sqlite3
db.sqlite3-journal
*.db-shm
*.db-wal
*.db.bak.*

# Flask stuff:
instance/
Expand Down
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"**/htmlcov/**",
"**/postman_collections/**",
"**/scripts/**",
"**/tools/**",
"**/storage/**",
"**/__pycache__/**",
"**/tests/test_main.py"
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,21 @@ This project uses famous football coaches as release codenames, following an A-Z

### Added

- UUID v4 primary key for the `players` table, replacing the previous integer PK (#66)
- `PlayerRequestModel` Pydantic model for POST/PUT request bodies (no `id` field) (#66)
- `PlayerResponseModel` Pydantic model for GET/POST response bodies (includes `id: UUID`) (#66)
- `tools/seed_001_starting_eleven.py`: standalone seed script populating 11 starting-eleven players with deterministic UUID v5 PKs (#66)
- `tools/seed_002_substitutes.py`: standalone seed script populating 14 substitute players with deterministic UUID v5 PKs (#66)
- `HyphenatedUUID` custom `TypeDecorator` in `schemas/player_schema.py` storing UUIDs as hyphenated `CHAR(36)` strings in SQLite, returning `uuid.UUID` objects in Python (#66)
Comment thread
nanotaboada marked this conversation as resolved.

### Changed

- `PlayerModel` split into `PlayerRequestModel` and `PlayerResponseModel` in `models/player_model.py` (#66)
- All route path parameters and service function signatures updated from `int` to `uuid.UUID` (#66)
- POST conflict detection changed from ID lookup to `squad_number` uniqueness check (#66)
- `tests/player_stub.py` updated with UUID-based test fixtures (#66)
- `tests/test_main.py` updated to assert UUID presence and format in API responses (#66)

### Deprecated

### Removed
Expand Down
1 change: 1 addition & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ ignore:
- "^models/.*"
- "^postman_collections/.*"
- "^schemas/.*"
- "^tools/.*"
- "^tests/.*"
- ".*\\.yml$"
- ".*\\.json$"
Expand Down
26 changes: 20 additions & 6 deletions models/player_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
Pydantic models defining the data schema for football players.

- `MainModel`: Base model with common config for camelCase aliasing.
- `PlayerModel`: Represents a football player with personal and team details.
- `PlayerRequestModel`: Represents player data for Create and Update operations.
- `PlayerResponseModel`: Represents player data including UUID for Retrieve operations.

These models are used for data validation and serialization in the API.
"""

from typing import Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel

Expand All @@ -27,15 +29,17 @@ class MainModel(BaseModel):
Pydantic models.
"""

model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
model_config = ConfigDict(
alias_generator=to_camel, populate_by_name=True, from_attributes=True
)


class PlayerModel(MainModel):
class PlayerRequestModel(MainModel):
"""
Pydantic model representing a football Player.
Pydantic model representing the data required for Create and Update operations
on a football Player.

Attributes:
id (int): The unique identifier for the Player.
first_name (str): The first name of the Player.
middle_name (Optional[str]): The middle name of the Player, if any.
last_name (str): The last name of the Player.
Expand All @@ -50,7 +54,6 @@ class PlayerModel(MainModel):
if provided.
"""

id: int
first_name: str
middle_name: Optional[str]
last_name: str
Expand All @@ -61,3 +64,14 @@ class PlayerModel(MainModel):
team: Optional[str]
league: Optional[str]
starting11: Optional[bool]


class PlayerResponseModel(PlayerRequestModel):
"""
Pydantic model representing a football Player with a UUID for Retrieve operations.

Attributes:
id (UUID): The unique identifier for the Player (UUID v4).
"""

id: UUID
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ exclude = '''
| htmlcov
| postman_collections
| scripts
| tools
| storage
| __pycache__
| tests/test_main\.py
Expand Down
70 changes: 40 additions & 30 deletions routes/player_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@
Endpoints:
- POST /players/ : Create a new Player.
- GET /players/ : Retrieve all Players.
- GET /players/{player_id} : Retrieve Player by ID.
- GET /players/squadnumber/{squad_number} : Retrieve Player by Squad Number.
- GET /players/{player_id} : Retrieve Player by UUID
(surrogate key, internal).
- GET /players/squadnumber/{squad_number} : Retrieve Player by Squad Number
(natural key, domain).
- PUT /players/{player_id} : Update an existing Player.
- DELETE /players/{player_id} : Delete an existing Player.
"""

from typing import List
from uuid import UUID
from fastapi import APIRouter, Body, Depends, HTTPException, status, Path, Response
from sqlalchemy.ext.asyncio import AsyncSession
from aiocache import SimpleMemoryCache

from databases.player_database import generate_async_session
from models.player_model import PlayerModel
from models.player_model import PlayerRequestModel, PlayerResponseModel
from services import player_service

api_router = APIRouter()
Expand All @@ -37,38 +40,45 @@

@api_router.post(
"/players/",
response_model=PlayerResponseModel,
status_code=status.HTTP_201_CREATED,
summary="Creates a new Player",
tags=["Players"],
)
async def post_async(
player_model: PlayerModel = Body(...),
player_model: PlayerRequestModel = Body(...),
async_session: AsyncSession = Depends(generate_async_session),
):
"""
Endpoint to create a new player.

Args:
player_model (PlayerModel): The Pydantic model representing the Player to
create.
player_model (PlayerRequestModel): The Pydantic model representing the Player
to create.
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.

Returns:
PlayerResponseModel: The created Player with its generated UUID.

Raises:
HTTPException: HTTP 409 Conflict error if the Player already exists.
"""
player = await player_service.retrieve_by_id_async(async_session, player_model.id)
if player:
existing = await player_service.retrieve_by_squad_number_async(
async_session, player_model.squad_number
)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
await player_service.create_async(async_session, player_model)
player = await player_service.create_async(async_session, player_model)
await simple_memory_cache.clear(CACHE_KEY)
return player
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# GET --------------------------------------------------------------------------


@api_router.get(
"/players/",
response_model=List[PlayerModel],
response_model=List[PlayerResponseModel],
status_code=status.HTTP_200_OK,
summary="Retrieves a collection of Players",
tags=["Players"],
Expand All @@ -83,7 +93,7 @@ async def get_all_async(
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.

Returns:
List[PlayerModel]: A list of Pydantic models representing all players.
List[PlayerResponseModel]: A list of Pydantic models representing all players.
"""
players = await simple_memory_cache.get(CACHE_KEY)
response.headers["X-Cache"] = "HIT"
Expand All @@ -96,27 +106,27 @@ async def get_all_async(

@api_router.get(
"/players/{player_id}",
response_model=PlayerModel,
response_model=PlayerResponseModel,
status_code=status.HTTP_200_OK,
summary="Retrieves a Player by its Id",
summary="Retrieves a Player by its UUID",
tags=["Players"],
)
async def get_by_id_async(
player_id: int = Path(..., title="The ID of the Player"),
player_id: UUID = Path(..., title="The UUID of the Player"),
async_session: AsyncSession = Depends(generate_async_session),
):
"""
Endpoint to retrieve a Player by its ID.
Endpoint to retrieve a Player by its UUID.

Args:
player_id (int): The ID of the Player to retrieve.
player_id (UUID): The UUID of the Player to retrieve.
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.

Returns:
PlayerModel: The Pydantic model representing the matching Player.
PlayerResponseModel: The Pydantic model representing the matching Player.

Raises:
HTTPException: Not found error if the Player with the specified ID does not
HTTPException: Not found error if the Player with the specified UUID does not
exist.
"""
player = await player_service.retrieve_by_id_async(async_session, player_id)
Expand All @@ -127,7 +137,7 @@ async def get_by_id_async(

@api_router.get(
"/players/squadnumber/{squad_number}",
response_model=PlayerModel,
response_model=PlayerResponseModel,
status_code=status.HTTP_200_OK,
summary="Retrieves a Player by its Squad Number",
tags=["Players"],
Expand All @@ -144,7 +154,7 @@ async def get_by_squad_number_async(
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.

Returns:
PlayerModel: The Pydantic model representing the matching Player.
PlayerResponseModel: The Pydantic model representing the matching Player.

Raises:
HTTPException: HTTP 404 Not Found error if the Player with the specified
Expand All @@ -168,27 +178,27 @@ async def get_by_squad_number_async(
tags=["Players"],
)
async def put_async(
player_id: int = Path(..., title="The ID of the Player"),
player_model: PlayerModel = Body(...),
player_id: UUID = Path(..., title="The UUID of the Player"),
player_model: PlayerRequestModel = Body(...),
async_session: AsyncSession = Depends(generate_async_session),
):
"""
Endpoint to entirely update an existing Player.

Args:
player_id (int): The ID of the Player to update.
player_model (PlayerModel): The Pydantic model representing the Player to
update.
player_id (UUID): The UUID of the Player to update.
player_model (PlayerRequestModel): The Pydantic model representing the Player
to update.
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.

Raises:
HTTPException: HTTP 404 Not Found error if the Player with the specified ID
HTTPException: HTTP 404 Not Found error if the Player with the specified UUID
does not exist.
"""
player = await player_service.retrieve_by_id_async(async_session, player_id)
if not player:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await player_service.update_async(async_session, player_model)
await player_service.update_async(async_session, player_id, player_model)
await simple_memory_cache.clear(CACHE_KEY)

Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand All @@ -202,18 +212,18 @@ async def put_async(
tags=["Players"],
)
async def delete_async(
player_id: int = Path(..., title="The ID of the Player"),
player_id: UUID = Path(..., title="The UUID of the Player"),
async_session: AsyncSession = Depends(generate_async_session),
):
"""
Endpoint to delete an existing Player.

Args:
player_id (int): The ID of the Player to delete.
player_id (UUID): The UUID of the Player to delete.
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.

Raises:
HTTPException: HTTP 404 Not Found error if the Player with the specified ID
HTTPException: HTTP 404 Not Found error if the Player with the specified UUID
does not exist.
"""
player = await player_service.retrieve_by_id_async(async_session, player_id)
Expand Down
Loading