Add v2 relational domain foundations with import run/file models
This commit is contained in:
@ -5,16 +5,16 @@ from .models import Competition, Season, TeamSeason
|
|||||||
|
|
||||||
@admin.register(Competition)
|
@admin.register(Competition)
|
||||||
class CompetitionAdmin(admin.ModelAdmin):
|
class CompetitionAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "competition_type", "gender", "country", "is_active")
|
list_display = ("name", "source_uid", "competition_type", "gender", "country", "is_active")
|
||||||
list_filter = ("competition_type", "gender", "country", "is_active")
|
list_filter = ("competition_type", "gender", "country", "is_active")
|
||||||
search_fields = ("name", "slug")
|
search_fields = ("name", "slug", "source_uid")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Season)
|
@admin.register(Season)
|
||||||
class SeasonAdmin(admin.ModelAdmin):
|
class SeasonAdmin(admin.ModelAdmin):
|
||||||
list_display = ("label", "start_date", "end_date", "is_current")
|
list_display = ("label", "source_uid", "start_date", "end_date", "is_current")
|
||||||
list_filter = ("is_current",)
|
list_filter = ("is_current",)
|
||||||
search_fields = ("label",)
|
search_fields = ("label", "source_uid")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TeamSeason)
|
@admin.register(TeamSeason)
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-13 12:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competitions', '0002_initial'),
|
||||||
|
('players', '0005_player_weight_index'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competition',
|
||||||
|
name='source_uid',
|
||||||
|
field=models.CharField(blank=True, max_length=120, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='season',
|
||||||
|
name='source_uid',
|
||||||
|
field=models.CharField(blank=True, max_length=120, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='competition',
|
||||||
|
index=models.Index(fields=['source_uid'], name='competition_source__1c043a_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='season',
|
||||||
|
index=models.Index(fields=['source_uid'], name='competition_source__41e6a6_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -14,6 +14,7 @@ class Competition(models.Model):
|
|||||||
|
|
||||||
name = models.CharField(max_length=220)
|
name = models.CharField(max_length=220)
|
||||||
slug = models.SlugField(max_length=240, unique=True)
|
slug = models.SlugField(max_length=240, unique=True)
|
||||||
|
source_uid = models.CharField(max_length=120, blank=True, null=True, unique=True)
|
||||||
competition_type = models.CharField(max_length=24, choices=CompetitionType.choices)
|
competition_type = models.CharField(max_length=24, choices=CompetitionType.choices)
|
||||||
gender = models.CharField(max_length=16, choices=Gender.choices, default=Gender.MEN)
|
gender = models.CharField(max_length=16, choices=Gender.choices, default=Gender.MEN)
|
||||||
level = models.PositiveSmallIntegerField(default=1)
|
level = models.PositiveSmallIntegerField(default=1)
|
||||||
@ -35,6 +36,7 @@ class Competition(models.Model):
|
|||||||
]
|
]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
|
models.Index(fields=["source_uid"]),
|
||||||
models.Index(fields=["country"]),
|
models.Index(fields=["country"]),
|
||||||
models.Index(fields=["competition_type"]),
|
models.Index(fields=["competition_type"]),
|
||||||
models.Index(fields=["gender"]),
|
models.Index(fields=["gender"]),
|
||||||
@ -46,6 +48,7 @@ class Competition(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Season(models.Model):
|
class Season(models.Model):
|
||||||
|
source_uid = models.CharField(max_length=120, blank=True, null=True, unique=True)
|
||||||
label = models.CharField(max_length=40, unique=True)
|
label = models.CharField(max_length=40, unique=True)
|
||||||
start_date = models.DateField()
|
start_date = models.DateField()
|
||||||
end_date = models.DateField()
|
end_date = models.DateField()
|
||||||
@ -57,6 +60,7 @@ class Season(models.Model):
|
|||||||
models.CheckConstraint(condition=models.Q(end_date__gte=models.F("start_date")), name="ck_season_dates")
|
models.CheckConstraint(condition=models.Q(end_date__gte=models.F("start_date")), name="ck_season_dates")
|
||||||
]
|
]
|
||||||
indexes = [
|
indexes = [
|
||||||
|
models.Index(fields=["source_uid"]),
|
||||||
models.Index(fields=["is_current"]),
|
models.Index(fields=["is_current"]),
|
||||||
models.Index(fields=["start_date"]),
|
models.Index(fields=["start_date"]),
|
||||||
models.Index(fields=["end_date"]),
|
models.Index(fields=["end_date"]),
|
||||||
|
|||||||
@ -1,117 +1,99 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib import messages
|
|
||||||
from django.db.models import Count
|
|
||||||
|
|
||||||
from apps.providers.registry import get_default_provider_namespace
|
from .models import ImportFile, ImportRun, IngestionError, IngestionRun
|
||||||
|
|
||||||
from .models import IngestionError, IngestionRun
|
|
||||||
from .tasks import trigger_full_sync, trigger_incremental_sync
|
|
||||||
|
|
||||||
|
|
||||||
class IngestionErrorInline(admin.TabularInline):
|
class ImportFileInline(admin.TabularInline):
|
||||||
model = IngestionError
|
model = ImportFile
|
||||||
extra = 0
|
extra = 0
|
||||||
readonly_fields = ("provider_namespace", "entity_type", "external_id", "severity", "message", "occurred_at")
|
readonly_fields = (
|
||||||
|
"relative_path",
|
||||||
|
|
||||||
@admin.register(IngestionRun)
|
|
||||||
class IngestionRunAdmin(admin.ModelAdmin):
|
|
||||||
list_display = (
|
|
||||||
"provider_namespace",
|
|
||||||
"job_type",
|
|
||||||
"status",
|
"status",
|
||||||
"records_processed",
|
"checksum",
|
||||||
"records_created",
|
"file_size_bytes",
|
||||||
"records_updated",
|
"rows_total",
|
||||||
"records_failed",
|
"rows_upserted",
|
||||||
"error_count",
|
"rows_failed",
|
||||||
"short_error_summary",
|
"error_message",
|
||||||
|
"processed_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ImportRun)
|
||||||
|
class ImportRunAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"source",
|
||||||
|
"status",
|
||||||
|
"files_total",
|
||||||
|
"files_processed",
|
||||||
|
"rows_total",
|
||||||
|
"rows_upserted",
|
||||||
|
"rows_failed",
|
||||||
"started_at",
|
"started_at",
|
||||||
"finished_at",
|
"finished_at",
|
||||||
|
"created_at",
|
||||||
)
|
)
|
||||||
list_filter = ("provider_namespace", "job_type", "status")
|
list_filter = ("source", "status")
|
||||||
search_fields = ("provider_namespace",)
|
search_fields = ("source", "error_summary")
|
||||||
inlines = (IngestionErrorInline,)
|
|
||||||
readonly_fields = (
|
readonly_fields = (
|
||||||
"provider_namespace",
|
"source",
|
||||||
"job_type",
|
|
||||||
"status",
|
"status",
|
||||||
"triggered_by",
|
"triggered_by",
|
||||||
"started_at",
|
"started_at",
|
||||||
"finished_at",
|
"finished_at",
|
||||||
"records_processed",
|
"files_total",
|
||||||
"records_created",
|
"files_processed",
|
||||||
"records_updated",
|
"rows_total",
|
||||||
"records_failed",
|
"rows_upserted",
|
||||||
|
"rows_failed",
|
||||||
"error_summary",
|
"error_summary",
|
||||||
"context",
|
"context",
|
||||||
"raw_payload",
|
|
||||||
"created_at",
|
"created_at",
|
||||||
)
|
)
|
||||||
actions = (
|
inlines = (ImportFileInline,)
|
||||||
"enqueue_full_sync_default_provider",
|
|
||||||
"enqueue_incremental_sync_default_provider",
|
|
||||||
"retry_selected_runs",
|
@admin.register(ImportFile)
|
||||||
|
class ImportFileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"import_run",
|
||||||
|
"relative_path",
|
||||||
|
"status",
|
||||||
|
"rows_total",
|
||||||
|
"rows_upserted",
|
||||||
|
"rows_failed",
|
||||||
|
"processed_at",
|
||||||
|
)
|
||||||
|
list_filter = ("status",)
|
||||||
|
search_fields = ("relative_path", "checksum", "error_message")
|
||||||
|
readonly_fields = (
|
||||||
|
"import_run",
|
||||||
|
"relative_path",
|
||||||
|
"status",
|
||||||
|
"checksum",
|
||||||
|
"file_size_bytes",
|
||||||
|
"rows_total",
|
||||||
|
"rows_upserted",
|
||||||
|
"rows_failed",
|
||||||
|
"error_message",
|
||||||
|
"payload_preview",
|
||||||
|
"processed_at",
|
||||||
|
"created_at",
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.action(description="Queue full sync (default provider)")
|
|
||||||
def enqueue_full_sync_default_provider(self, request, queryset):
|
|
||||||
provider_namespace = get_default_provider_namespace()
|
|
||||||
trigger_full_sync.delay(provider_namespace=provider_namespace, triggered_by_id=request.user.id)
|
|
||||||
self.message_user(request, f"Queued full sync task for {provider_namespace}.", level=messages.SUCCESS)
|
|
||||||
|
|
||||||
@admin.action(description="Queue incremental sync (default provider)")
|
@admin.register(IngestionRun)
|
||||||
def enqueue_incremental_sync_default_provider(self, request, queryset):
|
class LegacyIngestionRunAdmin(admin.ModelAdmin):
|
||||||
provider_namespace = get_default_provider_namespace()
|
list_display = ("provider_namespace", "job_type", "status", "started_at", "finished_at")
|
||||||
trigger_incremental_sync.delay(provider_namespace=provider_namespace, triggered_by_id=request.user.id)
|
list_filter = ("provider_namespace", "job_type", "status")
|
||||||
self.message_user(request, f"Queued incremental sync task for {provider_namespace}.", level=messages.SUCCESS)
|
search_fields = ("provider_namespace", "error_summary")
|
||||||
|
|
||||||
@admin.action(description="Retry selected ingestion runs")
|
|
||||||
def retry_selected_runs(self, request, queryset):
|
|
||||||
count = 0
|
|
||||||
for run in queryset:
|
|
||||||
if run.job_type == IngestionRun.JobType.INCREMENTAL:
|
|
||||||
trigger_incremental_sync.delay(
|
|
||||||
provider_namespace=run.provider_namespace,
|
|
||||||
triggered_by_id=request.user.id,
|
|
||||||
context={"retry_of": run.id},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
trigger_full_sync.delay(
|
|
||||||
provider_namespace=run.provider_namespace,
|
|
||||||
triggered_by_id=request.user.id,
|
|
||||||
context={"retry_of": run.id},
|
|
||||||
)
|
|
||||||
count += 1
|
|
||||||
self.message_user(request, f"Queued {count} retry task(s).", level=messages.SUCCESS)
|
|
||||||
|
|
||||||
def get_queryset(self, request):
|
|
||||||
queryset = super().get_queryset(request)
|
|
||||||
return queryset.annotate(_error_count=Count("errors"))
|
|
||||||
|
|
||||||
@admin.display(ordering="_error_count", description="Errors")
|
|
||||||
def error_count(self, obj):
|
|
||||||
return getattr(obj, "_error_count", 0)
|
|
||||||
|
|
||||||
@admin.display(description="Error summary")
|
|
||||||
def short_error_summary(self, obj):
|
|
||||||
if not obj.error_summary:
|
|
||||||
return "-"
|
|
||||||
return (obj.error_summary[:90] + "...") if len(obj.error_summary) > 90 else obj.error_summary
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(IngestionError)
|
@admin.register(IngestionError)
|
||||||
class IngestionErrorAdmin(admin.ModelAdmin):
|
class LegacyIngestionErrorAdmin(admin.ModelAdmin):
|
||||||
list_display = ("provider_namespace", "entity_type", "external_id", "severity", "occurred_at")
|
list_display = ("provider_namespace", "entity_type", "external_id", "severity", "occurred_at")
|
||||||
list_filter = ("severity", "provider_namespace")
|
list_filter = ("severity", "provider_namespace")
|
||||||
search_fields = ("entity_type", "external_id", "message")
|
search_fields = ("entity_type", "external_id", "message")
|
||||||
readonly_fields = (
|
|
||||||
"ingestion_run",
|
|
||||||
"provider_namespace",
|
|
||||||
"entity_type",
|
|
||||||
"external_id",
|
|
||||||
"severity",
|
|
||||||
"message",
|
|
||||||
"raw_payload",
|
|
||||||
"occurred_at",
|
|
||||||
)
|
|
||||||
|
|||||||
@ -0,0 +1,91 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-13 12:44
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ingestion', '0002_ingestionrun_error_summary'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ImportRun',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('source', models.CharField(default='snapshot', max_length=80)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('running', 'Running'), ('success', 'Success'), ('failed', 'Failed'), ('canceled', 'Canceled')], default='pending', max_length=24)),
|
||||||
|
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('files_total', models.PositiveIntegerField(default=0)),
|
||||||
|
('files_processed', models.PositiveIntegerField(default=0)),
|
||||||
|
('rows_total', models.PositiveIntegerField(default=0)),
|
||||||
|
('rows_upserted', models.PositiveIntegerField(default=0)),
|
||||||
|
('rows_failed', models.PositiveIntegerField(default=0)),
|
||||||
|
('error_summary', models.TextField(blank=True, default='')),
|
||||||
|
('context', models.JSONField(blank=True, default=dict)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('triggered_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_runs', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ImportFile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('relative_path', models.CharField(max_length=260)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('success', 'Success'), ('failed', 'Failed'), ('skipped', 'Skipped')], default='pending', max_length=24)),
|
||||||
|
('checksum', models.CharField(blank=True, max_length=128)),
|
||||||
|
('file_size_bytes', models.PositiveBigIntegerField(blank=True, null=True)),
|
||||||
|
('rows_total', models.PositiveIntegerField(default=0)),
|
||||||
|
('rows_upserted', models.PositiveIntegerField(default=0)),
|
||||||
|
('rows_failed', models.PositiveIntegerField(default=0)),
|
||||||
|
('error_message', models.TextField(blank=True)),
|
||||||
|
('payload_preview', models.JSONField(blank=True, default=dict)),
|
||||||
|
('processed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('import_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='ingestion.importrun')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='importrun',
|
||||||
|
index=models.Index(fields=['source', 'status'], name='ingestion_i_source_61db63_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='importrun',
|
||||||
|
index=models.Index(fields=['created_at'], name='ingestion_i_created_93c115_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='importrun',
|
||||||
|
index=models.Index(fields=['started_at'], name='ingestion_i_started_bf1d94_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='importrun',
|
||||||
|
index=models.Index(fields=['finished_at'], name='ingestion_i_finishe_73cbed_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='importfile',
|
||||||
|
index=models.Index(fields=['import_run', 'status'], name='ingestion_i_import__075f75_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='importfile',
|
||||||
|
index=models.Index(fields=['relative_path'], name='ingestion_i_relativ_183e34_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='importfile',
|
||||||
|
index=models.Index(fields=['processed_at'], name='ingestion_i_process_dfc080_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name='importfile',
|
||||||
|
constraint=models.UniqueConstraint(fields=('import_run', 'relative_path'), name='uq_import_file_per_run_path'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -2,6 +2,90 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRun(models.Model):
|
||||||
|
class RunStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
RUNNING = "running", "Running"
|
||||||
|
SUCCESS = "success", "Success"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
CANCELED = "canceled", "Canceled"
|
||||||
|
|
||||||
|
source = models.CharField(max_length=80, default="snapshot")
|
||||||
|
status = models.CharField(max_length=24, choices=RunStatus.choices, default=RunStatus.PENDING)
|
||||||
|
triggered_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
related_name="import_runs",
|
||||||
|
)
|
||||||
|
started_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
finished_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
files_total = models.PositiveIntegerField(default=0)
|
||||||
|
files_processed = models.PositiveIntegerField(default=0)
|
||||||
|
rows_total = models.PositiveIntegerField(default=0)
|
||||||
|
rows_upserted = models.PositiveIntegerField(default=0)
|
||||||
|
rows_failed = models.PositiveIntegerField(default=0)
|
||||||
|
error_summary = models.TextField(blank=True, default="")
|
||||||
|
context = models.JSONField(default=dict, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["source", "status"]),
|
||||||
|
models.Index(fields=["created_at"]),
|
||||||
|
models.Index(fields=["started_at"]),
|
||||||
|
models.Index(fields=["finished_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.source} | {self.status} | {self.created_at:%Y-%m-%d %H:%M}"
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFile(models.Model):
|
||||||
|
class FileStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
PROCESSING = "processing", "Processing"
|
||||||
|
SUCCESS = "success", "Success"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
SKIPPED = "skipped", "Skipped"
|
||||||
|
|
||||||
|
import_run = models.ForeignKey(
|
||||||
|
"ingestion.ImportRun",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="files",
|
||||||
|
)
|
||||||
|
relative_path = models.CharField(max_length=260)
|
||||||
|
status = models.CharField(max_length=24, choices=FileStatus.choices, default=FileStatus.PENDING)
|
||||||
|
checksum = models.CharField(max_length=128, blank=True)
|
||||||
|
file_size_bytes = models.PositiveBigIntegerField(blank=True, null=True)
|
||||||
|
rows_total = models.PositiveIntegerField(default=0)
|
||||||
|
rows_upserted = models.PositiveIntegerField(default=0)
|
||||||
|
rows_failed = models.PositiveIntegerField(default=0)
|
||||||
|
error_message = models.TextField(blank=True)
|
||||||
|
payload_preview = models.JSONField(default=dict, blank=True)
|
||||||
|
processed_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["import_run", "relative_path"],
|
||||||
|
name="uq_import_file_per_run_path",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["import_run", "status"]),
|
||||||
|
models.Index(fields=["relative_path"]),
|
||||||
|
models.Index(fields=["processed_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.relative_path} [{self.status}]"
|
||||||
|
|
||||||
|
|
||||||
class IngestionRun(models.Model):
|
class IngestionRun(models.Model):
|
||||||
class RunStatus(models.TextChoices):
|
class RunStatus(models.TextChoices):
|
||||||
PENDING = "pending", "Pending"
|
PENDING = "pending", "Pending"
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class PlayerCareerEntryInline(admin.TabularInline):
|
|||||||
class PlayerAdmin(admin.ModelAdmin):
|
class PlayerAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
"full_name",
|
"full_name",
|
||||||
|
"source_uid",
|
||||||
"birth_date",
|
"birth_date",
|
||||||
"nationality",
|
"nationality",
|
||||||
"nominal_position",
|
"nominal_position",
|
||||||
@ -53,7 +54,7 @@ class PlayerAdmin(admin.ModelAdmin):
|
|||||||
"origin_competition",
|
"origin_competition",
|
||||||
"origin_team",
|
"origin_team",
|
||||||
)
|
)
|
||||||
search_fields = ("full_name", "first_name", "last_name")
|
search_fields = ("full_name", "first_name", "last_name", "source_uid")
|
||||||
inlines = (PlayerAliasInline, PlayerCareerEntryInline)
|
inlines = (PlayerAliasInline, PlayerCareerEntryInline)
|
||||||
actions = ("recompute_origin_fields",)
|
actions = ("recompute_origin_fields",)
|
||||||
|
|
||||||
|
|||||||
24
apps/players/migrations/0006_player_source_uid_and_more.py
Normal file
24
apps/players/migrations/0006_player_source_uid_and_more.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-13 12:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competitions', '0003_competition_source_uid_season_source_uid_and_more'),
|
||||||
|
('players', '0005_player_weight_index'),
|
||||||
|
('teams', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='player',
|
||||||
|
name='source_uid',
|
||||||
|
field=models.CharField(blank=True, max_length=120, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='player',
|
||||||
|
index=models.Index(fields=['source_uid'], name='players_pla_source__93bb47_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -58,6 +58,7 @@ class Player(TimeStampedModel):
|
|||||||
first_name = models.CharField(max_length=120)
|
first_name = models.CharField(max_length=120)
|
||||||
last_name = models.CharField(max_length=120)
|
last_name = models.CharField(max_length=120)
|
||||||
full_name = models.CharField(max_length=260)
|
full_name = models.CharField(max_length=260)
|
||||||
|
source_uid = models.CharField(max_length=120, blank=True, null=True, unique=True)
|
||||||
birth_date = models.DateField(blank=True, null=True)
|
birth_date = models.DateField(blank=True, null=True)
|
||||||
nationality = models.ForeignKey(
|
nationality = models.ForeignKey(
|
||||||
"players.Nationality",
|
"players.Nationality",
|
||||||
@ -114,6 +115,7 @@ class Player(TimeStampedModel):
|
|||||||
]
|
]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["full_name"]),
|
models.Index(fields=["full_name"]),
|
||||||
|
models.Index(fields=["source_uid"]),
|
||||||
models.Index(fields=["last_name", "first_name"]),
|
models.Index(fields=["last_name", "first_name"]),
|
||||||
models.Index(fields=["birth_date"]),
|
models.Index(fields=["birth_date"]),
|
||||||
models.Index(fields=["nationality"]),
|
models.Index(fields=["nationality"]),
|
||||||
|
|||||||
@ -5,9 +5,9 @@ from .models import PlayerSeason, PlayerSeasonStats
|
|||||||
|
|
||||||
@admin.register(PlayerSeason)
|
@admin.register(PlayerSeason)
|
||||||
class PlayerSeasonAdmin(admin.ModelAdmin):
|
class PlayerSeasonAdmin(admin.ModelAdmin):
|
||||||
list_display = ("player", "season", "team", "competition", "games_played", "minutes_played")
|
list_display = ("player", "season", "source_uid", "team", "competition", "games_played", "minutes_played")
|
||||||
list_filter = ("season", "competition")
|
list_filter = ("season", "competition")
|
||||||
search_fields = ("player__full_name", "team__name", "competition__name", "season__label")
|
search_fields = ("player__full_name", "team__name", "competition__name", "season__label", "source_uid")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PlayerSeasonStats)
|
@admin.register(PlayerSeasonStats)
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-13 12:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competitions', '0003_competition_source_uid_season_source_uid_and_more'),
|
||||||
|
('players', '0006_player_source_uid_and_more'),
|
||||||
|
('stats', '0002_playerseasonstats_search_indexes'),
|
||||||
|
('teams', '0002_team_source_uid_team_teams_team_source__940258_idx'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='playerseason',
|
||||||
|
name='source_uid',
|
||||||
|
field=models.CharField(blank=True, max_length=160, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='playerseason',
|
||||||
|
index=models.Index(fields=['source_uid'], name='stats_playe_source__57b701_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -4,6 +4,7 @@ from django.db import models
|
|||||||
class PlayerSeason(models.Model):
|
class PlayerSeason(models.Model):
|
||||||
player = models.ForeignKey("players.Player", on_delete=models.CASCADE, related_name="player_seasons")
|
player = models.ForeignKey("players.Player", on_delete=models.CASCADE, related_name="player_seasons")
|
||||||
season = models.ForeignKey("competitions.Season", on_delete=models.CASCADE, related_name="player_seasons")
|
season = models.ForeignKey("competitions.Season", on_delete=models.CASCADE, related_name="player_seasons")
|
||||||
|
source_uid = models.CharField(max_length=160, blank=True, null=True, unique=True)
|
||||||
team = models.ForeignKey(
|
team = models.ForeignKey(
|
||||||
"teams.Team",
|
"teams.Team",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -31,6 +32,7 @@ class PlayerSeason(models.Model):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
indexes = [
|
indexes = [
|
||||||
|
models.Index(fields=["source_uid"]),
|
||||||
models.Index(fields=["player", "season"]),
|
models.Index(fields=["player", "season"]),
|
||||||
models.Index(fields=["season", "team"]),
|
models.Index(fields=["season", "team"]),
|
||||||
models.Index(fields=["season", "competition"]),
|
models.Index(fields=["season", "competition"]),
|
||||||
|
|||||||
@ -5,6 +5,6 @@ from .models import Team
|
|||||||
|
|
||||||
@admin.register(Team)
|
@admin.register(Team)
|
||||||
class TeamAdmin(admin.ModelAdmin):
|
class TeamAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "short_name", "country", "is_national_team")
|
list_display = ("name", "source_uid", "short_name", "country", "is_national_team")
|
||||||
list_filter = ("is_national_team", "country")
|
list_filter = ("is_national_team", "country")
|
||||||
search_fields = ("name", "short_name", "slug")
|
search_fields = ("name", "short_name", "slug", "source_uid")
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-03-13 12:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('players', '0006_player_source_uid_and_more'),
|
||||||
|
('teams', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='source_uid',
|
||||||
|
field=models.CharField(blank=True, max_length=120, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='team',
|
||||||
|
index=models.Index(fields=['source_uid'], name='teams_team_source__940258_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -5,6 +5,7 @@ class Team(models.Model):
|
|||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
short_name = models.CharField(max_length=80, blank=True)
|
short_name = models.CharField(max_length=80, blank=True)
|
||||||
slug = models.SlugField(max_length=220, unique=True)
|
slug = models.SlugField(max_length=220, unique=True)
|
||||||
|
source_uid = models.CharField(max_length=120, blank=True, null=True, unique=True)
|
||||||
country = models.ForeignKey(
|
country = models.ForeignKey(
|
||||||
"players.Nationality",
|
"players.Nationality",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -25,6 +26,7 @@ class Team(models.Model):
|
|||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["name"]),
|
models.Index(fields=["name"]),
|
||||||
models.Index(fields=["slug"]),
|
models.Index(fields=["slug"]),
|
||||||
|
models.Index(fields=["source_uid"]),
|
||||||
models.Index(fields=["country"]),
|
models.Index(fields=["country"]),
|
||||||
models.Index(fields=["is_national_team"]),
|
models.Index(fields=["is_national_team"]),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import pytest
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
|
|
||||||
from apps.competitions.models import Competition
|
from apps.competitions.models import Competition, Season
|
||||||
|
from apps.ingestion.models import ImportFile, ImportRun
|
||||||
from apps.players.models import Nationality, Player, Position, Role
|
from apps.players.models import Nationality, Player, Position, Role
|
||||||
from apps.providers.models import ExternalMapping
|
|
||||||
from apps.scouting.models import FavoritePlayer, SavedSearch
|
from apps.scouting.models import FavoritePlayer, SavedSearch
|
||||||
|
from apps.stats.models import PlayerSeason
|
||||||
|
from apps.teams.models import Team
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@ -24,6 +26,7 @@ def test_player_unique_full_name_birth_date_constraint():
|
|||||||
nationality=nationality,
|
nationality=nationality,
|
||||||
nominal_position=position,
|
nominal_position=position,
|
||||||
inferred_role=role,
|
inferred_role=role,
|
||||||
|
source_uid="player-src-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
@ -38,6 +41,48 @@ def test_player_unique_full_name_birth_date_constraint():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_source_uid_uniqueness_on_core_entities():
|
||||||
|
season = Season.objects.create(
|
||||||
|
source_uid="season-2024",
|
||||||
|
label="2024-2025",
|
||||||
|
start_date=date(2024, 10, 1),
|
||||||
|
end_date=date(2025, 6, 30),
|
||||||
|
)
|
||||||
|
competition = Competition.objects.create(
|
||||||
|
source_uid="comp-001",
|
||||||
|
name="Serie A",
|
||||||
|
slug="serie-a",
|
||||||
|
competition_type=Competition.CompetitionType.LEAGUE,
|
||||||
|
)
|
||||||
|
team = Team.objects.create(source_uid="team-001", name="Virtus Bologna", slug="virtus-bologna")
|
||||||
|
|
||||||
|
nationality = Nationality.objects.create(name="Spain", iso2_code="ES", iso3_code="ESP")
|
||||||
|
position = Position.objects.create(code="SF", name="Small Forward")
|
||||||
|
role = Role.objects.create(code="wing", name="Wing")
|
||||||
|
player = Player.objects.create(
|
||||||
|
source_uid="player-001",
|
||||||
|
first_name="Juan",
|
||||||
|
last_name="Perez",
|
||||||
|
full_name="Juan Perez",
|
||||||
|
birth_date=date(2000, 5, 1),
|
||||||
|
nationality=nationality,
|
||||||
|
nominal_position=position,
|
||||||
|
inferred_role=role,
|
||||||
|
)
|
||||||
|
|
||||||
|
PlayerSeason.objects.create(
|
||||||
|
source_uid="ps-001",
|
||||||
|
player=player,
|
||||||
|
season=season,
|
||||||
|
team=team,
|
||||||
|
competition=competition,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
Team.objects.create(source_uid="team-001", name="Another Team", slug="another-team")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_saved_search_unique_name_per_user_constraint():
|
def test_saved_search_unique_name_per_user_constraint():
|
||||||
user = User.objects.create_user(username="u1", password="pass12345")
|
user = User.objects.create_user(username="u1", password="pass12345")
|
||||||
@ -50,14 +95,14 @@ def test_saved_search_unique_name_per_user_constraint():
|
|||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_favorite_unique_player_per_user_constraint():
|
def test_favorite_unique_player_per_user_constraint():
|
||||||
user = User.objects.create_user(username="u2", password="pass12345")
|
user = User.objects.create_user(username="u2", password="pass12345")
|
||||||
nationality = Nationality.objects.create(name="Spain", iso2_code="ES", iso3_code="ESP")
|
nationality = Nationality.objects.create(name="France", iso2_code="FR", iso3_code="FRA")
|
||||||
position = Position.objects.create(code="SF", name="Small Forward")
|
position = Position.objects.create(code="PF", name="Power Forward")
|
||||||
role = Role.objects.create(code="wing", name="Wing")
|
role = Role.objects.create(code="big", name="Big")
|
||||||
player = Player.objects.create(
|
player = Player.objects.create(
|
||||||
first_name="Juan",
|
first_name="Pierre",
|
||||||
last_name="Perez",
|
last_name="Durand",
|
||||||
full_name="Juan Perez",
|
full_name="Pierre Durand",
|
||||||
birth_date=date(2000, 5, 1),
|
birth_date=date(2001, 3, 3),
|
||||||
nationality=nationality,
|
nationality=nationality,
|
||||||
nominal_position=position,
|
nominal_position=position,
|
||||||
inferred_role=role,
|
inferred_role=role,
|
||||||
@ -69,24 +114,9 @@ def test_favorite_unique_player_per_user_constraint():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_external_mapping_unique_provider_external_id_constraint():
|
def test_import_file_unique_path_within_import_run():
|
||||||
competition = Competition.objects.create(
|
run = ImportRun.objects.create(source="daily_snapshot")
|
||||||
name="Liga ACB",
|
ImportFile.objects.create(import_run=run, relative_path="players/2026-03-13.json")
|
||||||
slug="liga-acb",
|
|
||||||
competition_type=Competition.CompetitionType.LEAGUE,
|
|
||||||
gender=Competition.Gender.MEN,
|
|
||||||
level=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
ExternalMapping.objects.create(
|
|
||||||
provider_namespace="mvp_demo",
|
|
||||||
external_id="comp-001",
|
|
||||||
content_object=competition,
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
with pytest.raises(IntegrityError):
|
||||||
ExternalMapping.objects.create(
|
ImportFile.objects.create(import_run=run, relative_path="players/2026-03-13.json")
|
||||||
provider_namespace="mvp_demo",
|
|
||||||
external_id="comp-001",
|
|
||||||
content_object=competition,
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user