diff --git a/tabbycat/api/fields.py b/tabbycat/api/fields.py index 97bc045564f..4c509c25aac 100644 --- a/tabbycat/api/fields.py +++ b/tabbycat/api/fields.py @@ -27,7 +27,7 @@ def use_pk_only_optimization(self): return False def get_tournament(self, obj): - return obj.tournament + return self.context.get('tournament', obj.tournament) def get_url_kwargs(self, obj): lookup_value = getattr(obj, self.lookup_field) @@ -53,6 +53,41 @@ def lookup_kwargs(self): def get_queryset(self): return super().get_queryset().filter(**self.lookup_kwargs()).select_related(self.tournament_field) + def to_internal_value(self, data): + """ + Import-aware resolution: if context['import_mode'] is set, attempt to + resolve using an external-url -> instance mapping provided via + context['import_map'] before falling back to standard URL resolution. + """ + # Fast-path: if not in import mode, behave normally + if not getattr(self, 'context', None) or not self.context.get('import_mode'): + return super().to_internal_value(data) + + # Normalize to a relative path so keys are consistent + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + # Delegate to default handling for type errors + return super().to_internal_value(data) + + if http_prefix: + parsed = parse.urlparse(data) + path = parsed.path + prefix = get_script_prefix() + if path.startswith(prefix): + path = '/' + path[len(prefix):] + else: + path = uri_to_iri(parse.unquote(data)) + + # Try mapping lookup first + import_map = self.context.get('import_map') or {} + mapped = import_map.get(path) + if mapped is not None: + return mapped + + # Fallback to normal behavior (may still resolve locally) + return super().to_internal_value(data) + class TournamentHyperlinkedIdentityField(TournamentHyperlinkedRelatedField, HyperlinkedIdentityField): pass @@ -63,10 +98,10 @@ class RoundHyperlinkedRelatedField(TournamentHyperlinkedRelatedField): round_field = 'round' def get_tournament(self, obj): - return self.get_round(obj).tournament + return super().get_tournament(round := self.get_round(obj)) or round.tournament def get_round(self, obj): - return obj.round + return self.context.get('round', obj.round) def get_url_kwargs(self, obj): kwargs = super().get_url_kwargs(obj) @@ -246,6 +281,26 @@ def to_internal_value(self, data): if isinstance(data, tuple(model for model, field in self.models.values())): return data + # Import-aware shortcut: try import_map first + if getattr(self, 'context', None) and self.context.get('import_mode'): + try: + http_prefix = data.startswith(('http:', 'https:')) + except AttributeError: + self.fail('incorrect_type', data_type=type(data).__name__) + + if http_prefix: + parsed = parse.urlparse(data) + path = parsed.path + prefix = get_script_prefix() + if path.startswith(prefix): + path = '/' + path[len(prefix):] + else: + path = uri_to_iri(parse.unquote(data)) + + mapped = (self.context.get('import_map') or {}).get(path) + if mapped is not None: + return mapped + try: http_prefix = data.startswith(('http:', 'https:')) except AttributeError: diff --git a/tabbycat/api/serializers.py b/tabbycat/api/serializers.py index 5e8e65e953f..e8e3e572c5f 100644 --- a/tabbycat/api/serializers.py +++ b/tabbycat/api/serializers.py @@ -2,12 +2,15 @@ from collections.abc import Mapping from datetime import date, datetime, time from functools import partial, partialmethod +from urllib import parse from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from django.db import IntegrityError from django.db.models import QuerySet +from django.urls import get_script_prefix from django.utils import timezone +from django.utils.encoding import uri_to_iri from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import PermissionDenied @@ -1620,3 +1623,234 @@ class ParticipantIdField(fields.BaseSourceField): class Meta: model = Person fields = '__all__' + + +class FullRoundPairingSerializer(RoundPairingSerializer): + confirmed_ballot = BallotSerializer() + + def create(self, validated_data): + confirmed_ballot = validated_data.pop('confirmed_ballot', None) + debate = super().create(validated_data) + + if confirmed_ballot is not None: + save_related(BallotSerializer, confirmed_ballot, self.context, {'debate': debate}) + + return debate + + +class FullRoundSerializer(RoundSerializer): + pairings = FullRoundPairingSerializer(many=True, source='debate_set') + preformed_panels = PreformedPanelSerializer(many=True, source='preformedpanel_set') + + def create(self, validated_data): + pairings = validated_data.pop('debate_set', []) + preformed_panels = validated_data.pop('preformedpanel_set', []) + + round = super().create(validated_data) + + save_related(FullRoundPairingSerializer, pairings, self.context, {'round': round}) + save_related(PreformedPanelSerializer, preformed_panels, self.context, {'round': round}) + + return round + + +class FullAdjudicatorSerializer(AdjudicatorSerializer): + feedback = FeedbackSerializer(many=True, source='adjudicatorfeedback_set') + + +class FullTournamentSerializer(TournamentSerializer): + rounds = FullRoundSerializer(many=True, source='round_set') + teams = TeamSerializer(many=True, source='team_set') + adjudicators = FullAdjudicatorSerializer(many=True, source='adjudicator_set') + break_categories = BreakCategorySerializer(many=True, source='breakcategory_set') + speaker_categories = SpeakerCategorySerializer(many=True, source='speakercategory_set') + venues = VenueSerializer(many=True, source='venue_set') + venue_categories = VenueCategorySerializer(many=True, source='venuecategory_set') + score_criteria = ScoreCriterionSerializer(many=True, source='scorecriterion_set') + # institutions = InstitutionSerializer(many=True) + # feedback_questions = FeedbackQuestionSerializer(many=True, source='question_set') + + def _normalize_url_path(self, url): + try: + http_prefix = url.startswith(('http:', 'https:')) + except Exception: + return None + if http_prefix: + p = parse.urlparse(url) + path = p.path + prefix = get_script_prefix() + if path.startswith(prefix): + path = '/' + path[len(prefix):] + return path + return uri_to_iri(parse.unquote(url)) + + def _strip_ids_and_motion_urls(self, data): + """Deep-copy-like cleaner: remove generic 'id' fields and motion URLs for import leniency.""" + if isinstance(data, dict): + data = {k: self._strip_ids_and_motion_urls(v) for k, v in data.items() if k != 'id'} + # For motion-in-round entries, drop 'url' key to allow creating from text/reference + # Round export structure: motions: [{ 'url': ..., 'text': ..., 'reference': ... }] + if 'motions' in data and isinstance(data['motions'], list): + cleaned = [] + for m in data['motions']: + if isinstance(m, dict): + m = {k: self._strip_ids_and_motion_urls(v) for k, v in m.items() if k != 'url' and k != 'id'} + cleaned.append(m) + data['motions'] = cleaned + return data + if isinstance(data, list): + return [self._strip_ids_and_motion_urls(v) for v in data] + return data + + def to_internal_value(self, data): + if self.context.get('import_mode'): + # Stash nested sets to import later and validate only base Tournament fields now + self._raw_import = { + 'speaker_categories': data.get('speaker_categories', []) or [], + 'break_categories': data.get('break_categories', []) or [], + 'venue_categories': data.get('venue_categories', []) or [], + 'venues': data.get('venues', []) or [], + 'score_criteria': data.get('score_criteria', []) or [], + 'teams': data.get('teams', []) or [], + 'adjudicators': data.get('adjudicators', []) or [], + 'rounds': data.get('rounds', []) or [], + } + # Collect adjudicator feedback entries to import after rounds are created + self._raw_feedback = [] + for adj in self._raw_import['adjudicators']: + if isinstance(adj, dict) and isinstance(adj.get('feedback'), list): + for f in adj['feedback']: + self._raw_feedback.append(f) + + # Strip IDs and motion URLs to avoid cross-site URL validation issues + cleaned = {k: v for k, v in data.items() if k not in self._raw_import} + cleaned = self._strip_ids_and_motion_urls(cleaned) + return super().to_internal_value(cleaned) + return super().to_internal_value(data) + + def _import_list(self, items, serializer_cls, save_kwargs=None, context=None): + """Create objects from raw items via serializer validation, update import_map by URL paths.""" + if not items: + return [] + created = [] + ctx = self.context.copy() + if context: + ctx.update(context) + import_map = ctx.setdefault('import_map', {}) + pending_adj_conflicts = [] + for item in items: + # Preserve a copy for URL mapping before cleaning + raw_url = None + if isinstance(item, dict): + raw_url = item.get('url') + # Drop global institution links on import to avoid cross-site URL resolution + if serializer_cls in (TeamSerializer, AdjudicatorSerializer, FullAdjudicatorSerializer): + item = {k: v for k, v in item.items() if k not in ('institution', 'institution_conflicts')} + # Stash adjudicator_conflicts for a second pass since they can be forward references + if serializer_cls in (AdjudicatorSerializer, FullAdjudicatorSerializer) and 'adjudicator_conflicts' in item: + pending_adj_conflicts.append((raw_url, item.pop('adjudicator_conflicts'))) + s = serializer_cls(data=item, context=ctx) + s.is_valid(raise_exception=True) + obj = s.save(**(save_kwargs or {})) + created.append(obj) + if raw_url: + path = self._normalize_url_path(raw_url) + if path: + import_map[path] = obj + # Apply adjudicator conflicts now that all adjudicators exist and are mapped + if pending_adj_conflicts: + for owner_url, conflicts in pending_adj_conflicts: + owner = import_map.get(self._normalize_url_path(owner_url)) if owner_url else None + if owner is None: + continue + targets = [] + for c in conflicts or []: + path = self._normalize_url_path(c) + if path and path in import_map: + targets.append(import_map[path]) + if targets: + try: + owner.adjudicator_conflicts.set(targets) + except Exception: + pass + # Sync context back + self.context['import_map'] = import_map + return created + + def _perform_full_import(self, instance): + ctx = self.context + ctx.setdefault('import_map', {}) + ctx['tournament'] = instance + + self._import_list(self._raw_import.get('speaker_categories'), SpeakerCategorySerializer, save_kwargs={'tournament': instance}) + self._import_list(self._raw_import.get('break_categories'), BreakCategorySerializer, save_kwargs={'tournament': instance}) + self._import_list(self._raw_import.get('venue_categories'), VenueCategorySerializer, save_kwargs={'tournament': instance}) + self._import_list(self._raw_import.get('score_criteria'), ScoreCriterionSerializer, save_kwargs={'tournament': instance}) + self._import_list(self._raw_import.get('venues'), VenueSerializer, save_kwargs={'tournament': instance}) + self._import_list(self._raw_import.get('teams'), TeamSerializer, save_kwargs={'tournament': instance}) + self._import_list(self._raw_import.get('adjudicators'), AdjudicatorSerializer, save_kwargs={'tournament': instance}) + + for r in self._raw_import.get('rounds') or []: + # Extract nested + raw_motion_urls = [] + if isinstance(r, dict) and isinstance(r.get('motions'), list): + raw_motion_urls = [m.get('url') for m in r.get('motions', [])] + r = self._strip_ids_and_motion_urls(r) + pairings = r.pop('pairings', []) + preformed_panels = r.pop('preformed_panels', []) + + # Create round (with motions) + rs = RoundSerializer(data=r, context=ctx) + rs.is_valid(raise_exception=True) + rnd = rs.save(tournament=instance) + + # Map motions by sequence back to original URLs if present + if raw_motion_urls: + try: + rms = list(RoundMotion.objects.filter(round=rnd).select_related('motion').order_by('seq')) + for i, url in enumerate(raw_motion_urls): + if not url: + continue + path = self._normalize_url_path(url) + if not path: + continue + if i < len(rms): + ctx['import_map'][path] = rms[i].motion + except Exception: + pass + + # Pairings (debates) with optional confirmed ballots + for p in pairings: + ps = FullRoundPairingSerializer(data=p, context={**ctx, 'round': rnd}) + ps.is_valid(raise_exception=True) + debate = ps.save() + # Map pairing URL + if isinstance(p, dict) and p.get('url'): + path = self._normalize_url_path(p['url']) + if path: + ctx['import_map'][path] = debate + + # Preformed panels + for pp in preformed_panels: + pps = PreformedPanelSerializer(data=pp, context={**ctx, 'round': rnd}) + pps.is_valid(raise_exception=True) + pps.save() + + for f in getattr(self, '_raw_feedback', []) or []: + fs = FeedbackSerializer(data=self._strip_ids_and_motion_urls(f), context=ctx) + fs.is_valid(raise_exception=True) + fs.save() + + return instance + + def create(self, validated_data): + t = super().create(validated_data) + if self.context.get('import_mode'): + return self._perform_full_import(t) + return t + + def update(self, instance, validated_data): + t = super().update(instance, validated_data) + if self.context.get('import_mode'): + return self._perform_full_import(t) + return t diff --git a/tabbycat/api/urls.py b/tabbycat/api/urls.py index 332b3b79c5e..39d6e915f77 100644 --- a/tabbycat/api/urls.py +++ b/tabbycat/api/urls.py @@ -38,6 +38,9 @@ views.TournamentViewSet.as_view(detail_methods), name='api-tournament-detail'), + path('/full', views.FullTournamentViewSet.as_view({'get': 'retrieve'}), + name='api-tournament-full'), + path('/motions', include([ path('', views.MotionViewSet.as_view(list_methods), diff --git a/tabbycat/api/views.py b/tabbycat/api/views.py index 879a7ef5f59..81d03df0be6 100644 --- a/tabbycat/api/views.py +++ b/tabbycat/api/views.py @@ -1408,3 +1408,26 @@ class ParticipantIdentificationView(TournamentAPIMixin, ModelViewSet): def get_object(self): return self.request.auth + + +class FullTournamentViewSet(TournamentAPIMixin, ModelViewSet): + serializer_class = serializers.FullTournamentSerializer + lookup_field = 'slug' + lookup_url_kwarg = 'tournament_slug' + + def get_queryset(self): + return Tournament.objects.all().prefetch_related( + 'team_set__tournament', + 'team_set__speaker_set__categories__tournament', + 'team_set__breakcategory_set__tournament', + 'adjudicator_set__tournament', + 'round_set__roundmotion_set__motion', + 'round_set__debate_set__debateteam_set__team__tournament', + 'round_set__debate_set__debateadjudicator_set__adjudicator__tournament', + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + if self.request.method in ('POST', 'PUT', 'PATCH'): + ctx['import_mode'] = True + return ctx