TabbycatDebate/tabbycat

View on GitHub
tabbycat/draw/consumers.py

Summary

Maintainability
A
35 mins
Test Coverage
import logging

from asgiref.sync import async_to_sync
from channels.consumer import SyncConsumer
from channels.generic.websocket import JsonWebsocketConsumer
from channels.layers import get_channel_layer

from actionlog.models import ActionLogEntry
from adjallocation.serializers import SimpleDebateAllocationSerializer, SimpleDebateImportanceSerializer
from tournaments.mixins import RoundWebsocketMixin
from utils.mixins import SuperuserRequiredWebsocketMixin
from venues.serializers import SimpleDebateVenueSerializer

from .models import Debate, DebateTeam
from .serializers import EditDebateTeamsDebateSerializer, SimpleDebateSideStatusSerializer

logger = logging.getLogger(__name__)


class BaseAdjudicatorContainerConsumer(SuperuserRequiredWebsocketMixin, RoundWebsocketMixin, JsonWebsocketConsumer):
    """For receiving updates to either debates or preformed panels; making the
    supplied modifications; and re-broadcasting them. The intent is that the
    socket provides a dict of objects, which in turn have a dict of attributes
    that can be updated directly and the original object returned. This avoids
    having to serialise/re-serialise objects that creates many more queries"""

    def receive_json(self, content):
        """ Select the appropriate method given the indicated attribute in JSON
        i.e. from { "importance": { "73" : "1" }, "componentID": 2885 } """
        for key in content.keys():
            if key == 'action':
                self.receive_action(content['action'], content['settings'], self.scope["user"])
            elif key == 'importance':
                self.receive_importance(content)
            elif key == 'adjudicators':
                self.receive_adjudicators(content)

    def receive_action(self, action_function, action_settings, user):
        # TODO: Make this selection mechanism more robust
        worker = "venues" if action_function == "allocate_debate_venues" else "adjallocation"
        async_to_sync(get_channel_layer().send)(worker, {
            "type": action_function, # Corresponds to the function
            "extra": {'user_id': user.id, 'round_id': self.round.id,
                      'tournament_id': self.tournament.id,
                      'settings': action_settings,
                      'group_name': self.group_name()},
        })

    def get_debates_or_panels(self, debates_or_panels):
        """ Retrieve either the debates or panels from the JSON id keys """
        ids = [id for (id, d_or_p) in debates_or_panels.items()]
        debates_or_panels = list(self.model.objects.filter(id__in=ids))
        # TODO: error handling if return items fewer/more than expected
        return debates_or_panels

    def receive_importance(self, content):
        """ Update importances on the django data then reserialize/return it """
        changes = {int(c['id']): c for c in content['importance']}
        debates_or_panels = self.get_debates_or_panels(changes)
        for d_or_p in debates_or_panels:
            d_or_p.importance = changes[d_or_p.id]['importance']
            d_or_p.save()

        serialized = self.importance_serializer(debates_or_panels, many=True)
        content_to_return = content.copy()
        del content_to_return['importance'] # Reserialise as debatesOrPanels
        self.return_attributes(content_to_return, serialized)

    def delete_adjudicators(self, debate_or_panel, adj_ids):
        return debate_or_panel.related_adjudicator_set.exclude(
            adjudicator_id__in=adj_ids).delete()

    def create_adjudicators(self, debate_or_panel, adj_id, adj_type):
        return debate_or_panel.related_adjudicator_set.update_or_create(
            adjudicator_id=adj_id, defaults={'type': adj_type})

    def receive_adjudicators(self, content):
        """ Update adjudicators on the django data then reserialize/return it """
        changes = {int(c['id']): c for c in content['adjudicators']}
        # TODO: prefetch the adjudicator allocation objects
        debates_or_panels = self.get_debates_or_panels(changes)
        for d_or_p in debates_or_panels:
            sent_allocation = changes[d_or_p.id]['adjudicators']
            sent_allocation_ids = []
            for (position, position_ids) in sent_allocation.items():
                sent_allocation_ids.extend(adj_id for adj_id in position_ids)

            # Delete adjudicators in the posted information
            self.delete_adjudicators(d_or_p, sent_allocation_ids)
            # Re-create positions of adjudicators in debate
            for (position, position_ids) in sent_allocation.items():
                for adjudicator_id in position_ids:
                    self.create_adjudicators(d_or_p, adjudicator_id, position)

        # Re-fetch the modified data
        # TODO: is it necessary to re-fetch? Or should the objects be returned?
        debates_or_panels = self.get_debates_or_panels(changes)
        serialized = self.adjudicators_serializer(debates_or_panels, many=True)
        content_to_return = content.copy()
        del content_to_return['adjudicators']
        self.return_attributes(content_to_return, serialized)

    def return_attributes(self, original_content, serialized_content):
        """ Return the original JSON but with the generic debatesOrPanels key """
        original_content['debatesOrPanels'] = serialized_content.data
        async_to_sync(get_channel_layer().group_send)(
            self.group_name(), {
                'type': 'broadcast_debates_or_panels',
                'content': original_content,
            },
        )

    def broadcast_debates_or_panels(self, event):
        self.send_json(event['content'])


class DebateEditConsumer(BaseAdjudicatorContainerConsumer):
    group_prefix = 'debates'
    model = Debate
    importance_serializer = SimpleDebateImportanceSerializer
    sides_status_serializer = SimpleDebateSideStatusSerializer
    adjudicators_serializer = SimpleDebateAllocationSerializer
    venues_serializer = SimpleDebateVenueSerializer
    teams_serializer = EditDebateTeamsDebateSerializer

    def receive_json(self, content):
        for key in content.keys():
            if key == 'sides_confirmed':
                self.receive_debate_change(content, key, 'sides_confirmed', 'sides_confirmed', self.sides_status_serializer)
            elif key == 'venues':
                self.receive_debate_change(content, key, 'venue', 'venue_id', self.venues_serializer)
            elif key == 'teams':
                self.receive_teams(content)

        return super().receive_json(content)

    def modify_debate_teams(self, debate, sent_teams):
        if set(sent_teams.keys()) != set(self.tournament.sides):
            # TODO: raise error; "Sides in JSON object weren't correct"
            print("Sides in JSON object weren't correct")

        # Delete existing entries that won't be wanted (there shouldn't be any, but just in case)
        delete_count, deleted = debate.debateteam_set.exclude(side__in=self.tournament.sides).delete()
        logger.debug("Deleted %d debate teams from [%s]", deleted.get('draw.DebateTeam', 0), debate.matchup)

        # if len(teams) != len(posted_debateteams):
        #     # TODO: raise error
        #     # e.g. "Not all teams specified are associated with the tournament"
        #     pass

        # Update other DebateTeam objects
        for side, team_id in sent_teams.items():
            if team_id is None:
                DebateTeam.objects.filter(debate=debate, side=side).delete()
                logger.debug("position %s in [%s] is now vacant",
                             side, debate.matchup)
            else:
                obj, created = DebateTeam.objects.update_or_create(
                    debate=debate, side=side, defaults={'team_id': team_id})
                logger.debug("%s debate team: %s in [%s] is now %s",
                             "Created" if created else "Updated",
                             side, debate.matchup, team_id)

        debate._populate_teams()

    def receive_teams(self, content):
        changes = {int(c['id']): c for c in content['teams']}
        debates = self.get_debates_or_panels(changes)
        for debate in debates:
            sent_teams = changes[debate.id]['teams']
            self.modify_debate_teams(debate, sent_teams)

        debates = self.get_debates_or_panels(changes)
        serialized = self.teams_serializer(debates, many=True,
            context={'sides': self.tournament.sides})
        content_to_return = content.copy()
        del content_to_return['teams']
        self.return_attributes(content_to_return, serialized)

    def receive_debate_change(self, content, key, content_name, field_name, serializer):
        changes = {int(c['id']): c for c in content[key]}
        debates = self.get_debates_or_panels(changes)
        for debate in debates:
            setattr(debate, field_name, changes[debate.id][content_name])
            debate.save()

        debates = self.get_debates_or_panels(changes)
        serialized = serializer(debates, many=True)
        content_to_return = content.copy()
        del content_to_return[key]
        self.return_attributes(content_to_return, serialized)


class EditDebateOrPanelWorkerMixin(SyncConsumer):
    """ Mixin for consumers that are run by synchronous workers that perform
    actions to edit and re-serialise debates/panels """

    def log_action(self, extra, round, type):
        ActionLogEntry.objects.log(type=type, user_id=extra['user_id'],
                round=round, tournament=round.tournament, content_object=round)

    def reserialize_panels(self, serialiser, round, panels=None):
        if not panels:
            panels = round.preformedpanel_set.all() # TODO: prefetch

        serialized_panels = serialiser(panels, many=True)
        return serialized_panels

    def reserialize_debates(self, serialiser, round, debates=None):
        if not debates:
            debates = round.debate_set.all() # TODO: prefetch
        serialized_debates = serialiser(debates, many=True)
        return serialized_debates

    def return_error(self, group_name, error_text):
        """ Because the worker can't do proper returns we can't really catch
        exceptions across each function; provide a manual handler instead. """
        logger.warning(error_text)
        content = {'message': {'text': error_text, 'type': 'danger'}}
        async_to_sync(get_channel_layer().group_send)(
            group_name, {
                'type': 'broadcast_debates_or_panels',
                'content': content,
            },
        )

    def return_response(self, serialized_debates_or_panels, group_name,
                        message_text, message_type):
        content = {
            'debatesOrPanels': serialized_debates_or_panels.data,
            'message': {'text': message_text, 'type': message_type},
        }
        async_to_sync(get_channel_layer().group_send)(
            group_name, {
                'type': 'broadcast_debates_or_panels',
                'content': content,
            },
        )