Repository: TabbycatDebate/tabbycat

Issue:
E-mail entered ballots to adjudicators once entered
A check similar to #532.

For consensus formats, either e-mail to chair or to all voting members.
For voting formats, e-mail each adjudicator just their own ballot.


Discussion:
Might take this on too. The email message/subject would be a configuration setting. As some (English) defaults, I was thinking

* **Subject**: `Your ballot for %debate has been received`
* **Message**:
```
Hi %user,

Your ballot for %debate has been successfully received, with these scores:

%scores

If there are any problems, please contact the tab team.
```

A format of `%team - %debater: %score` is repeated until all the scores are present (for the adjudicator). That format will be localizable, but not customizable.

For consensus ballots, all the voting members should be emailed in my view. As such ballots do not provide a `speakerscoresbyadj`, it'll be a bit more complex.
Only one thing blocking me right now: The model for `results_speakerscorebyadj` is weird in that it "dances around" the speaker who got the score, only providing team and position. `results_speakerscore` does contain this information though, but I can't find a "Django" way to retrieve it. An SQL query for the purpose would be:
```
SELECT ... FROM results_speakerscorebyadj AS a INNER JOIN results_speakerscore AS b ON a.position = b.position AND a.debate_team_id = b.debate_team_id
```
Is there a neater way, should the debater field be added to the former table, or are we stuck with raw SQL?

Resolving patch:
diff --git a/tabbycat/options/preferences.py b/tabbycat/options/preferences.py
--- a/tabbycat/options/preferences.py
+++ b/tabbycat/options/preferences.py
@@ -833,6 +833,7 @@ class FeedbackReturnLocation(StringPreference):
     name = 'feedback_return_location'
     default = 'TBA'
 
+
 # ==============================================================================
 public_features = Section('public_features', verbose_name=_("Public Features"))
 # ==============================================================================
@@ -1244,7 +1245,7 @@ class DuplicateAdjs(BooleanPreference):
 
 @tournament_preferences_registry.register
 class AdjAllocationConfirmations(BooleanPreference):
-    help_text = 'Allow links to be sent to adjudicators that allow them to confirm shifts'
+    help_text = _("Allow links to be sent to adjudicators that allow them to confirm shifts")
     verbose_name = _("Adjudicator allocation confirmations")
     section = league_options
     name = 'allocation_confirmations'
@@ -1258,3 +1259,38 @@ class EnableCrossTournamentDrawPages(BooleanPreference):
     section = league_options
     name = 'enable_mass_draws'
     default = False
+
+
+# ==============================================================================
+email = Section('email', verbose_name=_("Email Sending"))
+# ==============================================================================
+
+
+@tournament_preferences_registry.register
+class EnableEmailBallotReceipts(BooleanPreference):
+    help_text = _("Enables judges' ballots to be sent to them by email after submission for confirmation")
+    verbose_name = _("Ballot receipts")
+    section = email
+    name = 'enable_ballot_receipts'
+    default = False
+
+
+@tournament_preferences_registry.register
+class BallotEmailSubjectLine(StringPreference):
+    help_text = _("The subject line for emails sent to adjudicators with their submitted ballot. "
+                  "Use '<DEBATE>' as a placeholder for the associated debate")
+    verbose_name = _("Ballot receipt subject line")
+    section = email
+    name = 'ballot_email_subject'
+    default = "Your ballot for <DEBATE> has been received"
+
+
+@tournament_preferences_registry.register
+class BallotEmailMessageBody(LongStringPreference):
+    help_text = _("The message body for emails sent to adjudicators with their submitted ballot. "
+                  "Use '<DEBATE>' as a placeholder for the associated debate, '<USER>' for the adjudicator, "
+                  "and '<SCORES>' for the ballot values.")
+    verbose_name = _("Ballot receipt message")
+    section = email
+    name = 'ballot_email_message'
+    default = "Hi <USER>,\n\nYour ballot for <DEBATE> has been successfully received, with these scores:\n\n<SCORES>\n\nIf there are any problems, please contact the tab team."
diff --git a/tabbycat/results/utils.py b/tabbycat/results/utils.py
--- a/tabbycat/results/utils.py
+++ b/tabbycat/results/utils.py
@@ -1,7 +1,10 @@
 import logging
 import datetime
 from itertools import combinations
+from smtplib import SMTPException
 
+from django.core.mail import send_mass_mail
+from django.conf import settings
 from django.db.models import Count
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
@@ -252,3 +255,40 @@ def side_and_position_names(tournament):
                 else _ORDINALS[pos]
                 for pos in tournament.positions]
             yield side, positions
+
+
+def send_ballot_receipt_emails_to_adjudicators(ballots, debate):
+
+    messages = []
+    scores = ''
+
+    round_name = _("%(tournament)s %(round)s @ %(room)s") % {'tournament': str(debate.round.tournament),
+                                                             'round': debate.round.name, 'room': debate.venue.name}
+    subject = debate.round.tournament.pref('ballot_email_subject').replace('<DEBATE>', round_name)
+    message = debate.round.tournament.pref('ballot_email_message').replace('<DEBATE>', round_name)
+
+    for ballot in ballots:
+        judge = ballot['adjudicator'] if 'adjudicator' in ballot else debate.debateadjudicator_set.get(type="C")
+
+        if judge.email is None:
+            continue
+
+        message_split = message.replace('<USER>', judge.name).split('<SCORES>')
+
+        for team in ballot['teams']:
+            scores += _("(%(side)s) %(team)s\n") % {'side': team['side'], 'team': team['team'].short_name}
+
+            for speaker in team['speakers']:
+                scores += _("- %(debater)s: %(score)s\n") % {'debater': speaker['speaker'], 'score': speaker['score']}
+
+        messages.append((subject, message_split[0] + scores + message_split[1], settings.DEFAULT_FROM_EMAIL, [judge.email]))
+        scores = ''
+
+    try:
+        send_mass_mail(messages, fail_silently=False)
+    except SMTPException:
+        logger.exception("Failed to send ballot receipt e-mails")
+        raise
+    except ConnectionError:
+        logger.exception("Connection error sending ballot receipt e-mails")
+        raise
diff --git a/tabbycat/results/views.py b/tabbycat/results/views.py
--- a/tabbycat/results/views.py
+++ b/tabbycat/results/views.py
@@ -1,5 +1,6 @@
 import datetime
 import logging
+from smtplib import SMTPException
 
 from django.contrib import messages
 from django.db import ProgrammingError
@@ -28,8 +29,9 @@
 from .forms import BPEliminationResultForm, PerAdjudicatorBallotSetForm, SingleBallotSetForm
 from .models import BallotSubmission, TeamScore
 from .tables import ResultsTableBuilder
+from .result import DebateResult
 from .prefetch import populate_confirmed_ballots
-from .utils import get_result_status_stats, populate_identical_ballotsub_lists
+from .utils import get_result_status_stats, populate_identical_ballotsub_lists, send_ballot_receipt_emails_to_adjudicators
 
 logger = logging.getLogger(__name__)
 
@@ -265,14 +267,28 @@ def add_success_message(self):
         # Default implementation does nothing.
         pass
 
+    def send_email_receipts(self):
+        # For proper error handling for admin/assistants, overwrite this
+        try:
+            send_ballot_receipt_emails_to_adjudicators(DebateResult(self.ballotsub).as_dicts(), self.debate)
+        except (SMTPException, ConnectionError):
+            return False
+        else:
+            return True
+
     def form_valid(self, form):
         self.ballotsub = form.save()
         if self.ballotsub.confirmed:
             self.ballotsub.confirmer = self.request.user
             self.ballotsub.confirm_timestamp = datetime.datetime.now()
             self.ballotsub.save()
+
+            if self.tournament.pref('enable_ballot_receipts'):
+                self.email_receipts_sent = self.send_email_receipts()
+
         self.add_success_message()
         self.round = self.ballotsub.debate.round  # for LogActionMixin
+
         return super().form_valid(form)
 
     def populate_objects(self):
@@ -295,6 +311,20 @@ def post(self, request, *args, **kwargs):
         return super().post(request, *args, **kwargs)
 
 
+class BallotEmailWithStatusMixin:
+    def send_email_receipts(self):
+        try:
+            send_ballot_receipt_emails_to_adjudicators(DebateResult(self.ballotsub).as_dicts(), self.debate)
+        except SMTPException:
+            messages.error(self.request, _("There was a problem sending ballot receipts to adjudicators."))
+            return False
+        except ConnectionError:
+            messages.error(self.request, _("There was a problem connecting to the e-mail server when trying to send ballot receipts to adjudicators."))
+            return False
+        else:
+            return True
+
+
 class AdministratorBallotSetMixin(AdministratorMixin):
     template_name = 'enter_results.html'
     tabroom = True
@@ -311,7 +341,7 @@ def get_success_url(self):
         return reverse_tournament('results-assistant-round-list', self.tournament)
 
 
-class BaseNewBallotSetView(SingleObjectFromTournamentMixin, BaseBallotSetView):
+class BaseNewBallotSetView(SingleObjectFromTournamentMixin, BallotEmailWithStatusMixin, BaseBallotSetView):
 
     model = Debate
     tournament_field_name = 'round__tournament'
@@ -320,7 +350,10 @@ class BaseNewBallotSetView(SingleObjectFromTournamentMixin, BaseBallotSetView):
     pk_url_kwarg = 'debate_id'
 
     def add_success_message(self):
-        messages.success(self.request, _("Ballot set for %(debate)s added.") % {'debate': self.debate.matchup})
+        message = _("Ballot set for %(debate)s added.") % {'debate': self.debate.matchup}
+        if getattr(self, 'email_receipts_sent', False):
+            message += _(" Email receipts sent.")
+        messages.success(self.request, message)
 
     def get_error_url(self):
         return self.get_success_url()
@@ -352,7 +385,7 @@ class AssistantNewBallotSetView(AssistantBallotSetMixin, BaseNewBallotSetView):
     pass
 
 
-class BaseEditBallotSetView(SingleObjectFromTournamentMixin, BaseBallotSetView):
+class BaseEditBallotSetView(SingleObjectFromTournamentMixin, BallotEmailWithStatusMixin, BaseBallotSetView):
 
     model = BallotSubmission
     tournament_field_name = 'debate__round__tournament'
@@ -376,6 +409,10 @@ def add_success_message(self):
             message = _("Ballot set for %(matchup)s confirmed.")
         else:
             message = _("Edits to ballot set for %(matchup)s saved.")
+
+        if getattr(self, 'email_receipts_sent', False):
+            message += _(" Email receipts sent.")
+
         messages.success(self.request, message % {'matchup': self.debate.matchup})
 
     def populate_objects(self):


Test patch: