Skip to content

Commit 419ce3b

Browse files
committed
prepare for prod
1 parent 7084050 commit 419ce3b

10 files changed

Lines changed: 172 additions & 41 deletions

File tree

Pipfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,6 @@ nplusone = "*"
4040

4141
[requires]
4242
python_version = "3.10.16"
43+
44+
[scripts]
45+
pylint = "python -m pylint --recursive=y --score=n manage.py mittab"

assets/js/sendJudgeCodes.js

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,21 @@ document.addEventListener("DOMContentLoaded", () => {
99
const getValue = (cell) =>
1010
cell?.dataset.sortValue ?? cell?.textContent?.trim().toLowerCase() ?? "";
1111

12+
const setSortDirection = (cell, direction) => {
13+
const headerCell = cell;
14+
headerCell.dataset.sortDir = direction;
15+
const arrow = headerCell.querySelector(".sort-arrow");
16+
let arrowText = "";
17+
if (direction === "asc") {
18+
arrowText = "▲";
19+
} else if (direction === "desc") {
20+
arrowText = "▼";
21+
}
22+
if (arrow) {
23+
arrow.textContent = arrowText;
24+
}
25+
};
26+
1227
const sortTable = (header) => {
1328
const key = header.dataset.sortKey;
1429
if (!key) return;
@@ -21,17 +36,9 @@ document.addEventListener("DOMContentLoaded", () => {
2136

2237
headers.forEach((th) => {
2338
if (th === header) return;
24-
th.dataset.sortDir = "";
25-
const arrow = th.querySelector(".sort-arrow");
26-
if (arrow) {
27-
arrow.textContent = "";
28-
}
39+
setSortDirection(th, "");
2940
});
30-
header.dataset.sortDir = currentDir;
31-
const activeArrow = header.querySelector(".sort-arrow");
32-
if (activeArrow) {
33-
activeArrow.textContent = currentDir === "asc" ? "▲" : "▼";
34-
}
41+
setSortDirection(header, currentDir);
3542

3643
rows.sort((a, b) => {
3744
const aCell = a.cells[header.cellIndex];
@@ -53,10 +60,11 @@ document.addEventListener("DOMContentLoaded", () => {
5360
};
5461

5562
headers.forEach((th) => {
56-
th.style.cursor = "pointer";
57-
th.addEventListener("click", (event) => {
63+
const header = th;
64+
header.style.cursor = "pointer";
65+
header.addEventListener("click", (event) => {
5866
event.preventDefault();
59-
sortTable(th);
67+
sortTable(header);
6068
});
6169
});
6270
});

mittab/apps/tab/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ def has_invalid_ranks(self):
452452

453453

454454
class EBallotForm(ResultEntryForm):
455-
ballot_code = forms.CharField(max_length=30, min_length=0)
455+
ballot_code = forms.CharField(max_length=BALLOT_CODE_MAX_LENGTH, min_length=0)
456456

457457
def __init__(self, *args, **kwargs):
458458
ballot_code = ""

mittab/apps/tab/migrations/0033_auto_20251114_2149.py renamed to mittab/apps/tab/migrations/0037_judge_code_emailing.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
class Migration(migrations.Migration):
99

1010
dependencies = [
11-
('tab', '0032_manualjudgeassignment'),
11+
("tab", "0036_alter_outround_room_nullable"),
1212
]
1313

1414
operations = [
@@ -20,14 +20,14 @@ class Migration(migrations.Migration):
2020
migrations.AlterField(
2121
model_name='judge',
2222
name='ballot_code',
23-
field=models.CharField(blank=True, max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Ballot code must contain at least one letter on each side of a single hyphen.', regex='^[A-Za-z]+-[A-Za-z]+$')]),
23+
field=models.CharField(blank=True, max_length=255, null=True, unique=True, validators=[django.core.validators.MaxLengthValidator(30), django.core.validators.RegexValidator(message='Ballot code must contain at least one letter on each side of a single hyphen.', regex='^[A-Za-z]+-[A-Za-z]+$')]),
2424
),
2525
migrations.CreateModel(
2626
name='JudgeCodeEmailLog',
2727
fields=[
2828
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
2929
('email', models.EmailField(max_length=254)),
30-
('ballot_code', models.CharField(max_length=64)),
30+
('ballot_code', models.CharField(max_length=30)),
3131
('sent_at', models.DateTimeField(auto_now_add=True)),
3232
('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='code_email_logs', to='tab.judge')),
3333
],

mittab/apps/tab/models.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from haikunator import Haikunator
44
from django.db import models
55
from django.core.exceptions import ValidationError
6-
from django.core.validators import RegexValidator
6+
from django.core.validators import MaxLengthValidator, RegexValidator
77

88
from mittab.libs.cacheing import cache_logic
99

@@ -365,7 +365,7 @@ class BreakingTeam(models.Model):
365365
choices=TYPE_CHOICES)
366366

367367

368-
BALLOT_CODE_MAX_LENGTH = 64
368+
BALLOT_CODE_MAX_LENGTH = 30
369369
ballot_code_validator = RegexValidator(
370370
regex=r"^[A-Za-z]+-[A-Za-z]+$",
371371
message="Ballot code must contain at least one letter on each side of a single hyphen.",
@@ -381,7 +381,10 @@ class Judge(models.Model):
381381
blank=True,
382382
null=True,
383383
unique=True,
384-
validators=[ballot_code_validator])
384+
validators=[
385+
MaxLengthValidator(BALLOT_CODE_MAX_LENGTH),
386+
ballot_code_validator,
387+
])
385388
is_dino = models.BooleanField(default=False)
386389
wing_only = models.BooleanField(default=False)
387390
required_room_tags = models.ManyToManyField("RoomTag", blank=True)

mittab/apps/tab/views/judge_views.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ def view_judge(request, judge_id):
215215
if form.is_valid():
216216
try:
217217
form.save()
218-
except ValueError:
218+
except (ValueError, ValidationError):
219219
return redirect_and_flash_error(
220220
request, "Judge information cannot be validated")
221221
updated_name = form.cleaned_data["name"]
@@ -243,7 +243,7 @@ def enter_judge(request):
243243
if form.is_valid():
244244
try:
245245
form.save()
246-
except ValueError:
246+
except (ValueError, ValidationError):
247247
return redirect_and_flash_error(request,
248248
"Judge cannot be validated")
249249
created_name = form.cleaned_data["name"]
@@ -377,17 +377,25 @@ def send_judge_codes(request):
377377
tournament_name = TabSettings.get("tournament_name", "your tournament")
378378
plan = _prepare_judge_code_plan(all_judges, tournament_name, request)
379379
sendable_by_id = {entry["judge"].id: entry for entry in plan["sendable"]}
380-
default_selected_ids = set(sendable_by_id.keys())
380+
default_selected_ids = list(sendable_by_id.keys())
381+
default_selected_id_set = set(default_selected_ids)
381382

382383
if request.method == "POST":
383-
selected_ids = {
384-
int(judge_id)
385-
for judge_id in request.POST.getlist("judge_ids")
386-
if judge_id.isdigit()
387-
}
388-
selected_ids &= default_selected_ids
384+
selected_ids = []
385+
seen_ids = set()
386+
for judge_id in request.POST.getlist("judge_ids"):
387+
if not judge_id.isdigit():
388+
continue
389+
390+
parsed_id = int(judge_id)
391+
if parsed_id not in default_selected_id_set or parsed_id in seen_ids:
392+
continue
393+
394+
selected_ids.append(parsed_id)
395+
seen_ids.add(parsed_id)
389396
else:
390397
selected_ids = default_selected_ids
398+
selected_id_set = set(selected_ids)
391399

392400
last_sent_map = dict(
393401
JudgeCodeEmailLog.objects.values("judge_id")
@@ -413,7 +421,7 @@ def send_judge_codes(request):
413421
judge_rows = []
414422
for judge in all_judges:
415423
can_send = judge.id in sendable_by_id
416-
checked = can_send and judge.id in selected_ids
424+
checked = can_send and judge.id in selected_id_set
417425
status = status_lookup.get(judge.id, "Ready" if can_send else "Not eligible")
418426
judge_rows.append({
419427
"judge": judge,
@@ -448,11 +456,33 @@ def send_judge_codes(request):
448456

449457
try:
450458
sent = EmailService().send_bulk(email_requests)
451-
except (EmailServiceError, ImproperlyConfigured) as exc:
459+
except ImproperlyConfigured as exc:
452460
return redirect_and_flash_error(
453461
request,
454462
f"Unable to send judge codes: {exc}",
455463
)
464+
except EmailServiceError as exc:
465+
sent_request_ids = {id(email_request) for email_request in exc.sent_requests}
466+
sent_log_entries = [
467+
entry["log_entry"]
468+
for entry in selected_entries
469+
if id(entry["email_request"]) in sent_request_ids
470+
]
471+
if sent_log_entries:
472+
JudgeCodeEmailLog.objects.bulk_create(sent_log_entries)
473+
474+
partial_message = ""
475+
if sent_log_entries:
476+
sent_count = len(sent_log_entries)
477+
partial_message = (
478+
f" after sending {sent_count} judge code"
479+
f"{'' if sent_count == 1 else 's'}"
480+
)
481+
482+
return redirect_and_flash_error(
483+
request,
484+
f"Unable to send judge codes{partial_message}: {exc}",
485+
)
456486

457487
JudgeCodeEmailLog.objects.bulk_create(log_entries)
458488

mittab/libs/email_service.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
class EmailServiceError(Exception):
1414
"""Raised when Amazon SES rejects a send request."""
1515

16+
def __init__(self, message, sent_requests=None):
17+
super().__init__(message)
18+
self.sent_requests = list(sent_requests or [])
19+
1620

1721
@dataclass(frozen=True)
1822
class EmailRequest:
@@ -41,6 +45,7 @@ def send_bulk(self, requests: Iterable[EmailRequest]) -> int:
4145
return 0
4246

4347
sent = 0
48+
sent_requests: List[EmailRequest] = []
4449
failures: List[str] = []
4550
list_management = self._list_management_options()
4651
configuration_set = settings.AWS_SES_CONFIGURATION_SET
@@ -59,13 +64,15 @@ def send_bulk(self, requests: Iterable[EmailRequest]) -> int:
5964
try:
6065
self.client.send_email(**payload)
6166
sent += 1
67+
sent_requests.append(request)
6268
except (BotoCoreError, ClientError) as exc:
6369
logger.exception("Amazon SES send failed: %s", exc)
6470
failures.append(str(exc))
6571

6672
if failures:
6773
raise EmailServiceError(
68-
f"Failed to send {len(failures)} email(s): {failures[-1]}"
74+
f"Failed to send {len(failures)} email(s): {failures[-1]}",
75+
sent_requests=sent_requests,
6976
)
7077

7178
return sent
@@ -88,7 +95,8 @@ def _list_management_options(self):
8895
contact_list = settings.AWS_MAILMANAGER_ADDRESS_LIST
8996
if not contact_list:
9097
logger.warning(
91-
"AWS_MAILMANAGER_ADDRESS_LIST not configured; unsubscribe links will be omitted"
98+
"AWS_MAILMANAGER_ADDRESS_LIST not configured; "
99+
"unsubscribe links will be omitted"
92100
)
93101
return None
94102

mittab/libs/tests/test_email_service.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,47 @@
55

66

77
class DummySESClient:
8-
def __init__(self, should_fail=False, fail_create=False, existing_lists=None):
8+
def __init__(
9+
self,
10+
should_fail=False,
11+
fail_create=False,
12+
existing_lists=None,
13+
fail_on_calls=None,
14+
):
915
self.should_fail = should_fail
1016
self.fail_create = fail_create
17+
self.fail_on_calls = set(fail_on_calls or [])
1118
self.calls = []
19+
self.send_attempts = 0
1220
self.created_lists = []
1321
self.contact_lists = set(existing_lists or [])
1422

1523
def send_email(self, **kwargs):
16-
if self.should_fail:
17-
raise ClientError({"Error": {"Code": "Boom", "Message": "boom"}}, "SendEmail")
24+
self.send_attempts += 1
25+
call_number = self.send_attempts
26+
if self.should_fail or call_number in self.fail_on_calls:
27+
raise ClientError(
28+
{"Error": {"Code": "Boom", "Message": "boom"}},
29+
"SendEmail",
30+
)
1831
self.calls.append(kwargs)
1932

2033
def create_contact_list(self, **kwargs):
2134
if self.fail_create:
22-
raise ClientError({"Error": {"Code": "RandomError", "Message": "boom"}}, "CreateContactList")
35+
raise ClientError(
36+
{"Error": {"Code": "RandomError", "Message": "boom"}},
37+
"CreateContactList",
38+
)
2339
self.created_lists.append(kwargs.get("ContactListName"))
2440
self.contact_lists.add(kwargs.get("ContactListName"))
2541

2642
def get_contact_list(self, **kwargs):
2743
name = kwargs.get("ContactListName")
2844
if name not in self.contact_lists:
29-
raise ClientError({"Error": {"Code": "NotFoundException", "Message": "missing"}}, "GetContactList")
45+
raise ClientError(
46+
{"Error": {"Code": "NotFoundException", "Message": "missing"}},
47+
"GetContactList",
48+
)
3049

3150

3251
def test_send_bulk_injects_unsubscribe_links(settings):
@@ -70,6 +89,30 @@ def test_send_bulk_raises_on_failure(settings):
7089
service.send_bulk([request])
7190

7291

92+
def test_send_bulk_reports_partial_successes(settings):
93+
settings.AWS_MAILMANAGER_ADDRESS_LIST = "test-contact-list"
94+
settings.DEFAULT_FROM_EMAIL = "MIT-TAB <no-reply@example.com>"
95+
settings.EMAIL_REPLY_TO = "no-reply@example.com"
96+
97+
client = DummySESClient(fail_on_calls={2})
98+
service = EmailService(ses_client=client)
99+
first_request = EmailRequest(
100+
to_address="judge1@example.com",
101+
subject="Judge Code",
102+
text_body="Test",
103+
)
104+
second_request = EmailRequest(
105+
to_address="judge2@example.com",
106+
subject="Judge Code",
107+
text_body="Test",
108+
)
109+
110+
with pytest.raises(EmailServiceError) as exc_info:
111+
service.send_bulk([first_request, second_request])
112+
113+
assert exc_info.value.sent_requests == [first_request]
114+
115+
73116
def test_send_bulk_without_contact_list(settings):
74117
settings.AWS_MAILMANAGER_ADDRESS_LIST = ""
75118
settings.DEFAULT_FROM_EMAIL = "MIT-TAB <no-reply@example.com>"
@@ -106,7 +149,7 @@ def test_send_bulk_existing_contact_list(settings):
106149
sent = service.send_bulk([request])
107150

108151
assert sent == 1
109-
assert client.created_lists == []
152+
assert not client.created_lists
110153

111154

112155
def test_send_bulk_contact_list_creation_failure(settings):

0 commit comments

Comments
 (0)