Skip to content

Commit 216b0a0

Browse files
nanotaboadaCopilot
andcommitted
feat(api): replace integer PK with UUID v4 for Player entity (#66)
- Add HyphenatedUUID custom TypeDecorator storing UUIDs as CHAR(36) in SQLite and returning uuid.UUID objects in Python - Split PlayerModel into PlayerRequestModel (no id, for POST/PUT) and PlayerResponseModel (includes id: UUID, for GET/POST responses) - Update all route path parameters and service signatures from int to UUID - Add retrieve_by_squad_number_async() service method; expose via GET /players/squadnumber/{squad_number} route - Change POST conflict detection from ID lookup to squad_number uniqueness - Return created Player from create_async() after session refresh - Add migration_001_starting_eleven.py: recreates players table with UUID PK and seeds 11 starters with deterministic UUID v5 values - Add migration_002_substitutes.py: seeds 14 substitutes with deterministic UUID v5 values (requires migration 001 to run first) - Update player_stub.py with UUID-based fixtures; unknown_player() now uses a nil-style UUID instead of an integer - Update test suite to assert UUID presence and version (UUID v4 for API-created records); DELETE resolves target UUID via squad number lookup Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent df8ce10 commit 216b0a0

File tree

15 files changed

+801
-86
lines changed

15 files changed

+801
-86
lines changed

.github/copilot-instructions.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ schemas/ — SQLAlchemy ORM models (database schema) [data laye
2626
databases/ — async SQLAlchemy session setup
2727
models/ — Pydantic models for request/response validation
2828
storage/ — SQLite database file (players-sqlite3.db, pre-seeded)
29+
scripts/ — shell scripts for Docker (entrypoint.sh, healthcheck.sh)
30+
tools/ — standalone seed scripts (run manually, not via Alembic)
2931
tests/ — pytest integration tests
3032
```
3133

@@ -37,6 +39,8 @@ tests/ — pytest integration tests
3739
- **Type hints**: Required everywhere — functions, variables, return types
3840
- **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()`)
3941
- **API contract**: camelCase JSON via Pydantic `alias_generator=to_camel`; Python internals stay snake_case
42+
- **Models**: `PlayerRequestModel` (no `id`, used for POST/PUT) and `PlayerResponseModel` (includes `id: UUID`, used for GET/POST responses); never use the removed `PlayerModel`
43+
- **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
4044
- **Caching**: cache key `"players"` (hardcoded); clear on POST/PUT/DELETE; `X-Cache` header (HIT/MISS)
4145
- **Errors**: Catch specific exceptions with rollback in services; Pydantic validation returns 422 (not 400)
4246
- **Logging**: `logging` module only; never `print()`
@@ -90,7 +94,7 @@ Example: `feat(api): add player stats endpoint (#42)`
9094

9195
### Ask before changing
9296

93-
- Database schema (`schemas/player_schema.py` — no Alembic, manual process)
97+
- Database schema (`schemas/player_schema.py` — no Alembic, use tools/ seed scripts manually)
9498
- Dependencies (`requirements*.txt`)
9599
- CI/CD configuration (`.github/workflows/`)
96100
- Docker setup

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ cover/
6161
local_settings.py
6262
db.sqlite3
6363
db.sqlite3-journal
64+
*.db-shm
65+
*.db-wal
66+
*.db.bak.*
6467

6568
# Flask stuff:
6669
instance/

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"**/htmlcov/**",
3737
"**/postman_collections/**",
3838
"**/scripts/**",
39+
"**/tools/**",
3940
"**/storage/**",
4041
"**/__pycache__/**",
4142
"**/tests/test_main.py"

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,21 @@ This project uses famous football coaches as release codenames, following an A-Z
4444

4545
### Added
4646

47+
- UUID v4 primary key for the `players` table, replacing the previous integer PK (#66)
48+
- `PlayerRequestModel` Pydantic model for POST/PUT request bodies (no `id` field) (#66)
49+
- `PlayerResponseModel` Pydantic model for GET/POST response bodies (includes `id: UUID`) (#66)
50+
- `tools/seed_001_starting_eleven.py`: standalone seed script populating 11 starting-eleven players with deterministic UUID v5 PKs (#66)
51+
- `tools/seed_002_substitutes.py`: standalone seed script populating 14 substitute players with deterministic UUID v5 PKs (#66)
52+
- `HyphenatedUUID` custom `TypeDecorator` in `schemas/player_schema.py` storing UUIDs as hyphenated `CHAR(36)` strings in SQLite, returning `uuid.UUID` objects in Python (#66)
53+
4754
### Changed
4855

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

5164
### Removed

codecov.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ ignore:
4444
- "^models/.*"
4545
- "^postman_collections/.*"
4646
- "^schemas/.*"
47+
- "^tools/.*"
4748
- "^tests/.*"
4849
- ".*\\.yml$"
4950
- ".*\\.json$"

models/player_model.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
Pydantic models defining the data schema for football players.
33
44
- `MainModel`: Base model with common config for camelCase aliasing.
5-
- `PlayerModel`: Represents a football player with personal and team details.
5+
- `PlayerRequestModel`: Represents player data for Create and Update operations.
6+
- `PlayerResponseModel`: Represents player data including UUID for Retrieve operations.
67
78
These models are used for data validation and serialization in the API.
89
"""
910

1011
from typing import Optional
12+
from uuid import UUID
1113
from pydantic import BaseModel, ConfigDict
1214
from pydantic.alias_generators import to_camel
1315

@@ -27,15 +29,17 @@ class MainModel(BaseModel):
2729
Pydantic models.
2830
"""
2931

30-
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
32+
model_config = ConfigDict(
33+
alias_generator=to_camel, populate_by_name=True, from_attributes=True
34+
)
3135

3236

33-
class PlayerModel(MainModel):
37+
class PlayerRequestModel(MainModel):
3438
"""
35-
Pydantic model representing a football Player.
39+
Pydantic model representing the data required for Create and Update operations
40+
on a football Player.
3641
3742
Attributes:
38-
id (int): The unique identifier for the Player.
3943
first_name (str): The first name of the Player.
4044
middle_name (Optional[str]): The middle name of the Player, if any.
4145
last_name (str): The last name of the Player.
@@ -50,7 +54,6 @@ class PlayerModel(MainModel):
5054
if provided.
5155
"""
5256

53-
id: int
5457
first_name: str
5558
middle_name: Optional[str]
5659
last_name: str
@@ -61,3 +64,14 @@ class PlayerModel(MainModel):
6164
team: Optional[str]
6265
league: Optional[str]
6366
starting11: Optional[bool]
67+
68+
69+
class PlayerResponseModel(PlayerRequestModel):
70+
"""
71+
Pydantic model representing a football Player with a UUID for Retrieve operations.
72+
73+
Attributes:
74+
id (UUID): The unique identifier for the Player (UUID v4).
75+
"""
76+
77+
id: UUID

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ exclude = '''
1313
| htmlcov
1414
| postman_collections
1515
| scripts
16+
| tools
1617
| storage
1718
| __pycache__
1819
| tests/test_main\.py

routes/player_route.py

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,22 @@
1111
Endpoints:
1212
- POST /players/ : Create a new Player.
1313
- GET /players/ : Retrieve all Players.
14-
- GET /players/{player_id} : Retrieve Player by ID.
15-
- GET /players/squadnumber/{squad_number} : Retrieve Player by Squad Number.
14+
- GET /players/{player_id} : Retrieve Player by UUID
15+
(surrogate key, internal).
16+
- GET /players/squadnumber/{squad_number} : Retrieve Player by Squad Number
17+
(natural key, domain).
1618
- PUT /players/{player_id} : Update an existing Player.
1719
- DELETE /players/{player_id} : Delete an existing Player.
1820
"""
1921

2022
from typing import List
23+
from uuid import UUID
2124
from fastapi import APIRouter, Body, Depends, HTTPException, status, Path, Response
2225
from sqlalchemy.ext.asyncio import AsyncSession
2326
from aiocache import SimpleMemoryCache
2427

2528
from databases.player_database import generate_async_session
26-
from models.player_model import PlayerModel
29+
from models.player_model import PlayerRequestModel, PlayerResponseModel
2730
from services import player_service
2831

2932
api_router = APIRouter()
@@ -37,38 +40,45 @@
3740

3841
@api_router.post(
3942
"/players/",
43+
response_model=PlayerResponseModel,
4044
status_code=status.HTTP_201_CREATED,
4145
summary="Creates a new Player",
4246
tags=["Players"],
4347
)
4448
async def post_async(
45-
player_model: PlayerModel = Body(...),
49+
player_model: PlayerRequestModel = Body(...),
4650
async_session: AsyncSession = Depends(generate_async_session),
4751
):
4852
"""
4953
Endpoint to create a new player.
5054
5155
Args:
52-
player_model (PlayerModel): The Pydantic model representing the Player to
53-
create.
56+
player_model (PlayerRequestModel): The Pydantic model representing the Player
57+
to create.
5458
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
5559
60+
Returns:
61+
PlayerResponseModel: The created Player with its generated UUID.
62+
5663
Raises:
5764
HTTPException: HTTP 409 Conflict error if the Player already exists.
5865
"""
59-
player = await player_service.retrieve_by_id_async(async_session, player_model.id)
60-
if player:
66+
existing = await player_service.retrieve_by_squad_number_async(
67+
async_session, player_model.squad_number
68+
)
69+
if existing:
6170
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
62-
await player_service.create_async(async_session, player_model)
71+
player = await player_service.create_async(async_session, player_model)
6372
await simple_memory_cache.clear(CACHE_KEY)
73+
return player
6474

6575

6676
# GET --------------------------------------------------------------------------
6777

6878

6979
@api_router.get(
7080
"/players/",
71-
response_model=List[PlayerModel],
81+
response_model=List[PlayerResponseModel],
7282
status_code=status.HTTP_200_OK,
7383
summary="Retrieves a collection of Players",
7484
tags=["Players"],
@@ -83,7 +93,7 @@ async def get_all_async(
8393
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
8494
8595
Returns:
86-
List[PlayerModel]: A list of Pydantic models representing all players.
96+
List[PlayerResponseModel]: A list of Pydantic models representing all players.
8797
"""
8898
players = await simple_memory_cache.get(CACHE_KEY)
8999
response.headers["X-Cache"] = "HIT"
@@ -96,27 +106,27 @@ async def get_all_async(
96106

97107
@api_router.get(
98108
"/players/{player_id}",
99-
response_model=PlayerModel,
109+
response_model=PlayerResponseModel,
100110
status_code=status.HTTP_200_OK,
101-
summary="Retrieves a Player by its Id",
111+
summary="Retrieves a Player by its UUID",
102112
tags=["Players"],
103113
)
104114
async def get_by_id_async(
105-
player_id: int = Path(..., title="The ID of the Player"),
115+
player_id: UUID = Path(..., title="The UUID of the Player"),
106116
async_session: AsyncSession = Depends(generate_async_session),
107117
):
108118
"""
109-
Endpoint to retrieve a Player by its ID.
119+
Endpoint to retrieve a Player by its UUID.
110120
111121
Args:
112-
player_id (int): The ID of the Player to retrieve.
122+
player_id (UUID): The UUID of the Player to retrieve.
113123
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
114124
115125
Returns:
116-
PlayerModel: The Pydantic model representing the matching Player.
126+
PlayerResponseModel: The Pydantic model representing the matching Player.
117127
118128
Raises:
119-
HTTPException: Not found error if the Player with the specified ID does not
129+
HTTPException: Not found error if the Player with the specified UUID does not
120130
exist.
121131
"""
122132
player = await player_service.retrieve_by_id_async(async_session, player_id)
@@ -127,7 +137,7 @@ async def get_by_id_async(
127137

128138
@api_router.get(
129139
"/players/squadnumber/{squad_number}",
130-
response_model=PlayerModel,
140+
response_model=PlayerResponseModel,
131141
status_code=status.HTTP_200_OK,
132142
summary="Retrieves a Player by its Squad Number",
133143
tags=["Players"],
@@ -144,7 +154,7 @@ async def get_by_squad_number_async(
144154
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
145155
146156
Returns:
147-
PlayerModel: The Pydantic model representing the matching Player.
157+
PlayerResponseModel: The Pydantic model representing the matching Player.
148158
149159
Raises:
150160
HTTPException: HTTP 404 Not Found error if the Player with the specified
@@ -168,27 +178,27 @@ async def get_by_squad_number_async(
168178
tags=["Players"],
169179
)
170180
async def put_async(
171-
player_id: int = Path(..., title="The ID of the Player"),
172-
player_model: PlayerModel = Body(...),
181+
player_id: UUID = Path(..., title="The UUID of the Player"),
182+
player_model: PlayerRequestModel = Body(...),
173183
async_session: AsyncSession = Depends(generate_async_session),
174184
):
175185
"""
176186
Endpoint to entirely update an existing Player.
177187
178188
Args:
179-
player_id (int): The ID of the Player to update.
180-
player_model (PlayerModel): The Pydantic model representing the Player to
181-
update.
189+
player_id (UUID): The UUID of the Player to update.
190+
player_model (PlayerRequestModel): The Pydantic model representing the Player
191+
to update.
182192
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
183193
184194
Raises:
185-
HTTPException: HTTP 404 Not Found error if the Player with the specified ID
195+
HTTPException: HTTP 404 Not Found error if the Player with the specified UUID
186196
does not exist.
187197
"""
188198
player = await player_service.retrieve_by_id_async(async_session, player_id)
189199
if not player:
190200
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
191-
await player_service.update_async(async_session, player_model)
201+
await player_service.update_async(async_session, player_id, player_model)
192202
await simple_memory_cache.clear(CACHE_KEY)
193203

194204

@@ -202,18 +212,18 @@ async def put_async(
202212
tags=["Players"],
203213
)
204214
async def delete_async(
205-
player_id: int = Path(..., title="The ID of the Player"),
215+
player_id: UUID = Path(..., title="The UUID of the Player"),
206216
async_session: AsyncSession = Depends(generate_async_session),
207217
):
208218
"""
209219
Endpoint to delete an existing Player.
210220
211221
Args:
212-
player_id (int): The ID of the Player to delete.
222+
player_id (UUID): The UUID of the Player to delete.
213223
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
214224
215225
Raises:
216-
HTTPException: HTTP 404 Not Found error if the Player with the specified ID
226+
HTTPException: HTTP 404 Not Found error if the Player with the specified UUID
217227
does not exist.
218228
"""
219229
player = await player_service.retrieve_by_id_async(async_session, player_id)

0 commit comments

Comments
 (0)