diff --git a/Dockerfile b/Dockerfile index fc473e5..c8dc807 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,38 @@ -FROM python:3.12-slim +FROM python:3.12-slim AS builder ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 + PIP_NO_CACHE_DIR=1 \ + VIRTUAL_ENV=/opt/venv WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends build-essential libpq-dev postgresql-client curl \ + && apt-get install -y --no-install-recommends build-essential libpq-dev \ && rm -rf /var/lib/apt/lists/* +RUN python -m venv "${VIRTUAL_ENV}" +ENV PATH="${VIRTUAL_ENV}/bin:${PATH}" + COPY requirements/base.txt /tmp/requirements/base.txt RUN pip install --upgrade pip && pip install -r /tmp/requirements/base.txt + +FROM python:3.12-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:${PATH}" + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpq5 postgresql-client curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /opt/venv /opt/venv COPY . /app RUN chmod +x /app/entrypoint.sh diff --git a/apps/competitions/admin.py b/apps/competitions/admin.py new file mode 100644 index 0000000..dd3ac45 --- /dev/null +++ b/apps/competitions/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from .models import Competition, Season, TeamSeason + + +@admin.register(Competition) +class CompetitionAdmin(admin.ModelAdmin): + list_display = ("name", "competition_type", "gender", "country", "is_active") + list_filter = ("competition_type", "gender", "country", "is_active") + search_fields = ("name", "slug") + + +@admin.register(Season) +class SeasonAdmin(admin.ModelAdmin): + list_display = ("label", "start_date", "end_date", "is_current") + list_filter = ("is_current",) + search_fields = ("label",) + + +@admin.register(TeamSeason) +class TeamSeasonAdmin(admin.ModelAdmin): + list_display = ("team", "season", "competition", "standing", "wins", "losses") + list_filter = ("season", "competition") + search_fields = ("team__name", "competition__name", "season__label") diff --git a/apps/competitions/migrations/0001_initial.py b/apps/competitions/migrations/0001_initial.py new file mode 100644 index 0000000..1bacbab --- /dev/null +++ b/apps/competitions/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Competition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=220)), + ('slug', models.SlugField(max_length=240, unique=True)), + ('competition_type', models.CharField(choices=[('league', 'League'), ('cup', 'Cup'), ('international', 'International')], max_length=24)), + ('gender', models.CharField(choices=[('men', 'Men'), ('women', 'Women'), ('mixed', 'Mixed')], default='men', max_length=16)), + ('level', models.PositiveSmallIntegerField(default=1)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Season', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('label', models.CharField(max_length=40, unique=True)), + ('start_date', models.DateField()), + ('end_date', models.DateField()), + ('is_current', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['-start_date'], + }, + ), + migrations.CreateModel( + name='TeamSeason', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('standing', models.PositiveSmallIntegerField(blank=True, null=True)), + ('wins', models.PositiveSmallIntegerField(blank=True, null=True)), + ('losses', models.PositiveSmallIntegerField(blank=True, null=True)), + ('points', models.PositiveSmallIntegerField(blank=True, null=True)), + ('coach_name', models.CharField(blank=True, max_length=140)), + ], + options={ + 'ordering': ['competition', 'season', 'team'], + }, + ), + ] diff --git a/apps/competitions/migrations/0002_initial.py b/apps/competitions/migrations/0002_initial.py new file mode 100644 index 0000000..d67eca0 --- /dev/null +++ b/apps/competitions/migrations/0002_initial.py @@ -0,0 +1,94 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('competitions', '0001_initial'), + ('players', '0001_initial'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='country', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='competitions', to='players.nationality'), + ), + migrations.AddIndex( + model_name='season', + index=models.Index(fields=['is_current'], name='competition_is_curr_787938_idx'), + ), + migrations.AddIndex( + model_name='season', + index=models.Index(fields=['start_date'], name='competition_start_d_08fb82_idx'), + ), + migrations.AddIndex( + model_name='season', + index=models.Index(fields=['end_date'], name='competition_end_dat_4ed2e7_idx'), + ), + migrations.AddConstraint( + model_name='season', + constraint=models.CheckConstraint(condition=models.Q(('end_date__gte', models.F('start_date'))), name='ck_season_dates'), + ), + migrations.AddField( + model_name='teamseason', + name='competition', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_seasons', to='competitions.competition'), + ), + migrations.AddField( + model_name='teamseason', + name='season', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_seasons', to='competitions.season'), + ), + migrations.AddField( + model_name='teamseason', + name='team', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_seasons', to='teams.team'), + ), + migrations.AddIndex( + model_name='competition', + index=models.Index(fields=['name'], name='competition_name_57eea2_idx'), + ), + migrations.AddIndex( + model_name='competition', + index=models.Index(fields=['country'], name='competition_country_93a128_idx'), + ), + migrations.AddIndex( + model_name='competition', + index=models.Index(fields=['competition_type'], name='competition_competi_7f1bff_idx'), + ), + migrations.AddIndex( + model_name='competition', + index=models.Index(fields=['gender'], name='competition_gender_e21a65_idx'), + ), + migrations.AddIndex( + model_name='competition', + index=models.Index(fields=['is_active'], name='competition_is_acti_53f00f_idx'), + ), + migrations.AddConstraint( + model_name='competition', + constraint=models.UniqueConstraint(fields=('name', 'country'), name='uq_competition_name_country'), + ), + migrations.AddIndex( + model_name='teamseason', + index=models.Index(fields=['team', 'season'], name='competition_team_id_c656bd_idx'), + ), + migrations.AddIndex( + model_name='teamseason', + index=models.Index(fields=['season', 'competition'], name='competition_season__9f17b6_idx'), + ), + migrations.AddIndex( + model_name='teamseason', + index=models.Index(fields=['competition', 'standing'], name='competition_competi_e1c8c7_idx'), + ), + migrations.AddConstraint( + model_name='teamseason', + constraint=models.UniqueConstraint(fields=('team', 'season', 'competition'), name='uq_team_season_competition'), + ), + ] diff --git a/apps/competitions/models.py b/apps/competitions/models.py new file mode 100644 index 0000000..cf80453 --- /dev/null +++ b/apps/competitions/models.py @@ -0,0 +1,98 @@ +from django.db import models + + +class Competition(models.Model): + class CompetitionType(models.TextChoices): + LEAGUE = "league", "League" + CUP = "cup", "Cup" + INTERNATIONAL = "international", "International" + + class Gender(models.TextChoices): + MEN = "men", "Men" + WOMEN = "women", "Women" + MIXED = "mixed", "Mixed" + + name = models.CharField(max_length=220) + slug = models.SlugField(max_length=240, unique=True) + competition_type = models.CharField(max_length=24, choices=CompetitionType.choices) + gender = models.CharField(max_length=16, choices=Gender.choices, default=Gender.MEN) + level = models.PositiveSmallIntegerField(default=1) + country = models.ForeignKey( + "players.Nationality", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="competitions", + ) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + constraints = [ + models.UniqueConstraint(fields=["name", "country"], name="uq_competition_name_country") + ] + indexes = [ + models.Index(fields=["name"]), + models.Index(fields=["country"]), + models.Index(fields=["competition_type"]), + models.Index(fields=["gender"]), + models.Index(fields=["is_active"]), + ] + + def __str__(self) -> str: + return self.name + + +class Season(models.Model): + label = models.CharField(max_length=40, unique=True) + start_date = models.DateField() + end_date = models.DateField() + is_current = models.BooleanField(default=False) + + class Meta: + ordering = ["-start_date"] + constraints = [ + models.CheckConstraint(check=models.Q(end_date__gte=models.F("start_date")), name="ck_season_dates") + ] + indexes = [ + models.Index(fields=["is_current"]), + models.Index(fields=["start_date"]), + models.Index(fields=["end_date"]), + ] + + def __str__(self) -> str: + return self.label + + +class TeamSeason(models.Model): + team = models.ForeignKey("teams.Team", on_delete=models.CASCADE, related_name="team_seasons") + season = models.ForeignKey("competitions.Season", on_delete=models.CASCADE, related_name="team_seasons") + competition = models.ForeignKey( + "competitions.Competition", + on_delete=models.CASCADE, + related_name="team_seasons", + ) + standing = models.PositiveSmallIntegerField(blank=True, null=True) + wins = models.PositiveSmallIntegerField(blank=True, null=True) + losses = models.PositiveSmallIntegerField(blank=True, null=True) + points = models.PositiveSmallIntegerField(blank=True, null=True) + coach_name = models.CharField(max_length=140, blank=True) + + class Meta: + ordering = ["competition", "season", "team"] + constraints = [ + models.UniqueConstraint( + fields=["team", "season", "competition"], + name="uq_team_season_competition", + ) + ] + indexes = [ + models.Index(fields=["team", "season"]), + models.Index(fields=["season", "competition"]), + models.Index(fields=["competition", "standing"]), + ] + + def __str__(self) -> str: + return f"{self.team} - {self.season} - {self.competition}" diff --git a/apps/ingestion/admin.py b/apps/ingestion/admin.py new file mode 100644 index 0000000..9edd789 --- /dev/null +++ b/apps/ingestion/admin.py @@ -0,0 +1,32 @@ +from django.contrib import admin + +from .models import IngestionError, IngestionRun + + +class IngestionErrorInline(admin.TabularInline): + model = IngestionError + extra = 0 + readonly_fields = ("occurred_at",) + + +@admin.register(IngestionRun) +class IngestionRunAdmin(admin.ModelAdmin): + list_display = ( + "provider_namespace", + "job_type", + "status", + "records_processed", + "records_failed", + "started_at", + "finished_at", + ) + list_filter = ("provider_namespace", "job_type", "status") + search_fields = ("provider_namespace",) + inlines = (IngestionErrorInline,) + + +@admin.register(IngestionError) +class IngestionErrorAdmin(admin.ModelAdmin): + list_display = ("provider_namespace", "entity_type", "external_id", "severity", "occurred_at") + list_filter = ("severity", "provider_namespace") + search_fields = ("entity_type", "external_id", "message") diff --git a/apps/ingestion/migrations/0001_initial.py b/apps/ingestion/migrations/0001_initial.py new file mode 100644 index 0000000..94e2989 --- /dev/null +++ b/apps/ingestion/migrations/0001_initial.py @@ -0,0 +1,84 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='IngestionRun', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('provider_namespace', models.CharField(max_length=80)), + ('job_type', models.CharField(choices=[('full_sync', 'Full Sync'), ('incremental', 'Incremental'), ('manual', 'Manual')], max_length=32)), + ('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)), + ('records_processed', models.PositiveIntegerField(default=0)), + ('records_created', models.PositiveIntegerField(default=0)), + ('records_updated', models.PositiveIntegerField(default=0)), + ('records_failed', models.PositiveIntegerField(default=0)), + ('context', models.JSONField(blank=True, default=dict)), + ('raw_payload', 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='ingestion_runs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='IngestionError', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('provider_namespace', models.CharField(max_length=80)), + ('entity_type', models.CharField(blank=True, max_length=80)), + ('external_id', models.CharField(blank=True, max_length=160)), + ('severity', models.CharField(choices=[('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], default='error', max_length=16)), + ('message', models.TextField()), + ('raw_payload', models.JSONField(blank=True, default=dict)), + ('occurred_at', models.DateTimeField(auto_now_add=True)), + ('ingestion_run', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='errors', to='ingestion.ingestionrun')), + ], + options={ + 'ordering': ['-occurred_at'], + }, + ), + migrations.AddIndex( + model_name='ingestionrun', + index=models.Index(fields=['provider_namespace', 'status'], name='ingestion_i_provide_1145f9_idx'), + ), + migrations.AddIndex( + model_name='ingestionrun', + index=models.Index(fields=['provider_namespace', 'job_type'], name='ingestion_i_provide_2f19cd_idx'), + ), + migrations.AddIndex( + model_name='ingestionrun', + index=models.Index(fields=['started_at'], name='ingestion_i_started_525875_idx'), + ), + migrations.AddIndex( + model_name='ingestionrun', + index=models.Index(fields=['finished_at'], name='ingestion_i_finishe_128c60_idx'), + ), + migrations.AddIndex( + model_name='ingestionerror', + index=models.Index(fields=['ingestion_run', 'occurred_at'], name='ingestion_i_ingesti_c957d2_idx'), + ), + migrations.AddIndex( + model_name='ingestionerror', + index=models.Index(fields=['provider_namespace', 'entity_type', 'external_id'], name='ingestion_i_provide_d3f744_idx'), + ), + migrations.AddIndex( + model_name='ingestionerror', + index=models.Index(fields=['severity'], name='ingestion_i_severit_9e9331_idx'), + ), + ] diff --git a/apps/ingestion/models.py b/apps/ingestion/models.py new file mode 100644 index 0000000..ba1f16b --- /dev/null +++ b/apps/ingestion/models.py @@ -0,0 +1,79 @@ +from django.conf import settings +from django.db import models + + +class IngestionRun(models.Model): + class RunStatus(models.TextChoices): + PENDING = "pending", "Pending" + RUNNING = "running", "Running" + SUCCESS = "success", "Success" + FAILED = "failed", "Failed" + CANCELED = "canceled", "Canceled" + + class JobType(models.TextChoices): + FULL_SYNC = "full_sync", "Full Sync" + INCREMENTAL = "incremental", "Incremental" + MANUAL = "manual", "Manual" + + provider_namespace = models.CharField(max_length=80) + job_type = models.CharField(max_length=32, choices=JobType.choices) + 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="ingestion_runs", + ) + started_at = models.DateTimeField(blank=True, null=True) + finished_at = models.DateTimeField(blank=True, null=True) + records_processed = models.PositiveIntegerField(default=0) + records_created = models.PositiveIntegerField(default=0) + records_updated = models.PositiveIntegerField(default=0) + records_failed = models.PositiveIntegerField(default=0) + context = models.JSONField(default=dict, blank=True) + raw_payload = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["provider_namespace", "status"]), + models.Index(fields=["provider_namespace", "job_type"]), + models.Index(fields=["started_at"]), + models.Index(fields=["finished_at"]), + ] + + def __str__(self) -> str: + return f"{self.provider_namespace} | {self.job_type} | {self.status}" + + +class IngestionError(models.Model): + class Severity(models.TextChoices): + WARNING = "warning", "Warning" + ERROR = "error", "Error" + CRITICAL = "critical", "Critical" + + ingestion_run = models.ForeignKey( + "ingestion.IngestionRun", + on_delete=models.CASCADE, + related_name="errors", + ) + provider_namespace = models.CharField(max_length=80) + entity_type = models.CharField(max_length=80, blank=True) + external_id = models.CharField(max_length=160, blank=True) + severity = models.CharField(max_length=16, choices=Severity.choices, default=Severity.ERROR) + message = models.TextField() + raw_payload = models.JSONField(default=dict, blank=True) + occurred_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-occurred_at"] + indexes = [ + models.Index(fields=["ingestion_run", "occurred_at"]), + models.Index(fields=["provider_namespace", "entity_type", "external_id"]), + models.Index(fields=["severity"]), + ] + + def __str__(self) -> str: + return f"{self.provider_namespace}:{self.entity_type} [{self.severity}]" diff --git a/apps/ingestion/services/__init__.py b/apps/ingestion/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ingestion/services/runs.py b/apps/ingestion/services/runs.py new file mode 100644 index 0000000..028d41e --- /dev/null +++ b/apps/ingestion/services/runs.py @@ -0,0 +1,46 @@ +from django.utils import timezone + +from apps.ingestion.models import IngestionError, IngestionRun + + +def start_ingestion_run(*, provider_namespace: str, job_type: str, triggered_by=None, context: dict | None = None) -> IngestionRun: + return IngestionRun.objects.create( + provider_namespace=provider_namespace, + job_type=job_type, + status=IngestionRun.RunStatus.RUNNING, + triggered_by=triggered_by, + started_at=timezone.now(), + context=context or {}, + ) + + +def finish_ingestion_run(*, run: IngestionRun, status: str, processed: int = 0, created: int = 0, updated: int = 0, failed: int = 0) -> IngestionRun: + run.status = status + run.records_processed = processed + run.records_created = created + run.records_updated = updated + run.records_failed = failed + run.finished_at = timezone.now() + run.save( + update_fields=[ + "status", + "records_processed", + "records_created", + "records_updated", + "records_failed", + "finished_at", + ] + ) + return run + + +def log_ingestion_error(*, run: IngestionRun, message: str, provider_namespace: str, severity: str = IngestionError.Severity.ERROR, entity_type: str = "", external_id: str = "", raw_payload: dict | None = None) -> IngestionError: + return IngestionError.objects.create( + ingestion_run=run, + provider_namespace=provider_namespace, + message=message, + severity=severity, + entity_type=entity_type, + external_id=external_id, + raw_payload=raw_payload or {}, + ) diff --git a/apps/players/admin.py b/apps/players/admin.py new file mode 100644 index 0000000..421a38d --- /dev/null +++ b/apps/players/admin.py @@ -0,0 +1,60 @@ +from django.contrib import admin + +from .models import Nationality, Player, PlayerAlias, PlayerCareerEntry, Position, Role + + +@admin.register(Nationality) +class NationalityAdmin(admin.ModelAdmin): + list_display = ("name", "iso2_code", "iso3_code") + search_fields = ("name", "iso2_code", "iso3_code") + + +@admin.register(Position) +class PositionAdmin(admin.ModelAdmin): + list_display = ("code", "name") + search_fields = ("code", "name") + + +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + list_display = ("code", "name") + search_fields = ("code", "name") + + +class PlayerAliasInline(admin.TabularInline): + model = PlayerAlias + extra = 0 + + +class PlayerCareerEntryInline(admin.TabularInline): + model = PlayerCareerEntry + extra = 0 + + +@admin.register(Player) +class PlayerAdmin(admin.ModelAdmin): + list_display = ( + "full_name", + "birth_date", + "nationality", + "nominal_position", + "inferred_role", + "is_active", + ) + list_filter = ("is_active", "nationality", "nominal_position", "inferred_role") + search_fields = ("full_name", "first_name", "last_name") + inlines = (PlayerAliasInline, PlayerCareerEntryInline) + + +@admin.register(PlayerAlias) +class PlayerAliasAdmin(admin.ModelAdmin): + list_display = ("alias", "player", "source", "is_primary") + list_filter = ("is_primary", "source") + search_fields = ("alias", "player__full_name") + + +@admin.register(PlayerCareerEntry) +class PlayerCareerEntryAdmin(admin.ModelAdmin): + list_display = ("player", "team", "competition", "season", "start_date", "end_date") + list_filter = ("competition", "season", "role_snapshot") + search_fields = ("player__full_name", "team__name", "competition__name") diff --git a/apps/players/migrations/0001_initial.py b/apps/players/migrations/0001_initial.py new file mode 100644 index 0000000..ff6d01c --- /dev/null +++ b/apps/players/migrations/0001_initial.py @@ -0,0 +1,113 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('competitions', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Position', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('code', models.CharField(max_length=10, unique=True)), + ('name', models.CharField(max_length=80, unique=True)), + ], + options={ + 'ordering': ['code'], + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('code', models.CharField(max_length=32, unique=True)), + ('name', models.CharField(max_length=120, unique=True)), + ('description', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Nationality', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=120, unique=True)), + ('iso2_code', models.CharField(max_length=2, unique=True)), + ('iso3_code', models.CharField(blank=True, max_length=3, null=True, unique=True)), + ], + options={ + 'verbose_name_plural': 'Nationalities', + 'ordering': ['name'], + 'indexes': [models.Index(fields=['name'], name='players_nat_name_8688fe_idx'), models.Index(fields=['iso2_code'], name='players_nat_iso2_co_57069a_idx')], + }, + ), + migrations.CreateModel( + name='Player', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('first_name', models.CharField(max_length=120)), + ('last_name', models.CharField(max_length=120)), + ('full_name', models.CharField(max_length=260)), + ('birth_date', models.DateField(blank=True, null=True)), + ('height_cm', models.PositiveSmallIntegerField(blank=True, null=True)), + ('weight_kg', models.PositiveSmallIntegerField(blank=True, null=True)), + ('wingspan_cm', models.PositiveSmallIntegerField(blank=True, null=True)), + ('dominant_hand', models.CharField(choices=[('right', 'Right'), ('left', 'Left'), ('both', 'Both'), ('unknown', 'Unknown')], default='unknown', max_length=16)), + ('is_active', models.BooleanField(default=True)), + ('nationality', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='players', to='players.nationality')), + ], + options={ + 'ordering': ['full_name', 'id'], + }, + ), + migrations.CreateModel( + name='PlayerAlias', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('alias', models.CharField(max_length=260)), + ('source', models.CharField(blank=True, max_length=80)), + ('is_primary', models.BooleanField(default=False)), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='players.player')), + ], + options={ + 'ordering': ['alias'], + }, + ), + migrations.CreateModel( + name='PlayerCareerEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('shirt_number', models.PositiveSmallIntegerField(blank=True, null=True)), + ('start_date', models.DateField(blank=True, null=True)), + ('end_date', models.DateField(blank=True, null=True)), + ('notes', models.TextField(blank=True)), + ('competition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='career_entries', to='competitions.competition')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='career_entries', to='players.player')), + ('season', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='career_entries', to='competitions.season')), + ], + options={ + 'ordering': ['player', '-start_date', '-id'], + }, + ), + ] diff --git a/apps/players/migrations/0002_initial.py b/apps/players/migrations/0002_initial.py new file mode 100644 index 0000000..c3c53fb --- /dev/null +++ b/apps/players/migrations/0002_initial.py @@ -0,0 +1,121 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('players', '0001_initial'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='playercareerentry', + name='team', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='career_entries', to='teams.team'), + ), + migrations.AddIndex( + model_name='position', + index=models.Index(fields=['code'], name='players_pos_code_ae7cc3_idx'), + ), + migrations.AddIndex( + model_name='position', + index=models.Index(fields=['name'], name='players_pos_name_0b1fd3_idx'), + ), + migrations.AddField( + model_name='player', + name='nominal_position', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nominal_players', to='players.position'), + ), + migrations.AddIndex( + model_name='role', + index=models.Index(fields=['code'], name='players_rol_code_51dc71_idx'), + ), + migrations.AddIndex( + model_name='role', + index=models.Index(fields=['name'], name='players_rol_name_bef503_idx'), + ), + migrations.AddField( + model_name='playercareerentry', + name='role_snapshot', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='career_entries', to='players.role'), + ), + migrations.AddField( + model_name='player', + name='inferred_role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='role_players', to='players.role'), + ), + migrations.AddIndex( + model_name='playeralias', + index=models.Index(fields=['alias'], name='players_pla_alias_c2e58f_idx'), + ), + migrations.AddIndex( + model_name='playeralias', + index=models.Index(fields=['source'], name='players_pla_source_dd2e52_idx'), + ), + migrations.AddConstraint( + model_name='playeralias', + constraint=models.UniqueConstraint(fields=('player', 'alias'), name='uq_player_alias_per_player'), + ), + migrations.AddIndex( + model_name='playercareerentry', + index=models.Index(fields=['player', 'start_date'], name='players_pla_player__80e21f_idx'), + ), + migrations.AddIndex( + model_name='playercareerentry', + index=models.Index(fields=['team', 'season'], name='players_pla_team_id_200f70_idx'), + ), + migrations.AddIndex( + model_name='playercareerentry', + index=models.Index(fields=['competition', 'season'], name='players_pla_competi_10cd9d_idx'), + ), + migrations.AddConstraint( + model_name='playercareerentry', + constraint=models.UniqueConstraint(fields=('player', 'team', 'competition', 'season', 'start_date'), name='uq_player_career_entry_scope'), + ), + migrations.AddConstraint( + model_name='playercareerentry', + constraint=models.CheckConstraint(condition=models.Q(('end_date__isnull', True), ('start_date__isnull', True), ('end_date__gte', models.F('start_date')), _connector='OR'), name='ck_career_entry_dates'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['full_name'], name='players_pla_full_na_c03349_idx'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['last_name', 'first_name'], name='players_pla_last_na_1786cf_idx'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['birth_date'], name='players_pla_birth_d_354cf8_idx'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['nationality'], name='players_pla_nationa_2ab246_idx'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['nominal_position'], name='players_pla_nominal_ff9fb6_idx'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['inferred_role'], name='players_pla_inferre_136050_idx'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['is_active'], name='players_pla_is_acti_90e805_idx'), + ), + migrations.AddIndex( + model_name='player', + index=models.Index(fields=['height_cm'], name='players_pla_height__c4ae99_idx'), + ), + migrations.AddConstraint( + model_name='player', + constraint=models.UniqueConstraint(fields=('full_name', 'birth_date'), name='uq_player_full_name_birth_date'), + ), + ] diff --git a/apps/players/models.py b/apps/players/models.py new file mode 100644 index 0000000..13dba02 --- /dev/null +++ b/apps/players/models.py @@ -0,0 +1,191 @@ +from django.db import models + + +class TimeStampedModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class Nationality(TimeStampedModel): + name = models.CharField(max_length=120, unique=True) + iso2_code = models.CharField(max_length=2, unique=True) + iso3_code = models.CharField(max_length=3, unique=True, blank=True, null=True) + + class Meta: + ordering = ["name"] + indexes = [models.Index(fields=["name"]), models.Index(fields=["iso2_code"])] + verbose_name_plural = "Nationalities" + + def __str__(self) -> str: + return f"{self.name} ({self.iso2_code})" + + +class Position(TimeStampedModel): + code = models.CharField(max_length=10, unique=True) + name = models.CharField(max_length=80, unique=True) + + class Meta: + ordering = ["code"] + indexes = [models.Index(fields=["code"]), models.Index(fields=["name"])] + + def __str__(self) -> str: + return f"{self.code} - {self.name}" + + +class Role(TimeStampedModel): + code = models.CharField(max_length=32, unique=True) + name = models.CharField(max_length=120, unique=True) + description = models.TextField(blank=True) + + class Meta: + ordering = ["name"] + indexes = [models.Index(fields=["code"]), models.Index(fields=["name"])] + + def __str__(self) -> str: + return self.name + + +class Player(TimeStampedModel): + class DominantHand(models.TextChoices): + RIGHT = "right", "Right" + LEFT = "left", "Left" + BOTH = "both", "Both" + UNKNOWN = "unknown", "Unknown" + + first_name = models.CharField(max_length=120) + last_name = models.CharField(max_length=120) + full_name = models.CharField(max_length=260) + birth_date = models.DateField(blank=True, null=True) + nationality = models.ForeignKey( + "players.Nationality", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="players", + ) + nominal_position = models.ForeignKey( + "players.Position", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="nominal_players", + ) + inferred_role = models.ForeignKey( + "players.Role", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="role_players", + ) + height_cm = models.PositiveSmallIntegerField(blank=True, null=True) + weight_kg = models.PositiveSmallIntegerField(blank=True, null=True) + wingspan_cm = models.PositiveSmallIntegerField(blank=True, null=True) + dominant_hand = models.CharField( + max_length=16, + choices=DominantHand.choices, + default=DominantHand.UNKNOWN, + ) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["full_name", "id"] + constraints = [ + models.UniqueConstraint( + fields=["full_name", "birth_date"], + name="uq_player_full_name_birth_date", + ) + ] + indexes = [ + models.Index(fields=["full_name"]), + models.Index(fields=["last_name", "first_name"]), + models.Index(fields=["birth_date"]), + models.Index(fields=["nationality"]), + models.Index(fields=["nominal_position"]), + models.Index(fields=["inferred_role"]), + models.Index(fields=["is_active"]), + models.Index(fields=["height_cm"]), + ] + + def __str__(self) -> str: + return self.full_name + + +class PlayerAlias(TimeStampedModel): + player = models.ForeignKey("players.Player", on_delete=models.CASCADE, related_name="aliases") + alias = models.CharField(max_length=260) + source = models.CharField(max_length=80, blank=True) + is_primary = models.BooleanField(default=False) + + class Meta: + ordering = ["alias"] + constraints = [ + models.UniqueConstraint(fields=["player", "alias"], name="uq_player_alias_per_player") + ] + indexes = [models.Index(fields=["alias"]), models.Index(fields=["source"])] + + def __str__(self) -> str: + return f"{self.alias} ({self.player_id})" + + +class PlayerCareerEntry(TimeStampedModel): + player = models.ForeignKey( + "players.Player", + on_delete=models.CASCADE, + related_name="career_entries", + ) + team = models.ForeignKey( + "teams.Team", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="career_entries", + ) + competition = models.ForeignKey( + "competitions.Competition", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="career_entries", + ) + season = models.ForeignKey( + "competitions.Season", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="career_entries", + ) + role_snapshot = models.ForeignKey( + "players.Role", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="career_entries", + ) + shirt_number = models.PositiveSmallIntegerField(blank=True, null=True) + start_date = models.DateField(blank=True, null=True) + end_date = models.DateField(blank=True, null=True) + notes = models.TextField(blank=True) + + class Meta: + ordering = ["player", "-start_date", "-id"] + constraints = [ + models.UniqueConstraint( + fields=["player", "team", "competition", "season", "start_date"], + name="uq_player_career_entry_scope", + ), + models.CheckConstraint( + check=models.Q(end_date__isnull=True) | models.Q(start_date__isnull=True) | models.Q(end_date__gte=models.F("start_date")), + name="ck_career_entry_dates", + ), + ] + indexes = [ + models.Index(fields=["player", "start_date"]), + models.Index(fields=["team", "season"]), + models.Index(fields=["competition", "season"]), + ] + + def __str__(self) -> str: + return f"{self.player} | {self.team or '-'} | {self.season or '-'}" diff --git a/apps/providers/admin.py b/apps/providers/admin.py new file mode 100644 index 0000000..d9414fa --- /dev/null +++ b/apps/providers/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import ExternalMapping + + +@admin.register(ExternalMapping) +class ExternalMappingAdmin(admin.ModelAdmin): + list_display = ( + "provider_namespace", + "external_id", + "content_type", + "object_id", + "is_active", + "last_seen_at", + ) + list_filter = ("provider_namespace", "is_active", "content_type") + search_fields = ("external_id", "external_secondary_id", "provider_namespace") diff --git a/apps/providers/migrations/0001_initial.py b/apps/providers/migrations/0001_initial.py new file mode 100644 index 0000000..2232690 --- /dev/null +++ b/apps/providers/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalMapping', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('provider_namespace', models.CharField(max_length=80)), + ('external_id', models.CharField(max_length=160)), + ('external_secondary_id', models.CharField(blank=True, max_length=160)), + ('object_id', models.PositiveBigIntegerField()), + ('raw_payload', models.JSONField(blank=True, default=dict)), + ('last_seen_at', models.DateTimeField(auto_now=True)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ], + options={ + 'indexes': [models.Index(fields=['provider_namespace', 'external_id'], name='providers_e_provide_8e3a4f_idx'), models.Index(fields=['content_type', 'object_id'], name='providers_e_content_f29ca2_idx'), models.Index(fields=['is_active'], name='providers_e_is_acti_8ef53c_idx')], + 'constraints': [models.UniqueConstraint(fields=('provider_namespace', 'external_id'), name='uq_mapping_provider_external_id'), models.UniqueConstraint(fields=('provider_namespace', 'content_type', 'object_id'), name='uq_mapping_provider_entity')], + }, + ), + ] diff --git a/apps/providers/models.py b/apps/providers/models.py new file mode 100644 index 0000000..5419780 --- /dev/null +++ b/apps/providers/models.py @@ -0,0 +1,41 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models + + +class ExternalMapping(models.Model): + """Maps internal entities to provider-specific IDs across namespaces.""" + + provider_namespace = models.CharField(max_length=80) + external_id = models.CharField(max_length=160) + external_secondary_id = models.CharField(max_length=160, blank=True) + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveBigIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + # Raw payload is kept only for troubleshooting and parsing diagnostics. + raw_payload = models.JSONField(default=dict, blank=True) + last_seen_at = models.DateTimeField(auto_now=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["provider_namespace", "external_id"], + name="uq_mapping_provider_external_id", + ), + models.UniqueConstraint( + fields=["provider_namespace", "content_type", "object_id"], + name="uq_mapping_provider_entity", + ), + ] + indexes = [ + models.Index(fields=["provider_namespace", "external_id"]), + models.Index(fields=["content_type", "object_id"]), + models.Index(fields=["is_active"]), + ] + + def __str__(self) -> str: + return f"{self.provider_namespace}:{self.external_id}" diff --git a/apps/providers/services/__init__.py b/apps/providers/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/providers/services/mappings.py b/apps/providers/services/mappings.py new file mode 100644 index 0000000..35ad258 --- /dev/null +++ b/apps/providers/services/mappings.py @@ -0,0 +1,19 @@ +from django.contrib.contenttypes.models import ContentType + +from apps.providers.models import ExternalMapping + + +def upsert_external_mapping(*, provider_namespace: str, external_id: str, instance, raw_payload: dict | None = None) -> ExternalMapping: + """Create or update a provider mapping for any internal model instance.""" + content_type = ContentType.objects.get_for_model(instance.__class__) + mapping, _ = ExternalMapping.objects.update_or_create( + provider_namespace=provider_namespace, + content_type=content_type, + object_id=instance.pk, + defaults={ + "external_id": external_id, + "raw_payload": raw_payload or {}, + "is_active": True, + }, + ) + return mapping diff --git a/apps/scouting/admin.py b/apps/scouting/admin.py new file mode 100644 index 0000000..5e46f2b --- /dev/null +++ b/apps/scouting/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + +from .models import FavoritePlayer, SavedSearch + + +@admin.register(SavedSearch) +class SavedSearchAdmin(admin.ModelAdmin): + list_display = ("name", "user", "is_public", "last_run_at", "updated_at") + list_filter = ("is_public",) + search_fields = ("name", "user__username", "user__email") + + +@admin.register(FavoritePlayer) +class FavoritePlayerAdmin(admin.ModelAdmin): + list_display = ("user", "player", "created_at") + list_filter = ("created_at",) + search_fields = ("user__username", "player__full_name") diff --git a/apps/scouting/migrations/0001_initial.py b/apps/scouting/migrations/0001_initial.py new file mode 100644 index 0000000..713b84c --- /dev/null +++ b/apps/scouting/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('players', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FavoritePlayer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('note', models.CharField(blank=True, max_length=240)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='players.player')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_players', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['user', 'created_at'], name='scouting_fa_user_id_e79538_idx'), models.Index(fields=['player'], name='scouting_fa_player__0f3abd_idx')], + 'constraints': [models.UniqueConstraint(fields=('user', 'player'), name='uq_favorite_player_per_user')], + }, + ), + migrations.CreateModel( + name='SavedSearch', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120)), + ('filters', models.JSONField(default=dict)), + ('is_public', models.BooleanField(default=False)), + ('last_run_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='saved_searches', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-updated_at'], + 'indexes': [models.Index(fields=['user', 'updated_at'], name='scouting_sa_user_id_913692_idx'), models.Index(fields=['is_public'], name='scouting_sa_is_publ_ae6b37_idx')], + 'constraints': [models.UniqueConstraint(fields=('user', 'name'), name='uq_saved_search_user_name')], + }, + ), + ] diff --git a/apps/scouting/models.py b/apps/scouting/models.py new file mode 100644 index 0000000..b21be09 --- /dev/null +++ b/apps/scouting/models.py @@ -0,0 +1,52 @@ +from django.conf import settings +from django.db import models + + +class SavedSearch(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="saved_searches", + ) + name = models.CharField(max_length=120) + filters = models.JSONField(default=dict) + is_public = models.BooleanField(default=False) + last_run_at = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + constraints = [models.UniqueConstraint(fields=["user", "name"], name="uq_saved_search_user_name")] + indexes = [ + models.Index(fields=["user", "updated_at"]), + models.Index(fields=["is_public"]), + ] + + def __str__(self) -> str: + return f"{self.name} ({self.user})" + + +class FavoritePlayer(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="favorite_players", + ) + player = models.ForeignKey( + "players.Player", + on_delete=models.CASCADE, + related_name="favorites", + ) + note = models.CharField(max_length=240, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint(fields=["user", "player"], name="uq_favorite_player_per_user") + ] + indexes = [models.Index(fields=["user", "created_at"]), models.Index(fields=["player"])] + + def __str__(self) -> str: + return f"{self.user} -> {self.player}" diff --git a/apps/scouting/services/__init__.py b/apps/scouting/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/scouting/services/searches.py b/apps/scouting/services/searches.py new file mode 100644 index 0000000..ea5defa --- /dev/null +++ b/apps/scouting/services/searches.py @@ -0,0 +1,28 @@ +from django.db.models import QuerySet + +from apps.players.models import Player + + +def apply_saved_search_filters(queryset: QuerySet[Player], filters: dict) -> QuerySet[Player]: + """Apply structured saved-search filters to a player queryset.""" + nationality_id = filters.get("nationality_id") + if nationality_id: + queryset = queryset.filter(nationality_id=nationality_id) + + nominal_position_id = filters.get("nominal_position_id") + if nominal_position_id: + queryset = queryset.filter(nominal_position_id=nominal_position_id) + + inferred_role_id = filters.get("inferred_role_id") + if inferred_role_id: + queryset = queryset.filter(inferred_role_id=inferred_role_id) + + min_height_cm = filters.get("min_height_cm") + if min_height_cm is not None: + queryset = queryset.filter(height_cm__gte=min_height_cm) + + max_height_cm = filters.get("max_height_cm") + if max_height_cm is not None: + queryset = queryset.filter(height_cm__lte=max_height_cm) + + return queryset diff --git a/apps/stats/admin.py b/apps/stats/admin.py new file mode 100644 index 0000000..3b0d61a --- /dev/null +++ b/apps/stats/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from .models import PlayerSeason, PlayerSeasonStats + + +@admin.register(PlayerSeason) +class PlayerSeasonAdmin(admin.ModelAdmin): + list_display = ("player", "season", "team", "competition", "games_played", "minutes_played") + list_filter = ("season", "competition") + search_fields = ("player__full_name", "team__name", "competition__name", "season__label") + + +@admin.register(PlayerSeasonStats) +class PlayerSeasonStatsAdmin(admin.ModelAdmin): + list_display = ( + "player_season", + "points", + "rebounds", + "assists", + "usage_rate", + "true_shooting_pct", + ) + search_fields = ("player_season__player__full_name",) diff --git a/apps/stats/migrations/0001_initial.py b/apps/stats/migrations/0001_initial.py new file mode 100644 index 0000000..05479a5 --- /dev/null +++ b/apps/stats/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('competitions', '0002_initial'), + ('players', '0002_initial'), + ('teams', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PlayerSeason', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('games_played', models.PositiveSmallIntegerField(default=0)), + ('games_started', models.PositiveSmallIntegerField(default=0)), + ('minutes_played', models.PositiveIntegerField(default=0)), + ('competition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_seasons', to='competitions.competition')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_seasons', to='players.player')), + ('season', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='player_seasons', to='competitions.season')), + ('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='player_seasons', to='teams.team')), + ], + options={ + 'ordering': ['-season__start_date', 'player__full_name'], + }, + ), + migrations.CreateModel( + name='PlayerSeasonStats', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('points', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('rebounds', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('assists', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('steals', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('blocks', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('turnovers', models.DecimalField(decimal_places=2, default=0, max_digits=6)), + ('fg_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('three_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('ft_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('usage_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('true_shooting_pct', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True)), + ('player_efficiency_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True)), + ('player_season', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='stats', to='stats.playerseason')), + ], + ), + migrations.AddIndex( + model_name='playerseason', + index=models.Index(fields=['player', 'season'], name='stats_playe_player__588dfe_idx'), + ), + migrations.AddIndex( + model_name='playerseason', + index=models.Index(fields=['season', 'team'], name='stats_playe_season__a42291_idx'), + ), + migrations.AddIndex( + model_name='playerseason', + index=models.Index(fields=['season', 'competition'], name='stats_playe_season__acbf94_idx'), + ), + migrations.AddIndex( + model_name='playerseason', + index=models.Index(fields=['team', 'competition'], name='stats_playe_team_id_9d2ead_idx'), + ), + migrations.AddConstraint( + model_name='playerseason', + constraint=models.UniqueConstraint(fields=('player', 'season', 'team', 'competition'), name='uq_player_season_scope'), + ), + migrations.AddIndex( + model_name='playerseasonstats', + index=models.Index(fields=['points'], name='stats_playe_points_8222f6_idx'), + ), + migrations.AddIndex( + model_name='playerseasonstats', + index=models.Index(fields=['rebounds'], name='stats_playe_rebound_662f1b_idx'), + ), + migrations.AddIndex( + model_name='playerseasonstats', + index=models.Index(fields=['assists'], name='stats_playe_assists_960591_idx'), + ), + migrations.AddIndex( + model_name='playerseasonstats', + index=models.Index(fields=['usage_rate'], name='stats_playe_usage_r_79913f_idx'), + ), + migrations.AddIndex( + model_name='playerseasonstats', + index=models.Index(fields=['true_shooting_pct'], name='stats_playe_true_sh_552b8f_idx'), + ), + ] diff --git a/apps/stats/models.py b/apps/stats/models.py new file mode 100644 index 0000000..b7a3885 --- /dev/null +++ b/apps/stats/models.py @@ -0,0 +1,71 @@ +from django.db import models + + +class PlayerSeason(models.Model): + 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") + team = models.ForeignKey( + "teams.Team", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="player_seasons", + ) + competition = models.ForeignKey( + "competitions.Competition", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="player_seasons", + ) + games_played = models.PositiveSmallIntegerField(default=0) + games_started = models.PositiveSmallIntegerField(default=0) + minutes_played = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ["-season__start_date", "player__full_name"] + constraints = [ + models.UniqueConstraint( + fields=["player", "season", "team", "competition"], + name="uq_player_season_scope", + ) + ] + indexes = [ + models.Index(fields=["player", "season"]), + models.Index(fields=["season", "team"]), + models.Index(fields=["season", "competition"]), + models.Index(fields=["team", "competition"]), + ] + + def __str__(self) -> str: + return f"{self.player} - {self.season}" + + +class PlayerSeasonStats(models.Model): + player_season = models.OneToOneField( + "stats.PlayerSeason", on_delete=models.CASCADE, related_name="stats" + ) + points = models.DecimalField(max_digits=6, decimal_places=2, default=0) + rebounds = models.DecimalField(max_digits=6, decimal_places=2, default=0) + assists = models.DecimalField(max_digits=6, decimal_places=2, default=0) + steals = models.DecimalField(max_digits=6, decimal_places=2, default=0) + blocks = models.DecimalField(max_digits=6, decimal_places=2, default=0) + turnovers = models.DecimalField(max_digits=6, decimal_places=2, default=0) + fg_pct = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True) + three_pct = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True) + ft_pct = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True) + usage_rate = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True) + true_shooting_pct = models.DecimalField(max_digits=5, decimal_places=2, blank=True, null=True) + player_efficiency_rating = models.DecimalField(max_digits=6, decimal_places=2, blank=True, null=True) + + class Meta: + indexes = [ + models.Index(fields=["points"]), + models.Index(fields=["rebounds"]), + models.Index(fields=["assists"]), + models.Index(fields=["usage_rate"]), + models.Index(fields=["true_shooting_pct"]), + ] + + def __str__(self) -> str: + return f"Stats for {self.player_season}" diff --git a/apps/teams/admin.py b/apps/teams/admin.py new file mode 100644 index 0000000..4a6440d --- /dev/null +++ b/apps/teams/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Team + + +@admin.register(Team) +class TeamAdmin(admin.ModelAdmin): + list_display = ("name", "short_name", "country", "is_national_team") + list_filter = ("is_national_team", "country") + search_fields = ("name", "short_name", "slug") diff --git a/apps/teams/migrations/0001_initial.py b/apps/teams/migrations/0001_initial.py new file mode 100644 index 0000000..da75da7 --- /dev/null +++ b/apps/teams/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.12 on 2026-03-10 09:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('players', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('short_name', models.CharField(blank=True, max_length=80)), + ('slug', models.SlugField(max_length=220, unique=True)), + ('founded_year', models.PositiveSmallIntegerField(blank=True, null=True)), + ('is_national_team', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('country', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teams', to='players.nationality')), + ], + options={ + 'ordering': ['name'], + 'indexes': [models.Index(fields=['name'], name='teams_team_name_43e047_idx'), models.Index(fields=['slug'], name='teams_team_slug_1ce39f_idx'), models.Index(fields=['country'], name='teams_team_country_f5cb06_idx'), models.Index(fields=['is_national_team'], name='teams_team_is_nati_04af60_idx')], + 'constraints': [models.UniqueConstraint(fields=('name', 'country'), name='uq_team_name_country')], + }, + ), + ] diff --git a/apps/teams/models.py b/apps/teams/models.py new file mode 100644 index 0000000..3d8ab33 --- /dev/null +++ b/apps/teams/models.py @@ -0,0 +1,33 @@ +from django.db import models + + +class Team(models.Model): + name = models.CharField(max_length=200) + short_name = models.CharField(max_length=80, blank=True) + slug = models.SlugField(max_length=220, unique=True) + country = models.ForeignKey( + "players.Nationality", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="teams", + ) + founded_year = models.PositiveSmallIntegerField(blank=True, null=True) + is_national_team = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + constraints = [ + models.UniqueConstraint(fields=["name", "country"], name="uq_team_name_country") + ] + indexes = [ + models.Index(fields=["name"]), + models.Index(fields=["slug"]), + models.Index(fields=["country"]), + models.Index(fields=["is_national_team"]), + ] + + def __str__(self) -> str: + return self.name