We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Reply-to address warning linking to incorrect URL
Bug report on: 2.3.0A

The "not specified reply-to" warning on the Email Participant pages links to an incorrect URL for the notification preferences returning a 404 error.

Linked to: https://asd-2019-jo.herokuapp.com/jo2019central/admin/options/notifications/
Correct: https://asd-2019-jo.herokuapp.com/jo2019central/admin/options/email/

![image](https://user-images.githubusercontent.com/3196468/54529061-b4215d00-49e3-11e9-892b-d3ba81667f17.png)

![image](https://user-images.githubusercontent.com/3196468/54529110-cdc2a480-49e3-11e9-95c3-b559559a74f8.png)

UI Request: Add way to select/deselect all participants for the "email participants page"
UI feature request for 2.3.0A

The "Email Participants" page should have a way to select/deselect all participants from the page.

A screenshot has been included below of the Email Participants Adjudicator Assignments page. 

![image](https://user-images.githubusercontent.com/3196468/54528755-b931dc80-49e2-11e9-96d2-27520da6fe85.png)

@tienne-B thanks a lot for the new notification features, they make using emails tonnes easier and we are making good use of them already. Apologies for the stream of issues following this one. 

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `tabbycat/notifications/views.py`
Content:
```
1 from datetime import datetime
2 import json
3 from smtplib import SMTPException
4 
5 from asgiref.sync import async_to_sync
6 from channels.layers import get_channel_layer
7 from django.contrib import messages
8 from django.db.models import Prefetch, Q
9 from django.http import HttpResponse
10 from django.urls import reverse_lazy
11 from django.utils import timezone
12 from django.utils.translation import gettext as _, gettext_lazy, ngettext
13 from django.views.generic.base import View
14 from django.views.generic.edit import FormView
15 
16 from participants.models import Person
17 from tournaments.mixins import RoundMixin, TournamentMixin
18 from utils.misc import reverse_tournament
19 from utils.mixins import AdministratorMixin
20 from utils.tables import TabbycatTableBuilder
21 from utils.views import VueTableTemplateView
22 
23 from .forms import BasicEmailForm, TestEmailForm
24 from .models import EmailStatus, SentMessageRecord
25 
26 
27 class TestEmailView(AdministratorMixin, FormView):
28     form_class = TestEmailForm
29     template_name = 'test_email.html'
30     success_url = reverse_lazy('notifications-test-email')
31     view_role = ""
32 
33     def form_valid(self, form):
34         host = self.request.get_host()
35         try:
36             recipient = form.send_email(host)
37         except (ConnectionError, SMTPException) as e:
38             messages.error(self.request,
39                 _("There was an error sending the test email: %(error)s") % {'error': str(e)})
40         else:
41             messages.success(self.request,
42                 _("A test email has been sent to %(recipient)s.") % {'recipient': recipient})
43         return super().form_valid(form)
44 
45 
46 class EmailStatusView(AdministratorMixin, TournamentMixin, VueTableTemplateView):
47     page_title = gettext_lazy("Email Statuses")
48     page_emoji = '📤'
49     template_name = 'email_statuses.html'
50 
51     tables_orientation = 'rows'
52 
53     def _create_status_timeline(self, status):
54         statuses = []
55         for s in status:
56             text = _("%(status)s @ %(time)s") % {'status': s.get_event_display(), 'time': s.timestamp}
57             statuses.append({
58                 'text': '<span class="%s">%s</span>' % (self._get_event_class(s.event), text)
59             })
60         return statuses
61 
62     def _get_event_class(self, event):
63         return {
64             EmailStatus.EVENT_TYPE_BOUNCED: 'text-warning',
65             EmailStatus.EVENT_TYPE_DROPPED: 'text-warning',
66             EmailStatus.EVENT_TYPE_SPAM: 'text-warning',
67             EmailStatus.EVENT_TYPE_DEFERRED: 'text-warning',
68             EmailStatus.EVENT_TYPE_PROCESSED: 'text-info',
69             EmailStatus.EVENT_TYPE_DELIVERED: 'text-info',
70             EmailStatus.EVENT_TYPE_OPENED: 'text-success',
71             EmailStatus.EVENT_TYPE_CLICKED: 'text-success',
72             EmailStatus.EVENT_TYPE_UNSUBSCRIBED: None,
73             EmailStatus.EVENT_TYPE_ASM_UNSUBSCRIBED: None,
74             EmailStatus.EVENT_TYPE_ASM_RESUBSCRIBED: None
75         }[event]
76 
77     def get_tables(self):
78         tables = []
79         notifications = self.tournament.bulknotification_set.select_related('round').prefetch_related(
80             Prefetch('sentmessagerecord_set', queryset=SentMessageRecord.objects.select_related('recipient').prefetch_related('emailstatus_set')))
81 
82         for n in notifications:
83             emails = n.sentmessagerecord_set.all()
84 
85             subtitle = n.round.name if n.round is not None else _("@ %s") % timezone.localtime(n.timestamp).strftime("%a, %d %b %Y %H:%M:%S")
86             table = TabbycatTableBuilder(view=self, title=n.get_event_display().title(), subtitle=subtitle)
87 
88             # Create arrays for columns
89             emails_status = []
90             emails_time = []
91             for e in emails:
92                 status = e.emailstatus_set.all()
93                 if status.count() == 0:
94                     na_email = {'text': _("N/A"), 'class': 'text-muted'}
95                     emails_status.append(na_email)
96                     emails_time.append(na_email)
97                     continue
98 
99                 first_status = status.first()
100                 status_cell = {
101                     "text": first_status.get_event_display(),
102                     "class": self._get_event_class(first_status.event),
103                     "popover": {"title": _("Timeline"), "content": self._create_status_timeline(status)}
104                 }
105                 emails_status.append(status_cell)
106                 emails_time.append(first_status.timestamp)
107 
108             table.add_column({'key': 'name', 'tooltip': _("Participant"), 'icon': 'user'}, [e.recipient.name for e in emails])
109             table.add_column({'key': 'name', 'title': _("Status")}, emails_status)
110             table.add_column({'key': 'name', 'title': _("Time")}, emails_time)
111 
112             tables.append(table)
113 
114         return tables
115 
116 
117 class EmailEventWebhookView(TournamentMixin, View):
118 
119     def post(self, request, *args, **kwargs):
120         if kwargs['key'] is not self.tournament.pref('email_hook_key'):
121             return HttpResponse(status=404) # 404: Not Found
122 
123         data = json.loads(request.body)
124 
125         records = SentMessageRecord.objects.filter(message_id__in=[obj['smtp-id'] for obj in data])
126         record_lookup = {smr.message_id: smr.id for smr in records}
127         statuses = []
128 
129         for obj in data:
130             dt = datetime.fromtimestamp(obj['timestamp'])
131             timestamp = timezone.make_aware(dt, timezone.utc)
132             email_id = record_lookup.get(obj['smtp-id'], None)
133             if email_id is None:
134                 continue
135             statuses.append(EmailStatus(email_id=email_id, timestamp=timestamp, event=obj['event'], data=obj))
136 
137         EmailStatus.objects.bulk_create(statuses)
138 
139         return HttpResponse(status=201) # 201: Created
140 
141 
142 class BaseSelectPeopleEmailView(AdministratorMixin, TournamentMixin, VueTableTemplateView, FormView):
143     template_name = "email_participants.html"
144     page_title = gettext_lazy("Email Participants")
145     page_emoji = '📤'
146 
147     form_class = BasicEmailForm
148 
149     def get_context_data(self, **kwargs):
150         context = super().get_context_data(**kwargs)
151         context['sg_webhook'] = EmailStatus.objects.filter(email__notification__tournament=self.tournament).exists()
152         return context
153 
154     def get_default_send_queryset(self):
155         return self.get_queryset().filter(email__isnull=False).exclude(email__exact="")
156 
157     def get_queryset(self):
158         """All the people from the tournament who could receive the message"""
159         queryset_filter = Q(speaker__team__tournament=self.tournament) | Q(adjudicator__tournament=self.tournament)
160         if self.tournament.pref('share_adjs'):
161             queryset_filter |= Q(adjudicator__tournament__isnull=True)
162 
163         return Person.objects.filter(queryset_filter).select_related('speaker', 'adjudicator')
164 
165     def default_send(self, p, default_send_queryset=None):
166         """Whether the person should be emailed by default"""
167         return p in default_send_queryset
168 
169     def get_table(self, mixed_participants=False):
170         table = TabbycatTableBuilder(view=self, sort_key='name')
171 
172         queryset = self.get_queryset()
173         default_send_queryset = self.get_default_send_queryset()
174 
175         table.add_column({'key': 'send', 'title': _("Send Email")}, [{
176             'component': 'check-cell',
177             'checked': self.default_send(p, default_send_queryset),
178             'id': p.id,
179             'name': 'recipients',
180             'value': p.id,
181             'noSave': True,
182             'type': 'adj' if mixed_participants and hasattr(p, 'adjudicator') else 'spk'
183         } for p in queryset])
184 
185         table.add_column({'key': 'name', 'tooltip': _("Participant"), 'icon': 'user'}, [{
186             'text': p.name,
187             'class': 'no-wrap' if len(p.name) < 20 else ''
188         } for p in queryset])
189 
190         table.add_column({'key': 'email', 'tooltip': _("Email Address"), 'icon': 'mail'}, [{
191             'text': p.email if p.email else _("Not Provided"),
192             'class': 'small' if p.email else 'small text-warning'
193         } for p in queryset])
194 
195         return table
196 
197 
198 class RoleColumnMixin:
199     """Mixin to have a column Adjudicator/Speaker for email"""
200 
201     def get_table(self, mixed_participants=True):
202         table = super().get_table(mixed_participants)
203 
204         table.add_column({'key': 'role', 'title': _("Role")}, [{
205             'text': _("Adjudicator") if hasattr(p, 'adjudicator') else _("Speaker")
206         } for p in self.get_queryset()])
207 
208         return table
209 
210     def get_context_data(self, **kwargs):
211         context = super().get_context_data(**kwargs)
212         context['categories'] = [
213             {'id': 'spk', 'name': _("Speakers")},
214             {'id': 'adj', 'name': _("Adjudicators")}
215         ]
216         return context
217 
218 
219 class CustomEmailCreateView(RoleColumnMixin, BaseSelectPeopleEmailView):
220 
221     def get_success_url(self):
222         return reverse_tournament('notifications-email', self.tournament)
223 
224     def default_send(self, p, default_send_queryset):
225         return False
226 
227     def post(self, request, *args, **kwargs):
228         people = Person.objects.filter(id__in=list(map(int, request.POST.getlist('recipients'))))
229 
230         async_to_sync(get_channel_layer().send)("notifications", {
231             "type": "email_custom",
232             "subject": request.POST['subject_line'],
233             "body": request.POST['message_body'],
234             "tournament": self.tournament.id,
235             "send_to": [(p.id, p.email) for p in people]
236         })
237 
238         messages.success(request, ngettext(
239             "%(count)s email has been queued for sending.",
240             "%(count)s emails have been queued for sending.",
241             len(people)
242         ) % {'count': len(people)})
243         return super().post(request, *args, **kwargs)
244 
245 
246 class TemplateEmailCreateView(BaseSelectPeopleEmailView):
247 
248     def get_initial(self):
249         initial = super().get_initial()
250         initial['subject_line'] = self.tournament.pref(self.subject_template)
251         initial['message_body'] = self.tournament.pref(self.message_template)
252 
253         return initial
254 
255     def post(self, request, *args, **kwargs):
256         self.tournament.preferences[self.subject_template] = request.POST['subject_line']
257         self.tournament.preferences[self.message_template] = request.POST['message_body']
258         email_recipients = list(map(int, request.POST.getlist('recipients')))
259 
260         async_to_sync(get_channel_layer().send)("notifications", {
261             "type": "email",
262             "message": self.event,
263             "extra": self.get_extra(),
264             "send_to": email_recipients,
265             "subject": request.POST['subject_line'],
266             "body": request.POST['message_body']
267         })
268 
269         messages.success(request, ngettext(
270             "%(count)s email has been queued for sending.",
271             "%(count)s emails have been queued for sending.",
272             len(email_recipients)
273         ) % {'count': len(email_recipients)})
274         return super().post(request, *args, **kwargs)
275 
276 
277 class TournamentTemplateEmailCreateView(TemplateEmailCreateView):
278 
279     def get_default_send_queryset(self):
280         return super().get_default_send_queryset().exclude(
281             sentmessagerecord__notification__event=self.event, sentmessagerecord__notification__tournament=self.tournament)
282 
283     def get_extra(self):
284         extra = {'tournament_id': self.tournament.id}
285         return extra
286 
287 
288 class RoundTemplateEmailCreateView(TemplateEmailCreateView, RoundMixin):
289 
290     def get_default_send_queryset(self):
291         return super().get_default_send_queryset().exclude(
292             sentmessagerecord__notification__event=self.event, sentmessagerecord__notification__round=self.round)
293 
294     def get_extra(self):
295         extra = {'round_id': self.round.id}
296         return extra
297
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/tabbycat/notifications/views.py b/tabbycat/notifications/views.py
--- a/tabbycat/notifications/views.py
+++ b/tabbycat/notifications/views.py
@@ -149,6 +149,8 @@
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context['sg_webhook'] = EmailStatus.objects.filter(email__notification__tournament=self.tournament).exists()
+
+        context['categories'] = [{'id': 'spk', 'name': "Email"}]
         return context
 
     def get_default_send_queryset(self):

{"golden_diff": "diff --git a/tabbycat/notifications/views.py b/tabbycat/notifications/views.py\n--- a/tabbycat/notifications/views.py\n+++ b/tabbycat/notifications/views.py\n@@ -149,6 +149,8 @@\n     def get_context_data(self, **kwargs):\n         context = super().get_context_data(**kwargs)\n         context['sg_webhook'] = EmailStatus.objects.filter(email__notification__tournament=self.tournament).exists()\n+\n+        context['categories'] = [{'id': 'spk', 'name': \"Email\"}]\n         return context\n \n     def get_default_send_queryset(self):\n", "issue": "Reply-to address warning linking to incorrect URL\nBug report on: 2.3.0A\r\n\r\nThe \"not specified reply-to\" warning on the Email Participant pages links to an incorrect URL for the notification preferences returning a 404 error.\r\n\r\nLinked to: https://asd-2019-jo.herokuapp.com/jo2019central/admin/options/notifications/\r\nCorrect: https://asd-2019-jo.herokuapp.com/jo2019central/admin/options/email/\r\n\r\n![image](https://user-images.githubusercontent.com/3196468/54529061-b4215d00-49e3-11e9-892b-d3ba81667f17.png)\r\n\r\n![image](https://user-images.githubusercontent.com/3196468/54529110-cdc2a480-49e3-11e9-95c3-b559559a74f8.png)\r\n\nUI Request: Add way to select/deselect all participants for the \"email participants page\"\nUI feature request for 2.3.0A\r\n\r\nThe \"Email Participants\" page should have a way to select/deselect all participants from the page.\r\n\r\nA screenshot has been included below of the Email Participants Adjudicator Assignments page. \r\n\r\n![image](https://user-images.githubusercontent.com/3196468/54528755-b931dc80-49e2-11e9-96d2-27520da6fe85.png)\r\n\r\n@tienne-B thanks a lot for the new notification features, they make using emails tonnes easier and we are making good use of them already. Apologies for the stream of issues following this one. \n", "before_files": [{"content": "from datetime import datetime\nimport json\nfrom smtplib import SMTPException\n\nfrom asgiref.sync import async_to_sync\nfrom channels.layers import get_channel_layer\nfrom django.contrib import messages\nfrom django.db.models import Prefetch, Q\nfrom django.http import HttpResponse\nfrom django.urls import reverse_lazy\nfrom django.utils import timezone\nfrom django.utils.translation import gettext as _, gettext_lazy, ngettext\nfrom django.views.generic.base import View\nfrom django.views.generic.edit import FormView\n\nfrom participants.models import Person\nfrom tournaments.mixins import RoundMixin, TournamentMixin\nfrom utils.misc import reverse_tournament\nfrom utils.mixins import AdministratorMixin\nfrom utils.tables import TabbycatTableBuilder\nfrom utils.views import VueTableTemplateView\n\nfrom .forms import BasicEmailForm, TestEmailForm\nfrom .models import EmailStatus, SentMessageRecord\n\n\nclass TestEmailView(AdministratorMixin, FormView):\n    form_class = TestEmailForm\n    template_name = 'test_email.html'\n    success_url = reverse_lazy('notifications-test-email')\n    view_role = \"\"\n\n    def form_valid(self, form):\n        host = self.request.get_host()\n        try:\n            recipient = form.send_email(host)\n        except (ConnectionError, SMTPException) as e:\n            messages.error(self.request,\n                _(\"There was an error sending the test email: %(error)s\") % {'error': str(e)})\n        else:\n            messages.success(self.request,\n                _(\"A test email has been sent to %(recipient)s.\") % {'recipient': recipient})\n        return super().form_valid(form)\n\n\nclass EmailStatusView(AdministratorMixin, TournamentMixin, VueTableTemplateView):\n    page_title = gettext_lazy(\"Email Statuses\")\n    page_emoji = '\ud83d\udce4'\n    template_name = 'email_statuses.html'\n\n    tables_orientation = 'rows'\n\n    def _create_status_timeline(self, status):\n        statuses = []\n        for s in status:\n            text = _(\"%(status)s @ %(time)s\") % {'status': s.get_event_display(), 'time': s.timestamp}\n            statuses.append({\n                'text': '<span class=\"%s\">%s</span>' % (self._get_event_class(s.event), text)\n            })\n        return statuses\n\n    def _get_event_class(self, event):\n        return {\n            EmailStatus.EVENT_TYPE_BOUNCED: 'text-warning',\n            EmailStatus.EVENT_TYPE_DROPPED: 'text-warning',\n            EmailStatus.EVENT_TYPE_SPAM: 'text-warning',\n            EmailStatus.EVENT_TYPE_DEFERRED: 'text-warning',\n            EmailStatus.EVENT_TYPE_PROCESSED: 'text-info',\n            EmailStatus.EVENT_TYPE_DELIVERED: 'text-info',\n            EmailStatus.EVENT_TYPE_OPENED: 'text-success',\n            EmailStatus.EVENT_TYPE_CLICKED: 'text-success',\n            EmailStatus.EVENT_TYPE_UNSUBSCRIBED: None,\n            EmailStatus.EVENT_TYPE_ASM_UNSUBSCRIBED: None,\n            EmailStatus.EVENT_TYPE_ASM_RESUBSCRIBED: None\n        }[event]\n\n    def get_tables(self):\n        tables = []\n        notifications = self.tournament.bulknotification_set.select_related('round').prefetch_related(\n            Prefetch('sentmessagerecord_set', queryset=SentMessageRecord.objects.select_related('recipient').prefetch_related('emailstatus_set')))\n\n        for n in notifications:\n            emails = n.sentmessagerecord_set.all()\n\n            subtitle = n.round.name if n.round is not None else _(\"@ %s\") % timezone.localtime(n.timestamp).strftime(\"%a, %d %b %Y %H:%M:%S\")\n            table = TabbycatTableBuilder(view=self, title=n.get_event_display().title(), subtitle=subtitle)\n\n            # Create arrays for columns\n            emails_status = []\n            emails_time = []\n            for e in emails:\n                status = e.emailstatus_set.all()\n                if status.count() == 0:\n                    na_email = {'text': _(\"N/A\"), 'class': 'text-muted'}\n                    emails_status.append(na_email)\n                    emails_time.append(na_email)\n                    continue\n\n                first_status = status.first()\n                status_cell = {\n                    \"text\": first_status.get_event_display(),\n                    \"class\": self._get_event_class(first_status.event),\n                    \"popover\": {\"title\": _(\"Timeline\"), \"content\": self._create_status_timeline(status)}\n                }\n                emails_status.append(status_cell)\n                emails_time.append(first_status.timestamp)\n\n            table.add_column({'key': 'name', 'tooltip': _(\"Participant\"), 'icon': 'user'}, [e.recipient.name for e in emails])\n            table.add_column({'key': 'name', 'title': _(\"Status\")}, emails_status)\n            table.add_column({'key': 'name', 'title': _(\"Time\")}, emails_time)\n\n            tables.append(table)\n\n        return tables\n\n\nclass EmailEventWebhookView(TournamentMixin, View):\n\n    def post(self, request, *args, **kwargs):\n        if kwargs['key'] is not self.tournament.pref('email_hook_key'):\n            return HttpResponse(status=404) # 404: Not Found\n\n        data = json.loads(request.body)\n\n        records = SentMessageRecord.objects.filter(message_id__in=[obj['smtp-id'] for obj in data])\n        record_lookup = {smr.message_id: smr.id for smr in records}\n        statuses = []\n\n        for obj in data:\n            dt = datetime.fromtimestamp(obj['timestamp'])\n            timestamp = timezone.make_aware(dt, timezone.utc)\n            email_id = record_lookup.get(obj['smtp-id'], None)\n            if email_id is None:\n                continue\n            statuses.append(EmailStatus(email_id=email_id, timestamp=timestamp, event=obj['event'], data=obj))\n\n        EmailStatus.objects.bulk_create(statuses)\n\n        return HttpResponse(status=201) # 201: Created\n\n\nclass BaseSelectPeopleEmailView(AdministratorMixin, TournamentMixin, VueTableTemplateView, FormView):\n    template_name = \"email_participants.html\"\n    page_title = gettext_lazy(\"Email Participants\")\n    page_emoji = '\ud83d\udce4'\n\n    form_class = BasicEmailForm\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context['sg_webhook'] = EmailStatus.objects.filter(email__notification__tournament=self.tournament).exists()\n        return context\n\n    def get_default_send_queryset(self):\n        return self.get_queryset().filter(email__isnull=False).exclude(email__exact=\"\")\n\n    def get_queryset(self):\n        \"\"\"All the people from the tournament who could receive the message\"\"\"\n        queryset_filter = Q(speaker__team__tournament=self.tournament) | Q(adjudicator__tournament=self.tournament)\n        if self.tournament.pref('share_adjs'):\n            queryset_filter |= Q(adjudicator__tournament__isnull=True)\n\n        return Person.objects.filter(queryset_filter).select_related('speaker', 'adjudicator')\n\n    def default_send(self, p, default_send_queryset=None):\n        \"\"\"Whether the person should be emailed by default\"\"\"\n        return p in default_send_queryset\n\n    def get_table(self, mixed_participants=False):\n        table = TabbycatTableBuilder(view=self, sort_key='name')\n\n        queryset = self.get_queryset()\n        default_send_queryset = self.get_default_send_queryset()\n\n        table.add_column({'key': 'send', 'title': _(\"Send Email\")}, [{\n            'component': 'check-cell',\n            'checked': self.default_send(p, default_send_queryset),\n            'id': p.id,\n            'name': 'recipients',\n            'value': p.id,\n            'noSave': True,\n            'type': 'adj' if mixed_participants and hasattr(p, 'adjudicator') else 'spk'\n        } for p in queryset])\n\n        table.add_column({'key': 'name', 'tooltip': _(\"Participant\"), 'icon': 'user'}, [{\n            'text': p.name,\n            'class': 'no-wrap' if len(p.name) < 20 else ''\n        } for p in queryset])\n\n        table.add_column({'key': 'email', 'tooltip': _(\"Email Address\"), 'icon': 'mail'}, [{\n            'text': p.email if p.email else _(\"Not Provided\"),\n            'class': 'small' if p.email else 'small text-warning'\n        } for p in queryset])\n\n        return table\n\n\nclass RoleColumnMixin:\n    \"\"\"Mixin to have a column Adjudicator/Speaker for email\"\"\"\n\n    def get_table(self, mixed_participants=True):\n        table = super().get_table(mixed_participants)\n\n        table.add_column({'key': 'role', 'title': _(\"Role\")}, [{\n            'text': _(\"Adjudicator\") if hasattr(p, 'adjudicator') else _(\"Speaker\")\n        } for p in self.get_queryset()])\n\n        return table\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context['categories'] = [\n            {'id': 'spk', 'name': _(\"Speakers\")},\n            {'id': 'adj', 'name': _(\"Adjudicators\")}\n        ]\n        return context\n\n\nclass CustomEmailCreateView(RoleColumnMixin, BaseSelectPeopleEmailView):\n\n    def get_success_url(self):\n        return reverse_tournament('notifications-email', self.tournament)\n\n    def default_send(self, p, default_send_queryset):\n        return False\n\n    def post(self, request, *args, **kwargs):\n        people = Person.objects.filter(id__in=list(map(int, request.POST.getlist('recipients'))))\n\n        async_to_sync(get_channel_layer().send)(\"notifications\", {\n            \"type\": \"email_custom\",\n            \"subject\": request.POST['subject_line'],\n            \"body\": request.POST['message_body'],\n            \"tournament\": self.tournament.id,\n            \"send_to\": [(p.id, p.email) for p in people]\n        })\n\n        messages.success(request, ngettext(\n            \"%(count)s email has been queued for sending.\",\n            \"%(count)s emails have been queued for sending.\",\n            len(people)\n        ) % {'count': len(people)})\n        return super().post(request, *args, **kwargs)\n\n\nclass TemplateEmailCreateView(BaseSelectPeopleEmailView):\n\n    def get_initial(self):\n        initial = super().get_initial()\n        initial['subject_line'] = self.tournament.pref(self.subject_template)\n        initial['message_body'] = self.tournament.pref(self.message_template)\n\n        return initial\n\n    def post(self, request, *args, **kwargs):\n        self.tournament.preferences[self.subject_template] = request.POST['subject_line']\n        self.tournament.preferences[self.message_template] = request.POST['message_body']\n        email_recipients = list(map(int, request.POST.getlist('recipients')))\n\n        async_to_sync(get_channel_layer().send)(\"notifications\", {\n            \"type\": \"email\",\n            \"message\": self.event,\n            \"extra\": self.get_extra(),\n            \"send_to\": email_recipients,\n            \"subject\": request.POST['subject_line'],\n            \"body\": request.POST['message_body']\n        })\n\n        messages.success(request, ngettext(\n            \"%(count)s email has been queued for sending.\",\n            \"%(count)s emails have been queued for sending.\",\n            len(email_recipients)\n        ) % {'count': len(email_recipients)})\n        return super().post(request, *args, **kwargs)\n\n\nclass TournamentTemplateEmailCreateView(TemplateEmailCreateView):\n\n    def get_default_send_queryset(self):\n        return super().get_default_send_queryset().exclude(\n            sentmessagerecord__notification__event=self.event, sentmessagerecord__notification__tournament=self.tournament)\n\n    def get_extra(self):\n        extra = {'tournament_id': self.tournament.id}\n        return extra\n\n\nclass RoundTemplateEmailCreateView(TemplateEmailCreateView, RoundMixin):\n\n    def get_default_send_queryset(self):\n        return super().get_default_send_queryset().exclude(\n            sentmessagerecord__notification__event=self.event, sentmessagerecord__notification__round=self.round)\n\n    def get_extra(self):\n        extra = {'round_id': self.round.id}\n        return extra\n", "path": "tabbycat/notifications/views.py"}], "after_files": [{"content": "from datetime import datetime\nimport json\nfrom smtplib import SMTPException\n\nfrom asgiref.sync import async_to_sync\nfrom channels.layers import get_channel_layer\nfrom django.contrib import messages\nfrom django.db.models import Prefetch, Q\nfrom django.http import HttpResponse\nfrom django.urls import reverse_lazy\nfrom django.utils import timezone\nfrom django.utils.translation import gettext as _, gettext_lazy, ngettext\nfrom django.views.generic.base import View\nfrom django.views.generic.edit import FormView\n\nfrom participants.models import Person\nfrom tournaments.mixins import RoundMixin, TournamentMixin\nfrom utils.misc import reverse_tournament\nfrom utils.mixins import AdministratorMixin\nfrom utils.tables import TabbycatTableBuilder\nfrom utils.views import VueTableTemplateView\n\nfrom .forms import BasicEmailForm, TestEmailForm\nfrom .models import EmailStatus, SentMessageRecord\n\n\nclass TestEmailView(AdministratorMixin, FormView):\n    form_class = TestEmailForm\n    template_name = 'test_email.html'\n    success_url = reverse_lazy('notifications-test-email')\n    view_role = \"\"\n\n    def form_valid(self, form):\n        host = self.request.get_host()\n        try:\n            recipient = form.send_email(host)\n        except (ConnectionError, SMTPException) as e:\n            messages.error(self.request,\n                _(\"There was an error sending the test email: %(error)s\") % {'error': str(e)})\n        else:\n            messages.success(self.request,\n                _(\"A test email has been sent to %(recipient)s.\") % {'recipient': recipient})\n        return super().form_valid(form)\n\n\nclass EmailStatusView(AdministratorMixin, TournamentMixin, VueTableTemplateView):\n    page_title = gettext_lazy(\"Email Statuses\")\n    page_emoji = '\ud83d\udce4'\n    template_name = 'email_statuses.html'\n\n    tables_orientation = 'rows'\n\n    def _create_status_timeline(self, status):\n        statuses = []\n        for s in status:\n            text = _(\"%(status)s @ %(time)s\") % {'status': s.get_event_display(), 'time': s.timestamp}\n            statuses.append({\n                'text': '<span class=\"%s\">%s</span>' % (self._get_event_class(s.event), text)\n            })\n        return statuses\n\n    def _get_event_class(self, event):\n        return {\n            EmailStatus.EVENT_TYPE_BOUNCED: 'text-warning',\n            EmailStatus.EVENT_TYPE_DROPPED: 'text-warning',\n            EmailStatus.EVENT_TYPE_SPAM: 'text-warning',\n            EmailStatus.EVENT_TYPE_DEFERRED: 'text-warning',\n            EmailStatus.EVENT_TYPE_PROCESSED: 'text-info',\n            EmailStatus.EVENT_TYPE_DELIVERED: 'text-info',\n            EmailStatus.EVENT_TYPE_OPENED: 'text-success',\n            EmailStatus.EVENT_TYPE_CLICKED: 'text-success',\n            EmailStatus.EVENT_TYPE_UNSUBSCRIBED: None,\n            EmailStatus.EVENT_TYPE_ASM_UNSUBSCRIBED: None,\n            EmailStatus.EVENT_TYPE_ASM_RESUBSCRIBED: None\n        }[event]\n\n    def get_tables(self):\n        tables = []\n        notifications = self.tournament.bulknotification_set.select_related('round').prefetch_related(\n            Prefetch('sentmessagerecord_set', queryset=SentMessageRecord.objects.select_related('recipient').prefetch_related('emailstatus_set')))\n\n        for n in notifications:\n            emails = n.sentmessagerecord_set.all()\n\n            subtitle = n.round.name if n.round is not None else _(\"@ %s\") % timezone.localtime(n.timestamp).strftime(\"%a, %d %b %Y %H:%M:%S\")\n            table = TabbycatTableBuilder(view=self, title=n.get_event_display().title(), subtitle=subtitle)\n\n            # Create arrays for columns\n            emails_status = []\n            emails_time = []\n            for e in emails:\n                status = e.emailstatus_set.all()\n                if status.count() == 0:\n                    na_email = {'text': _(\"N/A\"), 'class': 'text-muted'}\n                    emails_status.append(na_email)\n                    emails_time.append(na_email)\n                    continue\n\n                first_status = status.first()\n                status_cell = {\n                    \"text\": first_status.get_event_display(),\n                    \"class\": self._get_event_class(first_status.event),\n                    \"popover\": {\"title\": _(\"Timeline\"), \"content\": self._create_status_timeline(status)}\n                }\n                emails_status.append(status_cell)\n                emails_time.append(first_status.timestamp)\n\n            table.add_column({'key': 'name', 'tooltip': _(\"Participant\"), 'icon': 'user'}, [e.recipient.name for e in emails])\n            table.add_column({'key': 'name', 'title': _(\"Status\")}, emails_status)\n            table.add_column({'key': 'name', 'title': _(\"Time\")}, emails_time)\n\n            tables.append(table)\n\n        return tables\n\n\nclass EmailEventWebhookView(TournamentMixin, View):\n\n    def post(self, request, *args, **kwargs):\n        if kwargs['key'] is not self.tournament.pref('email_hook_key'):\n            return HttpResponse(status=404) # 404: Not Found\n\n        data = json.loads(request.body)\n\n        records = SentMessageRecord.objects.filter(message_id__in=[obj['smtp-id'] for obj in data])\n        record_lookup = {smr.message_id: smr.id for smr in records}\n        statuses = []\n\n        for obj in data:\n            dt = datetime.fromtimestamp(obj['timestamp'])\n            timestamp = timezone.make_aware(dt, timezone.utc)\n            email_id = record_lookup.get(obj['smtp-id'], None)\n            if email_id is None:\n                continue\n            statuses.append(EmailStatus(email_id=email_id, timestamp=timestamp, event=obj['event'], data=obj))\n\n        EmailStatus.objects.bulk_create(statuses)\n\n        return HttpResponse(status=201) # 201: Created\n\n\nclass BaseSelectPeopleEmailView(AdministratorMixin, TournamentMixin, VueTableTemplateView, FormView):\n    template_name = \"email_participants.html\"\n    page_title = gettext_lazy(\"Email Participants\")\n    page_emoji = '\ud83d\udce4'\n\n    form_class = BasicEmailForm\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context['sg_webhook'] = EmailStatus.objects.filter(email__notification__tournament=self.tournament).exists()\n\n        context['categories'] = [{'id': 'spk', 'name': \"Email\"}]\n        return context\n\n    def get_default_send_queryset(self):\n        return self.get_queryset().filter(email__isnull=False).exclude(email__exact=\"\")\n\n    def get_queryset(self):\n        \"\"\"All the people from the tournament who could receive the message\"\"\"\n        queryset_filter = Q(speaker__team__tournament=self.tournament) | Q(adjudicator__tournament=self.tournament)\n        if self.tournament.pref('share_adjs'):\n            queryset_filter |= Q(adjudicator__tournament__isnull=True)\n\n        return Person.objects.filter(queryset_filter).select_related('speaker', 'adjudicator')\n\n    def default_send(self, p, default_send_queryset=None):\n        \"\"\"Whether the person should be emailed by default\"\"\"\n        return p in default_send_queryset\n\n    def get_table(self, mixed_participants=False):\n        table = TabbycatTableBuilder(view=self, sort_key='name')\n\n        queryset = self.get_queryset()\n        default_send_queryset = self.get_default_send_queryset()\n\n        table.add_column({'key': 'send', 'title': _(\"Send Email\")}, [{\n            'component': 'check-cell',\n            'checked': self.default_send(p, default_send_queryset),\n            'id': p.id,\n            'name': 'recipients',\n            'value': p.id,\n            'noSave': True,\n            'type': 'adj' if mixed_participants and hasattr(p, 'adjudicator') else 'spk'\n        } for p in queryset])\n\n        table.add_column({'key': 'name', 'tooltip': _(\"Participant\"), 'icon': 'user'}, [{\n            'text': p.name,\n            'class': 'no-wrap' if len(p.name) < 20 else ''\n        } for p in queryset])\n\n        table.add_column({'key': 'email', 'tooltip': _(\"Email Address\"), 'icon': 'mail'}, [{\n            'text': p.email if p.email else _(\"Not Provided\"),\n            'class': 'small' if p.email else 'small text-warning'\n        } for p in queryset])\n\n        return table\n\n\nclass RoleColumnMixin:\n    \"\"\"Mixin to have a column Adjudicator/Speaker for email\"\"\"\n\n    def get_table(self, mixed_participants=True):\n        table = super().get_table(mixed_participants)\n\n        table.add_column({'key': 'role', 'title': _(\"Role\")}, [{\n            'text': _(\"Adjudicator\") if hasattr(p, 'adjudicator') else _(\"Speaker\")\n        } for p in self.get_queryset()])\n\n        return table\n\n    def get_context_data(self, **kwargs):\n        context = super().get_context_data(**kwargs)\n        context['categories'] = [\n            {'id': 'spk', 'name': _(\"Speakers\")},\n            {'id': 'adj', 'name': _(\"Adjudicators\")}\n        ]\n        return context\n\n\nclass CustomEmailCreateView(RoleColumnMixin, BaseSelectPeopleEmailView):\n\n    def get_success_url(self):\n        return reverse_tournament('notifications-email', self.tournament)\n\n    def default_send(self, p, default_send_queryset):\n        return False\n\n    def post(self, request, *args, **kwargs):\n        people = Person.objects.filter(id__in=list(map(int, request.POST.getlist('recipients'))))\n\n        async_to_sync(get_channel_layer().send)(\"notifications\", {\n            \"type\": \"email_custom\",\n            \"subject\": request.POST['subject_line'],\n            \"body\": request.POST['message_body'],\n            \"tournament\": self.tournament.id,\n            \"send_to\": [(p.id, p.email) for p in people]\n        })\n\n        messages.success(request, ngettext(\n            \"%(count)s email has been queued for sending.\",\n            \"%(count)s emails have been queued for sending.\",\n            len(people)\n        ) % {'count': len(people)})\n        return super().post(request, *args, **kwargs)\n\n\nclass TemplateEmailCreateView(BaseSelectPeopleEmailView):\n\n    def get_initial(self):\n        initial = super().get_initial()\n        initial['subject_line'] = self.tournament.pref(self.subject_template)\n        initial['message_body'] = self.tournament.pref(self.message_template)\n\n        return initial\n\n    def post(self, request, *args, **kwargs):\n        self.tournament.preferences[self.subject_template] = request.POST['subject_line']\n        self.tournament.preferences[self.message_template] = request.POST['message_body']\n        email_recipients = list(map(int, request.POST.getlist('recipients')))\n\n        async_to_sync(get_channel_layer().send)(\"notifications\", {\n            \"type\": \"email\",\n            \"message\": self.event,\n            \"extra\": self.get_extra(),\n            \"send_to\": email_recipients,\n            \"subject\": request.POST['subject_line'],\n            \"body\": request.POST['message_body']\n        })\n\n        messages.success(request, ngettext(\n            \"%(count)s email has been queued for sending.\",\n            \"%(count)s emails have been queued for sending.\",\n            len(email_recipients)\n        ) % {'count': len(email_recipients)})\n        return super().post(request, *args, **kwargs)\n\n\nclass TournamentTemplateEmailCreateView(TemplateEmailCreateView):\n\n    def get_default_send_queryset(self):\n        return super().get_default_send_queryset().exclude(\n            sentmessagerecord__notification__event=self.event, sentmessagerecord__notification__tournament=self.tournament)\n\n    def get_extra(self):\n        extra = {'tournament_id': self.tournament.id}\n        return extra\n\n\nclass RoundTemplateEmailCreateView(TemplateEmailCreateView, RoundMixin):\n\n    def get_default_send_queryset(self):\n        return super().get_default_send_queryset().exclude(\n            sentmessagerecord__notification__event=self.event, sentmessagerecord__notification__round=self.round)\n\n    def get_extra(self):\n        extra = {'round_id': self.round.id}\n        return extra\n", "path": "tabbycat/notifications/views.py"}]}