diff --git a/.coveragerc b/.coveragerc index c797b53..7127096 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,6 +10,6 @@ omit = [report] exclude_lines = - pragma: no cover # Standard pragma to intentionally skip lines - if __name__ == .__main__.: # Skips CLI bootstrapping code - raise NotImplementedError # Often placeholder stubs not meant to be covered + pragma: no cover + if __name__ == .__main__.: + raise NotImplementedError diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b6cdcea..274025d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 ``` @@ -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()` @@ -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 diff --git a/.gitignore b/.gitignore index 7d392e5..a1f9f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,9 @@ cover/ local_settings.py db.sqlite3 db.sqlite3-journal +*.db-shm +*.db-wal +*.db.bak.* # Flask stuff: instance/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 6110b19..628f754 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,6 +36,7 @@ "**/htmlcov/**", "**/postman_collections/**", "**/scripts/**", + "**/tools/**", "**/storage/**", "**/__pycache__/**", "**/tests/test_main.py" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8491ef5..69ebeb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,14 +44,39 @@ 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) + ### 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) +- `PlayerResponseModel` redeclared with `id` as first field to control JSON serialization order (#66) +- `HyphenatedUUID` methods now have full type annotations and Google-style docstrings; unused `dialect` params renamed to `_dialect` (#66) +- Service logger changed from `getLogger("uvicorn")` to `getLogger("uvicorn.error")`, aligned with `main.py` (#66) +- `logger.error(f"...")` replaced with `logger.exception("...: %s", error)` in all `SQLAlchemyError` handlers (#66) +- EN dashes replaced with ASCII hyphens in `seed_002` log and argparse strings (#66) +- `logger.error` replaced with `logger.exception` in `sqlite3.Error` handlers in `seed_001` and `seed_002` (#66) + ### Deprecated ### Removed ### Fixed +- POST/PUT/DELETE routes now raise `HTTP 500` on DB failure instead of silently returning success (#66) +- Cache cleared only after confirmed successful create, update, or delete (#66) +- DELETE test is now self-contained; no longer depends on POST test having run first (#66) +- UUID assertion in GET all test replaced with explicit `_is_valid_uuid()` validator (#66) +- Emiliano Martínez `middleName` corrected from `""` to `None` in `seed_001` (#66) + ### Security --- diff --git a/codecov.yml b/codecov.yml index 87c6853..59796bb 100644 --- a/codecov.yml +++ b/codecov.yml @@ -44,6 +44,7 @@ ignore: - "^models/.*" - "^postman_collections/.*" - "^schemas/.*" + - "^tools/.*" - "^tests/.*" - ".*\\.yml$" - ".*\\.json$" diff --git a/models/player_model.py b/models/player_model.py index 0ecaf5a..100cb39 100644 --- a/models/player_model.py +++ b/models/player_model.py @@ -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 @@ -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. @@ -50,14 +54,47 @@ class PlayerModel(MainModel): if provided. """ - id: int first_name: str - middle_name: Optional[str] + middle_name: Optional[str] = None last_name: str - date_of_birth: Optional[str] + date_of_birth: Optional[str] = None squad_number: int position: str - abbr_position: Optional[str] - team: Optional[str] - league: Optional[str] - starting11: Optional[bool] + abbr_position: Optional[str] = None + team: Optional[str] = None + league: Optional[str] = None + starting11: Optional[bool] = None + + +class PlayerResponseModel(MainModel): + """ + Pydantic model representing a football Player with a UUID for Retrieve operations. + + Attributes: + id (UUID): The unique identifier for the Player (UUID v4 for API-created + records, UUID v5 for migration-seeded records). + 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. + date_of_birth (Optional[str]): The date of birth of the Player, if provided. + squad_number (int): The unique squad number assigned to the Player. + position (str): The playing position of the Player. + abbr_position (Optional[str]): The abbreviated form of the Player's position, + if any. + team (Optional[str]): The team to which the Player belongs, if any. + league (Optional[str]): The league where the team plays, if any. + starting11 (Optional[bool]): Indicates if the Player is in the starting 11, + if provided. + """ + + id: UUID + first_name: str + middle_name: Optional[str] = None + last_name: str + date_of_birth: Optional[str] = None + squad_number: int + position: str + abbr_position: Optional[str] = None + team: Optional[str] = None + league: Optional[str] = None + starting11: Optional[bool] = None diff --git a/pyproject.toml b/pyproject.toml index 484ce82..d6123da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ exclude = ''' | htmlcov | postman_collections | scripts + | tools | storage | __pycache__ | tests/test_main\.py diff --git a/routes/player_route.py b/routes/player_route.py index ddeecc8..3a8d89c 100644 --- a/routes/player_route.py +++ b/routes/player_route.py @@ -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() @@ -37,30 +40,42 @@ @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) + if player is None: # pragma: no cover + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to create the Player due to a database error.", + ) await simple_memory_cache.clear(CACHE_KEY) + return player # GET -------------------------------------------------------------------------- @@ -68,7 +83,7 @@ async def post_async( @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"], @@ -83,7 +98,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" @@ -96,27 +111,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) @@ -127,7 +142,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"], @@ -144,7 +159,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 @@ -168,27 +183,32 @@ 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) + updated = await player_service.update_async(async_session, player_id, player_model) + if not updated: # pragma: no cover + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update the Player due to a database error.", + ) await simple_memory_cache.clear(CACHE_KEY) @@ -202,22 +222,27 @@ 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) if not player: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - await player_service.delete_async(async_session, player_id) + deleted = await player_service.delete_async(async_session, player_id) + if not deleted: # pragma: no cover + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to delete the Player due to a database error.", + ) await simple_memory_cache.clear(CACHE_KEY) diff --git a/schemas/player_schema.py b/schemas/player_schema.py index a3524a3..77bce83 100644 --- a/schemas/player_schema.py +++ b/schemas/player_schema.py @@ -6,21 +6,74 @@ Used for async database CRUD operations in the application. """ -from sqlalchemy import Column, String, Integer, Boolean +from typing import Optional, Union +from uuid import UUID, uuid4 +from sqlalchemy import Column, String, Integer, Boolean, TypeDecorator from databases.player_database import Base +class HyphenatedUUID(TypeDecorator): + """ + Custom SQLAlchemy type that stores UUIDs as hyphenated strings in SQLite + (e.g. '550e8400-e29b-41d4-a716-446655440000') and returns Python UUID objects. + """ + + impl = String(36) + cache_ok = True + + def process_bind_param( + self, value: Optional[Union[UUID, str]], _dialect + ) -> Optional[str]: + """Convert a UUID or string to a hyphenated UUID string for storage. + + Args: + value: A UUID object, a UUID string, or None. + _dialect: The SQLAlchemy dialect (unused). + + Returns: + A hyphenated UUID string (e.g. '550e8400-e29b-41d4-a716-446655440000'), + or None if value is None. + """ + if value is None: + return None + if isinstance(value, UUID): + return str(value) + return str(UUID(str(value))) + + def process_result_value(self, value: Optional[str], _dialect) -> Optional[UUID]: + """Convert a stored hyphenated UUID string back to a Python UUID object. + + Args: + value: A hyphenated UUID string, or None. + _dialect: The SQLAlchemy dialect (unused). + + Returns: + A Python UUID object, or None if value is None. + """ + if value is None: + return None + return UUID(value) + + class Player(Base): """ SQLAlchemy schema describing a database table of football players. Attributes: - id (Integer): The primary key for the player record. + id (UUID): Surrogate key — the internal technical primary key. Opaque to + end users; used for all CRUD operations and service-to-service calls. + Records created through the API receive a randomly generated UUID v4. + Records seeded by migration scripts use deterministic UUID v5 values so + that IDs are stable across environments and can be safely referenced in + tests. first_name (String): The first name of the player (not nullable). middle_name (String): The middle name of the player. last_name (String): The last name of the player (not nullable). date_of_birth (String): The date of birth of the player. - squad_number (Integer): The squad number of the player (not nullable, unique). + squad_number (Integer): Natural key — the domain identifier meaningful to + API consumers (e.g. squad number 10 = Messi). Unlike the surrogate UUID, + this value is human-readable and stable within a squad roster. It is the + preferred lookup key for external clients. Not nullable, unique. position (String): The playing position of the player (not nullable). abbr_position (String): The abbreviated form of the player's position. team (String): The team to which the player belongs. @@ -30,11 +83,21 @@ class Player(Base): __tablename__ = "players" - id = Column(Integer, primary_key=True) + # Surrogate key: opaque UUID, internal to the system. UUID v4 for API-created + # records (randomly generated); UUID v5 for migration-seeded records + # (deterministic, stable across environments). + id = Column( + HyphenatedUUID(), + primary_key=True, + default=uuid4, + nullable=False, + ) first_name = Column(String, name="firstName", nullable=False) middle_name = Column(String, name="middleName") last_name = Column(String, name="lastName", nullable=False) date_of_birth = Column(String, name="dateOfBirth") + # Natural key: human-readable domain identifier, unique within a squad roster. + # Preferred lookup key for external API consumers over the surrogate UUID. squad_number = Column(Integer, name="squadNumber", unique=True, nullable=False) position = Column(String, nullable=False) abbr_position = Column(String, name="abbrPosition") diff --git a/services/player_service.py b/services/player_service.py index 5c9e894..ade50e4 100644 --- a/services/player_service.py +++ b/services/player_service.py @@ -4,51 +4,64 @@ Functions: - create_async : Add a new Player to the database. - retrieve_all_async : Fetch all Player records. -- retrieve_by_id_async : Fetch a Player by its ID. -- retrieve_by_squad_number_async : Fetch a Player by its Squad Number. +- retrieve_by_id_async : Fetch a Player by its UUID + (surrogate key, internal). +- retrieve_by_squad_number_async : Fetch a Player by its Squad Number + (natural key, domain). - update_async : Fully update an existing Player. - delete_async : Remove a Player from the database. Handles SQLAlchemy exceptions with transaction rollback and logs errors. """ +import logging +from typing import List, Optional +from uuid import UUID + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.exc import SQLAlchemyError -from models.player_model import PlayerModel + +from models.player_model import PlayerRequestModel from schemas.player_schema import Player +# https://github.com/encode/uvicorn/issues/562 +logger = logging.getLogger("uvicorn.error") + # Create ----------------------------------------------------------------------- -async def create_async(async_session: AsyncSession, player_model: PlayerModel): +async def create_async( + async_session: AsyncSession, player_model: PlayerRequestModel +) -> Optional[Player]: """ Creates a new Player in the database. Args: async_session (AsyncSession): The async version of a SQLAlchemy ORM session. - player_model (PlayerModel): The Pydantic model representing the Player to - create. + player_model (PlayerRequestModel): The Pydantic model representing the Player + to create. Returns: - True if the Player was created successfully, False otherwise. + The created Player ORM object with its generated UUID, or None on failure. """ # https://docs.pydantic.dev/latest/concepts/serialization/#modelmodel_dump player = Player(**player_model.model_dump()) async_session.add(player) try: await async_session.commit() - return True - except SQLAlchemyError as error: - print(f"Error trying to create the Player: {error}") + await async_session.refresh(player) + return player + except SQLAlchemyError as error: # pragma: no cover + logger.exception("Error trying to create the Player: %s", error) await async_session.rollback() - return False + return None # Retrieve --------------------------------------------------------------------- -async def retrieve_all_async(async_session: AsyncSession): +async def retrieve_all_async(async_session: AsyncSession) -> List[Player]: """ Retrieves all the players from the database. @@ -65,16 +78,18 @@ async def retrieve_all_async(async_session: AsyncSession): return players -async def retrieve_by_id_async(async_session: AsyncSession, player_id: int): +async def retrieve_by_id_async( + async_session: AsyncSession, player_id: UUID +) -> Optional[Player]: """ - Retrieves a Player by its ID from the database. + Retrieves a Player by its UUID from the database. Args: async_session (AsyncSession): The async version of a SQLAlchemy ORM session. - player_id (int): The ID of the Player to retrieve. + player_id (UUID): The UUID of the Player to retrieve. Returns: - The Player matching the provided ID, or None if not found. + The Player matching the provided UUID, or None if not found. """ player = await async_session.get(Player, player_id) return player @@ -82,7 +97,7 @@ async def retrieve_by_id_async(async_session: AsyncSession, player_id: int): async def retrieve_by_squad_number_async( async_session: AsyncSession, squad_number: int -): +) -> Optional[Player]: """ Retrieves a Player by its Squad Number from the database. @@ -102,20 +117,25 @@ async def retrieve_by_squad_number_async( # Update ----------------------------------------------------------------------- -async def update_async(async_session: AsyncSession, player_model: PlayerModel): +async def update_async( + async_session: AsyncSession, player_id: UUID, player_model: PlayerRequestModel +) -> bool: """ Updates (entirely) an existing Player in the database. Args: async_session (AsyncSession): The async version of a SQLAlchemy ORM session. - 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. Returns: True if the Player was updated successfully, False otherwise. """ - player_id = player_model.id # Extract ID from player_model player = await async_session.get(Player, player_id) + if player is None: # pragma: no cover + logger.error("Player not found for update: %s", player_id) + return False player.first_name = player_model.first_name player.middle_name = player_model.middle_name player.last_name = player_model.last_name @@ -129,8 +149,8 @@ async def update_async(async_session: AsyncSession, player_model: PlayerModel): try: await async_session.commit() return True - except SQLAlchemyError as error: - print(f"Error trying to update the Player: {error}") + except SQLAlchemyError as error: # pragma: no cover + logger.exception("Error trying to update the Player: %s", error) await async_session.rollback() return False @@ -138,13 +158,13 @@ async def update_async(async_session: AsyncSession, player_model: PlayerModel): # Delete ----------------------------------------------------------------------- -async def delete_async(async_session: AsyncSession, player_id: int): +async def delete_async(async_session: AsyncSession, player_id: UUID) -> bool: """ Deletes an existing Player from the database. Args: async_session (AsyncSession): The async version of a SQLAlchemy ORM session. - player_id (int): The ID of the Player to delete. + player_id (UUID): The UUID of the Player to delete. Returns: True if the Player was deleted successfully, False otherwise. @@ -154,7 +174,7 @@ async def delete_async(async_session: AsyncSession, player_id: int): try: await async_session.commit() return True - except SQLAlchemyError as error: - print(f"Error trying to delete the Player: {error}") + except SQLAlchemyError as error: # pragma: no cover + logger.exception("Error trying to delete the Player: %s", error) await async_session.rollback() return False diff --git a/storage/players-sqlite3.db b/storage/players-sqlite3.db index a5f904c..210be57 100644 Binary files a/storage/players-sqlite3.db and b/storage/players-sqlite3.db differ diff --git a/tests/player_stub.py b/tests/player_stub.py index 2b458c9..e0dc2c6 100644 --- a/tests/player_stub.py +++ b/tests/player_stub.py @@ -35,9 +35,9 @@ def existing_player(): Creates a test stub for an existing Player. """ return Player( - id=1, - first_name="Damián", - middle_name="Emiliano", + id="b04965e6-a9bb-591f-8f8a-1adcb2c8dc39", + first_name="Emiliano", + middle_name="", last_name="Martínez", date_of_birth="1992-09-02T00:00:00.000Z", squad_number=23, @@ -52,9 +52,9 @@ def existing_player(): def nonexistent_player(): """ Creates a test stub for a nonexistent (new) Player. + No id is provided; the server generates a UUID on creation. """ return Player( - id=24, first_name="Thiago", middle_name="Ezequiel", last_name="Almada", @@ -70,12 +70,12 @@ def nonexistent_player(): def unknown_player(): """ - Creates a test stub for an unknown Player. + Creates a test stub for an unknown Player (valid UUID format, not in database). """ return Player( - id=999, + id="00000000-0000-0000-0000-000000000000", first_name="John", last_name="Doe", - squad_number="999", + squad_number=999, position="Lipsum", ) diff --git a/tests/test_main.py b/tests/test_main.py index 4b48c4e..d27c669 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -16,10 +16,26 @@ - Conflict and edge case behaviors """ -from tests.player_stub import existing_player, nonexistent_player, unknown_player +from uuid import UUID + +from tests.player_stub import ( + existing_player, + nonexistent_player, + unknown_player, +) PATH = "/players/" + +def _is_valid_uuid(value: str) -> bool: + """Return True if value is a well-formed UUID string, False otherwise.""" + try: + UUID(value) + return True + except ValueError: + return False + + # GET /health/ ----------------------------------------------------------------- @@ -39,7 +55,6 @@ def test_request_get_players_response_header_cache_miss(client): """GET /players/ initial request returns X-Cache: MISS""" # Act response = client.get(PATH) - # Assert assert "X-Cache" in response.headers assert response.headers.get("X-Cache") == "MISS" @@ -50,7 +65,6 @@ def test_request_get_players_response_header_cache_hit(client): # Act client.get(PATH) # initial response = client.get(PATH) # subsequent (cached) - # Assert assert "X-Cache" in response.headers assert response.headers.get("X-Cache") == "HIT" @@ -64,26 +78,24 @@ def test_request_get_players_response_status_ok(client): assert response.status_code == 200 -def test_request_get_players_response_body_players(client): - """GET /players/ returns list of players""" +def test_request_get_players_response_body_each_player_has_uuid(client): + """GET /players/ returns players each containing a UUID id field""" # Act response = client.get(PATH) # Assert players = response.json() - assert ( - len(players) == 25 - ) # Database has 25 players (ID 24 reserved for creation test) - assert all("id" in player for player in players) # Each player has an ID - assert all(player["id"] != 24 for player in players) # ID 24 not in database yet + assert all( + _is_valid_uuid(player["id"]) for player in players + ) # UUID v5 (migration-seeded) # GET /players/{player_id} ----------------------------------------------------- def test_request_get_player_id_nonexistent_response_status_not_found(client): - """GET /players/{player_id} with nonexistent ID returns 404 Not Found""" + """GET /players/{player_id} with nonexistent UUID returns 404 Not Found""" # Arrange - player_id = nonexistent_player().id + player_id = unknown_player().id # Act response = client.get(PATH + str(player_id)) # Assert @@ -108,7 +120,7 @@ def test_request_get_player_id_existing_response_body_player_match(client): response = client.get(PATH + str(player_id)) # Assert player = response.json() - assert player["id"] == player_id + assert player["id"] == str(player_id) # GET /players/squadnumber/{squad_number} -------------------------------------- @@ -167,13 +179,16 @@ def test_request_post_player_body_existing_response_status_conflict(client): def test_request_post_player_body_nonexistent_response_status_created(client): - """POST /players/ with nonexistent player returns 201 Created""" + """POST /players/ with nonexistent player returns 201 Created with a valid UUID""" # Arrange player = nonexistent_player() # Act response = client.post(PATH, json=player.__dict__) # Assert assert response.status_code == 201 + body = response.json() + assert "id" in body + assert UUID(body["id"]).version == 4 # UUID v4 (API-created) # PUT /players/{player_id} ----------------------------------------------------- @@ -229,9 +244,12 @@ def test_request_delete_player_id_unknown_response_status_not_found(client): def test_request_delete_player_id_existing_response_status_no_content(client): - """DELETE /players/{player_id} with existing ID returns 204 No Content""" - # Arrange - player_id = 24 # Thiago Almada - created by POST test, now deleted (atomic) + """DELETE /players/{player_id} with existing UUID returns 204 No Content""" + # Arrange — create the player to be deleted, then resolve its UUID + player = nonexistent_player() + client.post(PATH, json=player.__dict__) + lookup_response = client.get(PATH + "squadnumber/" + str(player.squad_number)) + player_id = lookup_response.json()["id"] # Act response = client.delete(PATH + str(player_id)) # Assert diff --git a/tools/seed_001_starting_eleven.py b/tools/seed_001_starting_eleven.py new file mode 100644 index 0000000..0029173 --- /dev/null +++ b/tools/seed_001_starting_eleven.py @@ -0,0 +1,304 @@ +""" +Seed 001 – Starting Eleven + +Recreates the `players` table using a UUID primary key (stored as TEXT in SQLite) +and seeds the 11 starting-eleven players from the 2022 FIFA World Cup squad of the +Argentina national football team. + +Usage: + python tools/seed_001_starting_eleven.py [--db-path PATH] + +Flags: + --db-path Path to the SQLite database file. + Defaults to ./storage/players-sqlite3.db + +Idempotency: + If all 11 starting-eleven UUIDs are already present the script exits without + making any changes. + +Backup: + A timestamped copy of the original database is written to the same directory + before any destructive operation (e.g. players-sqlite3.db.bak.20260219T120000). +""" + +import argparse +import logging +import shutil +import sqlite3 +import sys +from datetime import datetime +from pathlib import Path + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# UUID v5 (namespace-based, deterministic) values for each starting-eleven +# player. Using UUID v5 guarantees that the same player always gets the same +# ID regardless of when or where the migration runs, which makes these values +# safe to hardcode in test fixtures. Records created later through the API +# (POST) use UUID v4 (randomly generated) instead — there is no natural stable +# key to derive a deterministic UUID from for user-supplied data. +# --------------------------------------------------------------------------- +STARTING_ELEVEN = [ + { + "id": "b04965e6-a9bb-591f-8f8a-1adcb2c8dc39", + "firstName": "Emiliano", + "middleName": None, + "lastName": "Martínez", + "dateOfBirth": "1992-09-02T00:00:00.000Z", + "squadNumber": 23, + "position": "Goalkeeper", + "abbrPosition": "GK", + "team": "Aston Villa FC", + "league": "Premier League", + "starting11": 1, + }, + { + "id": "4b166dbe-d99d-5091-abdd-95b83330ed3a", + "firstName": "Nahuel", + "middleName": None, + "lastName": "Molina", + "dateOfBirth": "1998-04-06T00:00:00.000Z", + "squadNumber": 26, + "position": "Right-Back", + "abbrPosition": "RB", + "team": "Atlético Madrid", + "league": "La Liga", + "starting11": 1, + }, + { + "id": "98123fde-012f-5ff3-8b50-881449dac91a", + "firstName": "Cristian", + "middleName": "Gabriel", + "lastName": "Romero", + "dateOfBirth": "1998-04-27T00:00:00.000Z", + "squadNumber": 13, + "position": "Centre-Back", + "abbrPosition": "CB", + "team": "Tottenham Hotspur", + "league": "Premier League", + "starting11": 1, + }, + { + "id": "6ed955c6-506a-5343-9be4-2c0afae02eef", + "firstName": "Nicolás", + "middleName": "Hernán Gonzalo", + "lastName": "Otamendi", + "dateOfBirth": "1988-02-12T00:00:00.000Z", + "squadNumber": 19, + "position": "Centre-Back", + "abbrPosition": "CB", + "team": "SL Benfica", + "league": "Liga Portugal", + "starting11": 1, + }, + { + "id": "c8691da2-158a-5ed6-8537-0e6f140801f2", + "firstName": "Nicolás", + "middleName": "Alejandro", + "lastName": "Tagliafico", + "dateOfBirth": "1992-08-31T00:00:00.000Z", + "squadNumber": 3, + "position": "Left-Back", + "abbrPosition": "LB", + "team": "Olympique Lyon", + "league": "Ligue 1", + "starting11": 1, + }, + { + "id": "a6c4fc8f-6950-51de-a9ae-2c519c465071", + "firstName": "Ángel", + "middleName": "Fabián", + "lastName": "Di María", + "dateOfBirth": "1988-02-14T00:00:00.000Z", + "squadNumber": 11, + "position": "Right Winger", + "abbrPosition": "RW", + "team": "SL Benfica", + "league": "Liga Portugal", + "starting11": 1, + }, + { + "id": "a9f96b98-dd44-5216-ab0d-dbfc6b262edf", + "firstName": "Rodrigo", + "middleName": "Javier", + "lastName": "de Paul", + "dateOfBirth": "1994-05-24T00:00:00.000Z", + "squadNumber": 7, + "position": "Central Midfield", + "abbrPosition": "CM", + "team": "Atlético Madrid", + "league": "La Liga", + "starting11": 1, + }, + { + "id": "e99caacd-6c45-5906-bd9f-b79e62f25963", + "firstName": "Enzo", + "middleName": "Jeremías", + "lastName": "Fernández", + "dateOfBirth": "2001-01-17T00:00:00.000Z", + "squadNumber": 24, + "position": "Central Midfield", + "abbrPosition": "CM", + "team": "Chelsea FC", + "league": "Premier League", + "starting11": 1, + }, + { + "id": "e4d80b30-151e-51b5-9f4f-18a3b82718e6", + "firstName": "Alexis", + "middleName": None, + "lastName": "Mac Allister", + "dateOfBirth": "1998-12-24T00:00:00.000Z", + "squadNumber": 20, + "position": "Central Midfield", + "abbrPosition": "CM", + "team": "Liverpool FC", + "league": "Premier League", + "starting11": 1, + }, + { + "id": "0159d6c7-973f-5e7a-a9a0-d195d0ea6fe2", + "firstName": "Lionel", + "middleName": "Andrés", + "lastName": "Messi", + "dateOfBirth": "1987-06-24T00:00:00.000Z", + "squadNumber": 10, + "position": "Right Winger", + "abbrPosition": "RW", + "team": "Inter Miami CF", + "league": "Major League Soccer", + "starting11": 1, + }, + { + "id": "7fef88f7-411d-5669-b42d-bf5fc7f9b58b", + "firstName": "Julián", + "middleName": None, + "lastName": "Álvarez", + "dateOfBirth": "2000-01-31T00:00:00.000Z", + "squadNumber": 9, + "position": "Centre-Forward", + "abbrPosition": "CF", + "team": "Manchester City", + "league": "Premier League", + "starting11": 1, + }, +] + +CREATE_TABLE_SQL = """ +CREATE TABLE players ( + id TEXT PRIMARY KEY NOT NULL, + firstName TEXT NOT NULL, + middleName TEXT, + lastName TEXT NOT NULL, + dateOfBirth TEXT, + squadNumber INTEGER NOT NULL UNIQUE, + position TEXT NOT NULL, + abbrPosition TEXT, + team TEXT, + league TEXT, + starting11 INTEGER +) +""" + +INSERT_SQL = """ +INSERT OR IGNORE INTO players + (id, firstName, middleName, lastName, dateOfBirth, + squadNumber, position, abbrPosition, team, league, starting11) +VALUES + (:id, :firstName, :middleName, :lastName, :dateOfBirth, + :squadNumber, :position, :abbrPosition, :team, :league, :starting11) +""" + + +def _id_column_type(conn: sqlite3.Connection) -> str: + """Return the SQLite type of the 'id' column, or '' if the table is absent.""" + cursor = conn.execute("PRAGMA table_info(players)") + for row in cursor.fetchall(): + if row[1] == "id": + return row[2].upper() + return "" + + +def _already_migrated(conn: sqlite3.Connection) -> bool: + """Return True when all 11 starting-eleven UUIDs are already in the table.""" + uuids = [p["id"] for p in STARTING_ELEVEN] + placeholders = ",".join("?" * len(uuids)) + cursor = conn.execute( + f"SELECT COUNT(*) FROM players WHERE id IN ({placeholders})", uuids + ) + return cursor.fetchone()[0] == len(uuids) + + +def _backup(db_path: Path) -> None: + """Write a timestamped backup of the database file.""" + stamp = datetime.now().strftime("%Y%m%dT%H%M%S") + backup_path = db_path.with_suffix(f".db.bak.{stamp}") + shutil.copy2(db_path, backup_path) + logger.info("Backup created: %s", backup_path) + + +def run(db_path: Path) -> None: + """Execute migration 001.""" + if not db_path.exists(): + logger.error("Database not found: %s", db_path) + sys.exit(1) + + conn = sqlite3.connect(db_path) + + try: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + + id_type = _id_column_type(conn) + + if id_type == "TEXT" and _already_migrated(conn): + logger.info( + "Migration 001 already applied – all starting-eleven UUIDs present. " + "Skipping." + ) + return + + _backup(db_path) + + if id_type != "": + # Drop the old table (INTEGER or partially migrated schema) + logger.info("Dropping existing 'players' table (id type: %s).", id_type) + conn.execute("DROP TABLE IF EXISTS players") + + logger.info("Creating 'players' table with UUID primary key.") + conn.execute(CREATE_TABLE_SQL) + + logger.info("Inserting %d starting-eleven players.", len(STARTING_ELEVEN)) + conn.executemany(INSERT_SQL, STARTING_ELEVEN) + conn.commit() + + logger.info("Migration 001 – Starting Eleven completed successfully.") + + except sqlite3.Error as exc: + conn.rollback() + logger.exception("Migration failed: %s", exc) + sys.exit(1) + finally: + conn.close() + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Migration 001 – seed starting-eleven players with UUID PKs." + ) + parser.add_argument( + "--db-path", + default="./storage/players-sqlite3.db", + help="Path to the SQLite database file (default: ./storage/players-sqlite3.db)", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + run(Path(args.db_path)) diff --git a/tools/seed_002_substitutes.py b/tools/seed_002_substitutes.py new file mode 100644 index 0000000..a64c481 --- /dev/null +++ b/tools/seed_002_substitutes.py @@ -0,0 +1,307 @@ +""" +Seed 002 – Substitutes + +Seeds the 14 substitute players from the 2022 FIFA World Cup squad of the +Argentina national football team into an already-seeded `players` table +(UUID primary key, created by Seed 001). + +Usage: + python tools/seed_002_substitutes.py [--db-path PATH] + +Flags: + --db-path Path to the SQLite database file. + Defaults to ./storage/players-sqlite3.db + +Idempotency: + If all 14 substitute UUIDs are already present the script exits without + making any changes. + +Prerequisite: + Seed 001 must have been executed before running this script. + The `players` table must already exist with a TEXT (UUID) primary key. +""" + +import argparse +import logging +import sqlite3 +import sys +from pathlib import Path + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", +) +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# UUID v5 (namespace-based, deterministic) values for each substitute player. +# See seed_001_starting_eleven.py for the rationale behind using UUID v5 +# in migrations versus UUID v4 for API-created records. +# --------------------------------------------------------------------------- +SUBSTITUTES = [ + { + "id": "52524d6e-10dc-5261-aa36-8b2efcbaa5f0", + "firstName": "Franco", + "middleName": "Daniel", + "lastName": "Armani", + "dateOfBirth": "1986-10-16T00:00:00.000Z", + "squadNumber": 1, + "position": "Goalkeeper", + "abbrPosition": "GK", + "team": "River Plate", + "league": "Copa de la Liga", + "starting11": 0, + }, + { + "id": "91c274f2-9a0d-5ce6-ac3d-7529f452df21", + "firstName": "Gerónimo", + "middleName": None, + "lastName": "Rulli", + "dateOfBirth": "1992-05-20T00:00:00.000Z", + "squadNumber": 12, + "position": "Goalkeeper", + "abbrPosition": "GK", + "team": "Ajax Amsterdam", + "league": "Eredivisie", + "starting11": 0, + }, + { + "id": "0ff1e264-520d-543a-87dd-181a491e667e", + "firstName": "Juan", + "middleName": "Marcos", + "lastName": "Foyth", + "dateOfBirth": "1998-01-12T00:00:00.000Z", + "squadNumber": 2, + "position": "Right-Back", + "abbrPosition": "RB", + "team": "Villarreal", + "league": "La Liga", + "starting11": 0, + }, + { + "id": "23986425-d3a5-5e13-8bab-299745777a8d", + "firstName": "Gonzalo", + "middleName": "Ariel", + "lastName": "Montiel", + "dateOfBirth": "1997-01-01T00:00:00.000Z", + "squadNumber": 4, + "position": "Right-Back", + "abbrPosition": "RB", + "team": "Nottingham Forest", + "league": "Premier League", + "starting11": 0, + }, + { + "id": "c15b38c9-9a3e-543c-a703-dd742f25b4d5", + "firstName": "Germán", + "middleName": "Alejo", + "lastName": "Pezzella", + "dateOfBirth": "1991-06-27T00:00:00.000Z", + "squadNumber": 6, + "position": "Centre-Back", + "abbrPosition": "CB", + "team": "Real Betis Balompié", + "league": "La Liga", + "starting11": 0, + }, + { + "id": "db680066-c83d-5ed7-89a4-1d79466ea62d", + "firstName": "Marcos", + "middleName": "Javier", + "lastName": "Acuña", + "dateOfBirth": "1991-10-28T00:00:00.000Z", + "squadNumber": 8, + "position": "Left-Back", + "abbrPosition": "LB", + "team": "Sevilla FC", + "league": "La Liga", + "starting11": 0, + }, + { + "id": "cadb7952-2bba-5609-88d4-8e47ec4e7920", + "firstName": "Lisandro", + "middleName": None, + "lastName": "Martínez", + "dateOfBirth": "1998-01-18T00:00:00.000Z", + "squadNumber": 25, + "position": "Centre-Back", + "abbrPosition": "CB", + "team": "Manchester United", + "league": "Premier League", + "starting11": 0, + }, + { + "id": "35140057-a2a4-5adb-a500-46f8ed8b66a9", + "firstName": "Leandro", + "middleName": "Daniel", + "lastName": "Paredes", + "dateOfBirth": "1994-06-29T00:00:00.000Z", + "squadNumber": 5, + "position": "Defensive Midfield", + "abbrPosition": "DM", + "team": "AS Roma", + "league": "Serie A", + "starting11": 0, + }, + { + "id": "66e549b7-01e2-5d07-98d5-430f74d8d3b2", + "firstName": "Exequiel", + "middleName": "Alejandro", + "lastName": "Palacios", + "dateOfBirth": "1998-10-05T00:00:00.000Z", + "squadNumber": 14, + "position": "Central Midfield", + "abbrPosition": "CM", + "team": "Bayer 04 Leverkusen", + "league": "Bundesliga", + "starting11": 0, + }, + { + "id": "292c8e99-2378-55aa-83d8-350e0ac3f1cc", + "firstName": "Alejandro", + "middleName": "Darío", + "lastName": "Gómez", + "dateOfBirth": "1988-02-15T00:00:00.000Z", + "squadNumber": 17, + "position": "Left Winger", + "abbrPosition": "LW", + "team": "AC Monza", + "league": "Serie A", + "starting11": 0, + }, + { + "id": "0e3b230a-0509-55d8-96a0-9875f387a2be", + "firstName": "Guido", + "middleName": None, + "lastName": "Rodríguez", + "dateOfBirth": "1994-04-12T00:00:00.000Z", + "squadNumber": 18, + "position": "Defensive Midfield", + "abbrPosition": "DM", + "team": "Real Betis Balompié", + "league": "La Liga", + "starting11": 0, + }, + { + "id": "4c507660-a83b-55c0-9b2b-83eccb07723d", + "firstName": "Ángel", + "middleName": "Martín", + "lastName": "Correa", + "dateOfBirth": "1995-03-09T00:00:00.000Z", + "squadNumber": 15, + "position": "Right Winger", + "abbrPosition": "RW", + "team": "Atlético Madrid", + "league": "La Liga", + "starting11": 0, + }, + { + "id": "c2708a8b-120a-56f5-a30d-990048af87cc", + "firstName": "Paulo", + "middleName": "Exequiel", + "lastName": "Dybala", + "dateOfBirth": "1993-11-15T00:00:00.000Z", + "squadNumber": 21, + "position": "Second Striker", + "abbrPosition": "SS", + "team": "AS Roma", + "league": "Serie A", + "starting11": 0, + }, + { + "id": "e7263999-68b6-5a23-b530-af25b7efd632", + "firstName": "Lautaro", + "middleName": "Javier", + "lastName": "Martínez", + "dateOfBirth": "1997-08-22T00:00:00.000Z", + "squadNumber": 22, + "position": "Centre-Forward", + "abbrPosition": "CF", + "team": "Inter Milan", + "league": "Serie A", + "starting11": 0, + }, +] + +INSERT_SQL = """ +INSERT OR IGNORE INTO players + (id, firstName, middleName, lastName, dateOfBirth, + squadNumber, position, abbrPosition, team, league, starting11) +VALUES + (:id, :firstName, :middleName, :lastName, :dateOfBirth, + :squadNumber, :position, :abbrPosition, :team, :league, :starting11) +""" + + +def _table_exists(conn: sqlite3.Connection) -> bool: + """Return True when the `players` table is present.""" + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='players'" + ) + return cursor.fetchone() is not None + + +def _already_migrated(conn: sqlite3.Connection) -> bool: + """Return True when all 14 substitute UUIDs are already in the table.""" + uuids = [p["id"] for p in SUBSTITUTES] + placeholders = ",".join("?" * len(uuids)) + cursor = conn.execute( + f"SELECT COUNT(*) FROM players WHERE id IN ({placeholders})", uuids + ) + return cursor.fetchone()[0] == len(uuids) + + +def run(db_path: Path) -> None: + """Execute migration 002.""" + if not db_path.exists(): + logger.error("Database not found: %s", db_path) + sys.exit(1) + + conn = sqlite3.connect(db_path) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + + try: + if not _table_exists(conn): + logger.error( + "The 'players' table does not exist. " + "Run seed_001_starting_eleven.py first." + ) + sys.exit(1) + + if _already_migrated(conn): + logger.info( + "Migration 002 already applied - all substitute UUIDs present. " + "Skipping." + ) + return + + logger.info("Inserting %d substitute players.", len(SUBSTITUTES)) + conn.executemany(INSERT_SQL, SUBSTITUTES) + conn.commit() + + logger.info("Migration 002 - Substitutes completed successfully.") + + except sqlite3.Error as exc: + conn.rollback() + logger.exception("Migration failed: %s", exc) + sys.exit(1) + finally: + conn.close() + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Migration 002 - seed substitute players with UUID PKs." + ) + parser.add_argument( + "--db-path", + default="./storage/players-sqlite3.db", + help="Path to the SQLite database file (default: ./storage/players-sqlite3.db)", + ) + return parser.parse_args() + + +if __name__ == "__main__": + args = _parse_args() + run(Path(args.db_path))