Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions tabbycat/api/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
234 changes: 234 additions & 0 deletions tabbycat/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions tabbycat/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions tabbycat/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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