Repository: TabbycatDebate/tabbycat

Issue:
Create API endpoints for preformed panels
It would be good to create a couple endpoints as to work with preformed panels:

- `GET /api/v1/tournaments/{name}/rounds/{seq}/preformed-panels`
  - List current preformed panels for round
- `POST /api/v1/tournaments/{name}/rounds/{seq}/preformed-panels`
  - Create a custom preformed panel
- `PUT /api/v1/tournaments/{name}/rounds/{seq}/preformed-panels`
  - Create blank set of panels
- `GET|POST|PATCH|DELETE /api/v1/tournaments/{name}/rounds/{seq}/preformed-panels/{id}`
  - Standard CRUD verbs for specific panel of round

All of these would be restricted to admin.

The model (using the `adjudicators` field from draw endpoints):

```json
{
    "importance": 0.0,
    "bracket_min": 0.0,
    "bracket_max": 0.0,
    "room_rank": 0,
    "liveness": 0,
    "adjudicators": [
        "chair": "",
        "panellists": [],
        "trainees": []
    ]
}
```


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
@@ -14,7 +14,7 @@
 from rest_framework.relations import Hyperlink
 from rest_framework.settings import api_settings
 
-from adjallocation.models import DebateAdjudicator
+from adjallocation.models import DebateAdjudicator, PreformedPanel
 from adjfeedback.models import AdjudicatorFeedback, AdjudicatorFeedbackQuestion
 from breakqual.models import BreakCategory, BreakingTeam
 from draw.models import Debate, DebateTeam
@@ -881,6 +881,21 @@ class SpeakerStandingsSerializer(BaseStandingsSerializer):
     speaker = fields.AnonymisingHyperlinkedTournamentRelatedField(view_name='api-speaker-detail', anonymous_source='anonymous')
 
 
+class DebateAdjudicatorSerializer(serializers.Serializer):
+    adjudicators = Adjudicator.objects.all()
+    chair = fields.TournamentHyperlinkedRelatedField(view_name='api-adjudicator-detail', queryset=adjudicators)
+    panellists = fields.TournamentHyperlinkedRelatedField(many=True, view_name='api-adjudicator-detail', queryset=adjudicators)
+    trainees = fields.TournamentHyperlinkedRelatedField(many=True, view_name='api-adjudicator-detail', queryset=adjudicators)
+
+    def save(self, **kwargs):
+        aa = kwargs['debate'].adjudicators
+        aa.chair = self.validated_data.get('chair')
+        aa.panellists = self.validated_data.get('panellists')
+        aa.trainees = self.validated_data.get('trainees')
+        aa.save()
+        return aa
+
+
 class RoundPairingSerializer(serializers.ModelSerializer):
     class DebateTeamSerializer(serializers.ModelSerializer):
         team = fields.TournamentHyperlinkedRelatedField(view_name='api-team-detail', queryset=Team.objects.all())
@@ -889,20 +904,6 @@ class Meta:
             model = DebateTeam
             fields = ('team', 'side')
 
-    class DebateAdjudicatorSerializer(serializers.Serializer):
-        adjudicators = Adjudicator.objects.all()
-        chair = fields.TournamentHyperlinkedRelatedField(view_name='api-adjudicator-detail', queryset=adjudicators)
-        panellists = fields.TournamentHyperlinkedRelatedField(many=True, view_name='api-adjudicator-detail', queryset=adjudicators)
-        trainees = fields.TournamentHyperlinkedRelatedField(many=True, view_name='api-adjudicator-detail', queryset=adjudicators)
-
-        def save(self, **kwargs):
-            aa = kwargs['debate'].adjudicators
-            aa.chair = self.validated_data.get('chair')
-            aa.panellists = self.validated_data.get('panellists')
-            aa.trainees = self.validated_data.get('trainees')
-            aa.save()
-            return aa
-
     url = fields.RoundHyperlinkedIdentityField(view_name='api-pairing-detail', lookup_url_kwarg='debate_pk')
     venue = fields.TournamentHyperlinkedRelatedField(view_name='api-venue-detail', queryset=Venue.objects.all(),
         required=False, allow_null=True)
@@ -1285,3 +1286,33 @@ def update(self, instance, validated_data):
         instance.discarded = validated_data['discarded']
         instance.save()
         return instance
+
+
+class PreformedPanelSerializer(serializers.ModelSerializer):
+    url = fields.RoundHyperlinkedIdentityField(view_name='api-preformedpanel-detail', lookup_url_kwarg='debate_pk')
+    adjudicators = DebateAdjudicatorSerializer(required=False, allow_null=True)
+
+    class Meta:
+        model = PreformedPanel
+        exclude = ('round',)
+
+    def create(self, validated_data):
+        adjs_data = validated_data.pop('adjudicators', None)
+
+        validated_data['round'] = self.context['round']
+        debate = super().create(validated_data)
+
+        if adjs_data is not None:
+            adjudicators = self.DebateAdjudicatorSerializer()
+            adjudicators._validated_data = adjs_data
+            adjudicators.save(debate=debate)
+
+        return debate
+
+    def update(self, instance, validated_data):
+        if validated_data.get('adjudicators', None) is not None:
+            adjudicators = self.DebateAdjudicatorSerializer()
+            adjudicators._validated_data = validated_data.pop('adjudicators')
+            adjudicators.save(debate=instance)
+
+        return super().update(instance, validated_data)
diff --git a/tabbycat/api/urls.py b/tabbycat/api/urls.py
--- a/tabbycat/api/urls.py
+++ b/tabbycat/api/urls.py
@@ -92,6 +92,15 @@
                                 ])),
                             ])),
                         ])),
+
+                        path('/preformed-panels', include([
+                            path('',
+                                views.PreformedPanelViewSet.as_view({'get': 'list', 'post': 'create', 'delete': 'delete_all', 'put': 'add_blank'}),
+                                name='api-preformedpanel-list'),
+                            path('/<int:debate_pk>',
+                                views.PreformedPanelViewSet.as_view(detail_methods),
+                                name='api-preformedpanel-detail'),
+                        ])),
                     ])),
                 ])),
 
diff --git a/tabbycat/api/views.py b/tabbycat/api/views.py
--- a/tabbycat/api/views.py
+++ b/tabbycat/api/views.py
@@ -15,6 +15,8 @@
 from rest_framework.views import APIView
 from rest_framework.viewsets import GenericViewSet, ModelViewSet
 
+from adjallocation.models import PreformedPanel
+from adjallocation.preformed.anticipated import calculate_anticipated_draw
 from adjfeedback.models import AdjudicatorFeedbackQuestion
 from availability.models import RoundAvailability
 from breakqual.models import BreakCategory
@@ -656,3 +658,52 @@ def delete(self, request, *args, **kwargs):
         # Delete class of availabilities
         self.get_queryset().delete()
         return Response(status=204)
+
+
+class PreformedPanelViewSet(RoundAPIMixin, AdministratorAPIMixin, ModelViewSet):
+
+    serializer_class = serializers.PreformedPanelSerializer
+    lookup_url_kwarg = 'debate_pk'
+
+    @property
+    def debate(self):
+        if hasattr(self, '_debate'):
+            return self._debate
+
+        self._debate = get_object_or_404(PreformedPanel, pk=self.kwargs.get('debate_pk'))
+        return self._debate
+
+    def perform_create(self, serializer):
+        serializer.save(**{'debate': self.debate})
+
+    def lookup_kwargs(self):
+        kwargs = super().lookup_kwargs()
+        kwargs['debate'] = self.debate
+        return kwargs
+
+    def get_serializer_context(self):
+        context = super().get_serializer_context()
+        context['debate'] = self.debate
+        return context
+
+    def get_queryset(self):
+        return super().get_queryset().select_related('round', 'round__tournament').prefetch_related(
+            'debateteam_set', 'debateteam_set__team', 'debateteam_set__team__tournament',
+            'preformedpaneladjudicator_set', 'preformedpaneladjudicator_set__adjudicator',
+            'preformedpaneladjudicator_set__adjudicator__tournament',
+        )
+
+    def delete_all(self, request, *args, **kwargs):
+        self.get_queryset().delete()
+        return Response(status=204)  # No content
+
+    def add_blank(self, request, *args, **kwargs):
+        """Add blank preformed panel objects with calculated bracket and liveness for round."""
+        for i, (bracket_min, bracket_max, liveness) in enumerate(calculate_anticipated_draw(self.round), start=1):
+            PreformedPanel.objects.update_or_create(round=self.round, room_rank=i, defaults={
+                'bracket_max': bracket_max,
+                'bracket_min': bracket_min,
+                'liveness': liveness,
+            })
+
+        return self.get(request, *args, **kwargs)


Test patch: