Repository: TabbycatDebate/tabbycat

Issue:
Create endpoint for participant round results
`GET`-only endpoints mirroring the round-by-round information found in team/speaker tab (public when enabled):

Schema could be akin to:
```
{
    participant: url,
    rounds: [
        {
            round: url,
            score: float
        }
    ]
}
```
For teams, it could include the team score and points. It doesn't have to include pairings as those would already be available.


Discussion:

Resolving patch:
diff --git a/tabbycat/api/serializers.py b/tabbycat/api/serializers.py
--- a/tabbycat/api/serializers.py
+++ b/tabbycat/api/serializers.py
@@ -25,7 +25,7 @@
 from participants.utils import populate_code_names
 from privateurls.utils import populate_url_keys
 from results.mixins import TabroomSubmissionFieldsMixin
-from results.models import BallotSubmission, SpeakerScore
+from results.models import BallotSubmission, SpeakerScore, TeamScore
 from results.result import DebateResult
 from standings.speakers import SpeakerStandingsGenerator
 from standings.teams import TeamStandingsGenerator
@@ -1381,3 +1381,51 @@ def update(self, instance, validated_data):
             adjudicators.save(debate=instance)
 
         return super().update(instance, validated_data)
+
+
+class SpeakerRoundScoresSerializer(serializers.ModelSerializer):
+    class RoundScoresSerializer(serializers.ModelSerializer):
+        class RoundSpeechSerializer(serializers.ModelSerializer):
+            class Meta:
+                model = SpeakerScore
+                fields = ('score', 'position', 'ghost')
+
+        round = fields.TournamentHyperlinkedRelatedField(view_name='api-round-detail', source='debate.round',
+            lookup_field='seq', lookup_url_kwarg='round_seq',
+            queryset=Round.objects.all())
+
+        speeches = RoundSpeechSerializer(many=True, source="scores")
+
+        class Meta:
+            model = DebateTeam
+            fields = ('round', 'speeches')
+
+    speaker = fields.TournamentHyperlinkedIdentityField(tournament_field='team__tournament', view_name='api-speaker-detail')
+    rounds = RoundScoresSerializer(many=True, source="debateteams")
+
+    class Meta:
+        model = Speaker
+        fields = ('speaker', 'rounds')
+
+
+class TeamRoundScoresSerializer(serializers.ModelSerializer):
+
+    class ScoreSerializer(serializers.ModelSerializer):
+        round = fields.TournamentHyperlinkedRelatedField(view_name='api-round-detail', source='debate.round',
+            lookup_field='seq', lookup_url_kwarg='round_seq',
+            queryset=Round.objects.all())
+
+        points = serializers.IntegerField(source='ballot.points')
+        score = serializers.FloatField(source='ballot.score')
+        has_ghost = serializers.BooleanField(source='ballot.has_ghost')
+
+        class Meta:
+            model = TeamScore
+            fields = ('round', 'points', 'score', 'has_ghost')
+
+    team = fields.TournamentHyperlinkedIdentityField(view_name='api-team-detail')
+    rounds = ScoreSerializer(many=True, source="debateteam_set")
+
+    class Meta:
+        model = Team
+        fields = ('team', 'rounds')
diff --git a/tabbycat/api/urls.py b/tabbycat/api/urls.py
--- a/tabbycat/api/urls.py
+++ b/tabbycat/api/urls.py
@@ -157,9 +157,14 @@
                     path('/<int:pk>',
                          views.TeamViewSet.as_view(detail_methods),
                          name='api-team-detail'),
-                    path('/standings',
-                         views.TeamStandingsView.as_view(),
-                         name='api-team-standings'),
+                    path('/standings', include([
+                        path('',
+                            views.TeamStandingsView.as_view(),
+                             name='api-team-standings'),
+                        path('/rounds',
+                            views.TeamRoundStandingsRoundsView.as_view({'get': 'list'}),
+                            name='api-team-round-standings'),
+                    ])),
                 ])),
                 path('/adjudicators', include([
                     path('',
@@ -193,6 +198,9 @@
                         path('/replies',
                             views.ReplySpeakerStandingsView.as_view(),
                             name='api-reply-speaker-standings'),
+                        path('/rounds',
+                            views.SpeakerRoundStandingsRoundsView.as_view({'get': 'list'}),
+                            name='api-speaker-round-standings'),
                     ])),
                 ])),
                 path('/venues', include([
diff --git a/tabbycat/api/views.py b/tabbycat/api/views.py
--- a/tabbycat/api/views.py
+++ b/tabbycat/api/views.py
@@ -1,3 +1,4 @@
+from copy import deepcopy
 from itertools import groupby
 
 from asgiref.sync import async_to_sync
@@ -25,9 +26,10 @@
 from checkins.consumers import CheckInEventConsumer
 from checkins.models import Event
 from checkins.utils import create_identifiers, get_unexpired_checkins
-from draw.models import Debate
+from draw.models import Debate, DebateTeam
 from options.models import TournamentPreferenceModel
 from participants.models import Adjudicator, Institution, Speaker, SpeakerCategory, Team
+from results.models import SpeakerScore, TeamScore
 from standings.speakers import SpeakerStandingsGenerator
 from standings.teams import TeamStandingsGenerator
 from tournaments.mixins import TournamentFromUrlMixin
@@ -659,6 +661,78 @@ class TeamStandingsView(BaseStandingsView):
     generator = TeamStandingsGenerator
 
 
+@extend_schema(tags=['standings'], parameters=[
+    tournament_parameter,
+    OpenApiParameter('replies', description='Whether to include reply speeches', required=False, type=bool, default=False),
+    OpenApiParameter('substantive', description='Whether to include substantive speeches', required=False, type=bool, default=True),
+    OpenApiParameter('ghost', description='Include ghost (iron-person) scores', required=False, type=bool, default=False),
+])
+@extend_schema_view(
+    list=extend_schema(summary="Get speaker scores per round", responses=serializers.SpeakerRoundScoresSerializer(many=True)),
+)
+class SpeakerRoundStandingsRoundsView(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet):
+    serializer_class = serializers.SpeakerRoundScoresSerializer
+    tournament_field = "team__tournament"
+    access_preference = 'speaker_tab_released'
+
+    def get_queryset(self):
+        qs = super().get_queryset().prefetch_related(Prefetch('team__debateteam_set', queryset=DebateTeam.objects.all().select_related('debate__round__tournament')))
+        data = {s.id: s for s in qs.all()}
+
+        speaker_scores = SpeakerScore.objects.select_related('speaker', 'ballot_submission',
+            'debate_team__debate__round__tournament').filter(
+            ballot_submission__confirmed=True, speaker_id__in=data.keys(),
+        ).order_by('speaker_id', 'debate_team_id', 'position')
+
+        if self.request.query_params.get('ghost', False) == 'true':
+            speaker_scores = speaker_scores.filter(ghost=True)
+        if self.request.query_params.get('replies', False) == 'true':
+            speaker_scores = speaker_scores.filter(position=self.tournament.reply_position)
+        elif self.request.query_params.get('substantive', 'true') == 'true':
+            speaker_scores = speaker_scores.filter(position__lte=self.tournament.last_substantive_position)
+
+        for spk in data.values():
+            spk.debateteams = deepcopy(spk.team.debateteam_set.all())
+            for dt in spk.debateteams:
+                dt.scores = []
+
+        for speaker, all_scores in groupby(speaker_scores, key=lambda ss: ss.speaker_id):
+            speaker_rounds = {dt.id: dt for dt in data[speaker].debateteams}
+            for dt, round_scores in groupby(all_scores, key=lambda ss: ss.debate_team_id):
+                speaker_rounds[dt].scores.extend(list(round_scores))
+
+        return data.values()
+
+
+@extend_schema(tags=['standings'], parameters=[
+    tournament_parameter,
+])
+@extend_schema_view(
+    list=extend_schema(summary="Get team scores per round", responses=serializers.TeamRoundScoresSerializer(many=True)),
+)
+class TeamRoundStandingsRoundsView(TournamentAPIMixin, TournamentPublicAPIMixin, ModelViewSet):
+    serializer_class = serializers.TeamRoundScoresSerializer
+    access_preference = 'team_tab_released'
+
+    def perform_create(self, serializer):
+        serializer.save()
+
+    def get_queryset(self):
+        ts_pf = Prefetch('teamscore_set', queryset=TeamScore.objects.filter(ballot_submission__confirmed=True), to_attr='round_scores')
+        qs = super().get_queryset().prefetch_related(
+            Prefetch('debateteam_set', queryset=DebateTeam.objects.all().prefetch_related(ts_pf).select_related('debate__round__tournament')))
+
+        for t in qs:
+            for dt in t.debateteam_set.all():
+                if len(dt.round_scores):
+                    # There should only ever be one confirmed score
+                    dt.ballot = dt.round_scores[0]
+                else:
+                    dt.ballot = TeamScore()
+
+        return qs
+
+
 @extend_schema(tags=['debates'], parameters=round_parameters)
 @extend_schema_view(
     list=extend_schema(summary="List pairings in round"),


Test patch: