Merge branch 'feature/phase-2-first-public-european-importer' into develop

This commit is contained in:
bisco
2026-04-11 00:45:39 +02:00
5 changed files with 702 additions and 1 deletions

View File

@ -16,6 +16,7 @@ The current application baseline provides:
- user-scoped plain-text scouting notes on player detail pages - user-scoped plain-text scouting notes on player detail pages
- user-scoped saved searches (save, rerun, delete) - user-scoped saved searches (save, rerun, delete)
- first real-data ingestion command baseline (`import_hoopdata_demo_competition`) with idempotent source-identity mapping - first real-data ingestion command baseline (`import_hoopdata_demo_competition`) with idempotent source-identity mapping
- first public European importer (`import_lba_public_serie_a`) for LBA Serie A player-stat scope with idempotent external-ID binding
Accepted technical and product-shaping decisions live in: Accepted technical and product-shaping decisions live in:
- `docs/ARCHITECTURE.md` - `docs/ARCHITECTURE.md`
@ -66,6 +67,12 @@ First real-data importer (ADR-0009 baseline):
- explicit input path import: - explicit input path import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition --input /app/scouting/sample_data/imports/hoopdata_demo_serie_a2_2025_2026.json` `docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_hoopdata_demo_competition --input /app/scouting/sample_data/imports/hoopdata_demo_serie_a2_2025_2026.json`
First public European importer (LBA Serie A scope):
- live public-source import (season start year 2025):
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025`
- deterministic local-fixture import:
`docker compose --env-file .env -f infra/docker-compose.yml exec -T app python manage.py import_lba_public_serie_a --season 2025 --fixture /app/scouting/sample_data/imports/lba_public_serie_a_fixture.json`
Legacy shared favorites and notes from the pre-auth MVP are cleared by the early-stage ownership migration so the app can move cleanly to user-scoped data. Legacy shared favorites and notes from the pre-auth MVP are cleared by the early-stage ownership migration so the app can move cleanly to user-scoped data.
## Workflow ## Workflow

View File

@ -0,0 +1,359 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from urllib.error import URLError
from urllib.parse import urlencode
from urllib.request import urlopen
from django.db import transaction
from scouting.models import (
Competition,
ExternalEntityMapping,
Player,
PlayerSeason,
PlayerSeasonStats,
Season,
Team,
)
class ImportValidationError(ValueError):
pass
LBA_SOURCE_NAME = "lba_public"
LBA_COMPETITION_EXTERNAL_ID = "lba-serie-a"
LBA_COMPETITION_NAME = "Lega Basket Serie A"
LBA_COUNTRY = "IT"
LBA_LEVEL = "top"
LBA_STATS_ENDPOINT = "https://www.legabasket.it/api/statistics/get-players-statistics"
LBA_STAT_CATEGORIES = ["points", "assists", "regain_balls", "lost_balls", "plus_minus", "rating_oer"]
CATEGORY_TO_MODEL_FIELD = {
"points": "points",
"assists": "assists",
"regain_balls": "steals",
"lost_balls": "turnovers",
"plus_minus": "plus_minus",
"rating_oer": "offensive_rating",
}
@dataclass
class ImportSummary:
players_created: int = 0
players_updated: int = 0
teams_created: int = 0
teams_updated: int = 0
contexts_created: int = 0
contexts_updated: int = 0
class LbaPublicStatsSource:
def __init__(self, base_url: str = LBA_STATS_ENDPOINT, timeout_sec: int = 30):
self.base_url = base_url
self.timeout_sec = timeout_sec
def fetch_category(self, season_start_year: int, category: str) -> dict:
query = urlencode({"s": season_start_year, "cat": category})
url = f"{self.base_url}?{query}"
try:
with urlopen(url, timeout=self.timeout_sec) as response:
payload = json.loads(response.read().decode("utf-8"))
except URLError as exc:
raise ImportValidationError(f"Could not fetch LBA source URL '{url}': {exc}") from exc
except json.JSONDecodeError as exc:
raise ImportValidationError(f"Invalid JSON received from '{url}': {exc}") from exc
if not isinstance(payload, dict) or "stats" not in payload:
raise ImportValidationError(f"LBA source response from '{url}' is missing 'stats'.")
return payload
class LbaFixtureStatsSource:
def __init__(self, fixture_path: Path):
self.fixture_path = fixture_path
try:
payload = json.loads(fixture_path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ImportValidationError(f"Fixture file not found: {fixture_path}") from exc
except json.JSONDecodeError as exc:
raise ImportValidationError(f"Invalid fixture JSON at '{fixture_path}': {exc}") from exc
categories = payload.get("categories")
if not isinstance(categories, dict):
raise ImportValidationError("Fixture payload must include a 'categories' object.")
self.categories = categories
def fetch_category(self, season_start_year: int, category: str) -> dict:
del season_start_year
payload = self.categories.get(category)
if payload is None:
raise ImportValidationError(f"Fixture payload missing category '{category}'.")
if not isinstance(payload, dict) or "stats" not in payload:
raise ImportValidationError(f"Fixture category '{category}' is missing 'stats'.")
return payload
class LbaSerieAPublicImporter:
def __init__(self, *, season_start_year: int, source: LbaPublicStatsSource | LbaFixtureStatsSource):
self.season_start_year = season_start_year
self.source = source
self.summary = ImportSummary()
@transaction.atomic
def run(self) -> ImportSummary:
aggregated = self._collect_players()
competition = self._upsert_competition()
for record in aggregated.values():
season = self._upsert_season(record["year"])
team = self._upsert_team(record)
player = self._upsert_player(record)
context = self._upsert_player_season(record, player, team, season, competition)
self._upsert_stats(context, record)
return self.summary
def _collect_players(self) -> dict:
players = {}
for category in LBA_STAT_CATEGORIES:
payload = self.source.fetch_category(self.season_start_year, category)
stats = payload.get("stats")
if not isinstance(stats, list):
raise ImportValidationError(f"Category '{category}' response must include a list in 'stats'.")
for row in stats:
self._validate_stat_row(category, row)
key = (row["player_id"], row["team_id"], row["year"])
if key not in players:
players[key] = {
"player_id": row["player_id"],
"team_id": row["team_id"],
"year": row["year"],
"name": row["name"],
"surname": row["surname"],
"team_name": row["team_name"],
"scores": {},
}
players[key]["scores"][category] = self._to_decimal(row.get("score"))
if not players:
raise ImportValidationError("No player statistics found from LBA source.")
return players
def _validate_stat_row(self, category: str, row: dict) -> None:
if not isinstance(row, dict):
raise ImportValidationError(f"Category '{category}' contains a non-object stat row.")
for field in ["player_id", "team_id", "year", "name", "surname", "team_name", "score"]:
if row.get(field) in (None, ""):
raise ImportValidationError(f"Category '{category}' row missing required field '{field}'.")
@staticmethod
def _to_decimal(value) -> Decimal:
return Decimal(str(value))
def _mapping_for(self, entity_type: str, external_id: str) -> ExternalEntityMapping | None:
return ExternalEntityMapping.objects.filter(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
external_id=external_id,
).first()
def _bind_mapping(self, *, entity_type: str, external_id: str, object_id: int) -> None:
existing_for_external = ExternalEntityMapping.objects.filter(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
external_id=external_id,
).first()
if existing_for_external and existing_for_external.object_id != object_id:
raise ImportValidationError(
f"External ID '{external_id}' for {entity_type} is already linked to a different record."
)
existing_for_object = ExternalEntityMapping.objects.filter(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
object_id=object_id,
).first()
if existing_for_object and existing_for_object.external_id != external_id:
raise ImportValidationError(
f"Conflicting mapping for {entity_type} object {object_id}: "
f"'{existing_for_object.external_id}' vs '{external_id}'."
)
ExternalEntityMapping.objects.get_or_create(
source_name=LBA_SOURCE_NAME,
entity_type=entity_type,
external_id=external_id,
defaults={"object_id": object_id},
)
def _upsert_competition(self) -> Competition:
mapping = self._mapping_for(ExternalEntityMapping.EntityType.COMPETITION, LBA_COMPETITION_EXTERNAL_ID)
defaults = {"country": LBA_COUNTRY, "level": LBA_LEVEL}
if mapping:
competition = Competition.objects.filter(pk=mapping.object_id).first()
if competition is None:
raise ImportValidationError("Competition mapping points to a missing record.")
Competition.objects.filter(pk=competition.pk).update(name=LBA_COMPETITION_NAME, **defaults)
competition.refresh_from_db()
else:
competition, _ = Competition.objects.get_or_create(name=LBA_COMPETITION_NAME, defaults=defaults)
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.COMPETITION,
external_id=LBA_COMPETITION_EXTERNAL_ID,
object_id=competition.id,
)
return competition
def _upsert_season(self, year: int) -> Season:
season_name = f"{year}-{year + 1}"
season, created = Season.objects.get_or_create(
name=season_name,
defaults={"start_year": year, "end_year": year + 1},
)
if not created and (season.start_year != year or season.end_year != year + 1):
raise ImportValidationError(
f"Season '{season_name}' exists but does not match expected years {year}-{year + 1}."
)
return season
def _upsert_team(self, record: dict) -> Team:
external_id = str(record["team_id"])
mapping = self._mapping_for(ExternalEntityMapping.EntityType.TEAM, external_id)
if mapping:
team = Team.objects.filter(pk=mapping.object_id).first()
if team is None:
raise ImportValidationError("Team mapping points to a missing record.")
updates = []
if team.name != record["team_name"]:
team.name = record["team_name"]
updates.append("name")
if team.country != LBA_COUNTRY:
team.country = LBA_COUNTRY
updates.append("country")
if updates:
team.save(update_fields=updates + ["updated_at"])
self.summary.teams_updated += 1
else:
team, created = Team.objects.get_or_create(
name=record["team_name"],
country=LBA_COUNTRY,
defaults={},
)
if created:
self.summary.teams_created += 1
else:
self.summary.teams_updated += 1
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.TEAM,
external_id=external_id,
object_id=team.id,
)
return team
def _upsert_player(self, record: dict) -> Player:
external_id = str(record["player_id"])
mapping = self._mapping_for(ExternalEntityMapping.EntityType.PLAYER, external_id)
full_name = f"{record['name']} {record['surname']}".strip()
if mapping:
player = Player.objects.filter(pk=mapping.object_id).first()
if player is None:
raise ImportValidationError("Player mapping points to a missing record.")
position = player.position or Player.Position.SG
player.full_name = full_name
player.first_name = record["name"]
player.last_name = record["surname"]
player.position = position
player.save()
self.summary.players_updated += 1
else:
# LBA stats endpoint does not expose position directly. To satisfy the current
# required model field without guessing role/taxonomy data, we use a neutral
# default and keep role/specialty ownership untouched.
player = Player.objects.create(
full_name=full_name,
first_name=record["name"],
last_name=record["surname"],
position=Player.Position.SG,
)
self.summary.players_created += 1
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.PLAYER,
external_id=external_id,
object_id=player.id,
)
return player
def _upsert_player_season(
self,
record: dict,
player: Player,
team: Team,
season: Season,
competition: Competition,
) -> PlayerSeason:
context_external_id = f"{record['year']}:{record['team_id']}:{record['player_id']}"
mapping = self._mapping_for(ExternalEntityMapping.EntityType.PLAYER_SEASON, context_external_id)
if mapping:
context = PlayerSeason.objects.filter(pk=mapping.object_id).first()
if context is None:
raise ImportValidationError("PlayerSeason mapping points to a missing record.")
if (
context.player_id != player.id
or context.team_id != team.id
or context.season_id != season.id
or context.competition_id != competition.id
):
raise ImportValidationError("Mapped player-season context does not match incoming source identity.")
self.summary.contexts_updated += 1
else:
context, created = PlayerSeason.objects.get_or_create(
player=player,
team=team,
season=season,
competition=competition,
defaults={},
)
if created:
self.summary.contexts_created += 1
else:
self.summary.contexts_updated += 1
self._bind_mapping(
entity_type=ExternalEntityMapping.EntityType.PLAYER_SEASON,
external_id=context_external_id,
object_id=context.id,
)
return context
def _upsert_stats(self, context: PlayerSeason, record: dict) -> None:
stats_defaults = {
"points": None,
"assists": None,
"steals": None,
"turnovers": None,
"blocks": None,
"efg_pct": None,
"ts_pct": None,
"plus_minus": None,
"offensive_rating": None,
"defensive_rating": None,
}
for category, value in record["scores"].items():
model_field = CATEGORY_TO_MODEL_FIELD.get(category)
if model_field:
stats_defaults[model_field] = value
PlayerSeasonStats.objects.update_or_create(player_season=context, defaults=stats_defaults)

View File

@ -0,0 +1,57 @@
from __future__ import annotations
from pathlib import Path
from django.core.management.base import BaseCommand, CommandError
from scouting.importers.lba_public import (
LBA_SOURCE_NAME,
LbaFixtureStatsSource,
LbaPublicStatsSource,
LbaSerieAPublicImporter,
ImportValidationError,
)
class Command(BaseCommand):
help = "Import public LBA Serie A player statistics for one season using the ADR-0009 baseline flow."
def add_arguments(self, parser):
parser.add_argument(
"--season",
type=int,
default=2025,
help="Season start year to import from LBA public statistics API (default: 2025).",
)
parser.add_argument(
"--fixture",
default="",
help="Optional local fixture JSON path for deterministic/offline runs.",
)
def handle(self, *args, **options):
season_start_year = options["season"]
fixture_path = (options.get("fixture") or "").strip()
try:
if fixture_path:
source = LbaFixtureStatsSource(Path(fixture_path))
source_label = f"fixture={fixture_path}"
else:
source = LbaPublicStatsSource()
source_label = "public=https://www.legabasket.it/api/statistics/get-players-statistics"
summary = LbaSerieAPublicImporter(season_start_year=season_start_year, source=source).run()
except ImportValidationError as exc:
raise CommandError(f"LBA public import failed: {exc}") from exc
self.stdout.write(
self.style.SUCCESS(
"Imported LBA Serie A public statistics successfully. "
f"Source={LBA_SOURCE_NAME}; season={season_start_year}; "
f"players +{summary.players_created}/~{summary.players_updated}, "
f"teams +{summary.teams_created}/~{summary.teams_updated}, "
f"contexts +{summary.contexts_created}/~{summary.contexts_updated}."
)
)
self.stdout.write(f"Input: {source_label}")

View File

@ -0,0 +1,148 @@
{
"categories": {
"points": {
"stats": [
{
"category": "points",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 19.5,
"team_id": 1642
},
{
"category": "points",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 11.2,
"team_id": 1715
}
]
},
"assists": {
"stats": [
{
"category": "assists",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 4.4,
"team_id": 1642
},
{
"category": "assists",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 2.1,
"team_id": 1715
}
]
},
"regain_balls": {
"stats": [
{
"category": "regain_balls",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 1.3,
"team_id": 1642
},
{
"category": "regain_balls",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 0.8,
"team_id": 1715
}
]
},
"lost_balls": {
"stats": [
{
"category": "lost_balls",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 2.2,
"team_id": 1642
},
{
"category": "lost_balls",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 1.0,
"team_id": 1715
}
]
},
"plus_minus": {
"stats": [
{
"category": "plus_minus",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 3.1,
"team_id": 1642
},
{
"category": "plus_minus",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 1.5,
"team_id": 1715
}
]
},
"rating_oer": {
"stats": [
{
"category": "rating_oer",
"player_id": 6609,
"name": "Muhammad-Ali",
"surname": "Abdur-Rahkman",
"team_name": "NutriBullet Treviso Basket",
"year": 2025,
"score": 111.4,
"team_id": 1642
},
{
"category": "rating_oer",
"player_id": 45,
"name": "Nicola",
"surname": "Akele",
"team_name": "Virtus Olidata Bologna",
"year": 2025,
"score": 106.7,
"team_id": 1715
}
]
}
}
}

View File

@ -1,9 +1,10 @@
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from pathlib import Path
from urllib.parse import urlencode from urllib.parse import urlencode
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management import call_command from django.core.management import CommandError, call_command
from django.db import connection from django.db import connection
from django.db.migrations.executor import MigrationExecutor from django.db.migrations.executor import MigrationExecutor
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
@ -526,6 +527,135 @@ class FirstRealIngestionFlowTests(TestCase):
self.assertContains(detail_response, "PTS 17.2") self.assertContains(detail_response, "PTS 17.2")
class FirstPublicEuropeanImporterTests(TestCase):
COMMAND_NAME = "import_lba_public_serie_a"
SOURCE_NAME = "lba_public"
FIXTURE_PATH = Path(__file__).resolve().parent / "sample_data" / "imports" / "lba_public_serie_a_fixture.json"
def run_import(self):
call_command(self.COMMAND_NAME, fixture=str(self.FIXTURE_PATH), season=2025)
def test_importer_command_runs_successfully_for_lba_scope(self):
self.run_import()
self.assertGreaterEqual(Player.objects.count(), 2)
def test_importer_creates_expected_entities_contexts_and_stats(self):
self.run_import()
self.assertTrue(Competition.objects.filter(name="Lega Basket Serie A").exists())
self.assertTrue(Season.objects.filter(name="2025-2026", start_year=2025, end_year=2026).exists())
self.assertTrue(Player.objects.filter(full_name="Muhammad-Ali Abdur-Rahkman").exists())
self.assertTrue(Player.objects.filter(full_name="Nicola Akele").exists())
self.assertTrue(Team.objects.filter(name="NutriBullet Treviso Basket", country="IT").exists())
self.assertTrue(PlayerSeason.objects.filter(player__full_name="Muhammad-Ali Abdur-Rahkman").exists())
self.assertTrue(PlayerSeasonStats.objects.filter(player_season__player__full_name="Muhammad-Ali Abdur-Rahkman").exists())
def test_importer_is_idempotent_for_same_input(self):
self.run_import()
first_counts = {
"players": Player.objects.count(),
"teams": Team.objects.count(),
"contexts": PlayerSeason.objects.count(),
"stats": PlayerSeasonStats.objects.count(),
"mappings": ExternalEntityMapping.objects.filter(source_name=self.SOURCE_NAME).count(),
}
self.run_import()
self.assertEqual(Player.objects.count(), first_counts["players"])
self.assertEqual(Team.objects.count(), first_counts["teams"])
self.assertEqual(PlayerSeason.objects.count(), first_counts["contexts"])
self.assertEqual(PlayerSeasonStats.objects.count(), first_counts["stats"])
self.assertEqual(
ExternalEntityMapping.objects.filter(source_name=self.SOURCE_NAME).count(),
first_counts["mappings"],
)
def test_importer_respects_external_entity_mapping(self):
self.run_import()
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.COMPETITION,
external_id="lba-serie-a",
).exists()
)
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.PLAYER,
external_id="6609",
).exists()
)
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.TEAM,
external_id="1642",
).exists()
)
self.assertTrue(
ExternalEntityMapping.objects.filter(
source_name=self.SOURCE_NAME,
entity_type=ExternalEntityMapping.EntityType.PLAYER_SEASON,
external_id="2025:1642:6609",
).exists()
)
def test_importer_does_not_overwrite_internal_scouting_enrichment(self):
role = Role.objects.create(name="internal role lba", slug="internal-role-lba")
specialty = Specialty.objects.create(name="internal specialty lba", slug="internal-specialty-lba")
self.run_import()
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
player.roles.add(role)
player.specialties.add(specialty)
self.run_import()
player.refresh_from_db()
self.assertTrue(player.roles.filter(pk=role.pk).exists())
self.assertTrue(player.specialties.filter(pk=specialty.pk).exists())
def test_importer_does_not_interfere_with_user_owned_data(self):
self.run_import()
user = User.objects.create_user(username="lba_user", password="pass12345")
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
favorite = FavoritePlayer.objects.create(user=user, player=player)
note = PlayerNote.objects.create(user=user, player=player, body="LBA imported profile")
saved = SavedSearch.objects.create(user=user, name="LBA Search", params={"name": "Muhammad"})
self.run_import()
self.assertTrue(FavoritePlayer.objects.filter(pk=favorite.pk).exists())
self.assertTrue(PlayerNote.objects.filter(pk=note.pk).exists())
self.assertTrue(SavedSearch.objects.filter(pk=saved.pk).exists())
def test_imported_data_is_visible_in_search_and_detail_flows(self):
self.run_import()
list_response = self.client.get(reverse("scouting:player_list"), {"name": "Muhammad"})
self.assertEqual(list_response.status_code, 200)
self.assertContains(list_response, "Muhammad-Ali Abdur-Rahkman")
player = Player.objects.get(full_name="Muhammad-Ali Abdur-Rahkman")
detail_response = self.client.get(reverse("scouting:player_detail", args=[player.id]))
self.assertEqual(detail_response.status_code, 200)
self.assertContains(detail_response, "Muhammad-Ali Abdur-Rahkman")
self.assertContains(detail_response, "PTS 19.5")
def test_importer_fails_cleanly_for_malformed_fixture(self):
broken_fixture = Path(__file__).resolve().parent / "sample_data" / "imports" / "lba_public_broken_fixture.json"
broken_fixture.write_text("{\"categories\": {\"points\": {\"stats\": [{\"name\": \"OnlyName\"}]}}}", encoding="utf-8")
try:
with self.assertRaises(CommandError):
call_command(self.COMMAND_NAME, fixture=str(broken_fixture), season=2025)
finally:
if broken_fixture.exists():
broken_fixture.unlink()
class FavoritePlayerViewsTests(TestCase): class FavoritePlayerViewsTests(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):