Skip to content

Commit 27dcca6

Browse files
committed
Small elections regressions
1 parent ffa2625 commit 27dcca6

File tree

8 files changed

+320
-25
lines changed

8 files changed

+320
-25
lines changed

astra_app/core/static/core/js/form_validation_bootstrap44.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,10 @@
7676
form.addEventListener(
7777
"submit",
7878
function (event) {
79-
if (event.submitter && event.submitter.formNoValidate) {
80-
return;
81-
}
82-
79+
const allowInvalidSubmit =
80+
event.submitter && event.submitter.dataset.allowInvalidSubmit === "true";
8381
const isValid = validateForm(form, "submit", event.target);
84-
if (!isValid) {
82+
if (!isValid && !allowInvalidSubmit) {
8583
event.preventDefault();
8684
event.stopPropagation();
8785
}

astra_app/core/templates/core/ballot_verify.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ <h5>Ballot Information</h5>
127127
{% if verification_snippet %}
128128
<h5>Copy/paste constants for local verification</h5>
129129
<p class="text-muted">
130-
Copy/paste the block below into <strong>verify-ballot-hash.py</strong>. It includes the election ID,
130+
Copy/paste the block below into <strong><a href="{% static 'verify-ballot-hash.py' %}">verify-ballot-hash.py</a></strong>. It includes the election ID,
131131
candidate username-to-ID mapping, and your credential public ID. You still need to enter your own vote choices
132132
in the script (vote choices are secret and are not shown on this page).
133133
</p>

astra_app/core/templates/core/election_detail.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
.election-voter-card.collapsed-card .card-tools .js-card-search {
3333
display: none;
3434
}
35+
36+
.candidate-card-divider {
37+
clear: both;
38+
}
3539
</style>
3640
{% endblock %}
3741

@@ -245,7 +249,7 @@ <h3 class="card-title mb-0">
245249
</p>
246250
{% endif %}
247251

248-
<hr />
252+
<hr class="candidate-card-divider" />
249253
<p class="mb-0">
250254
<strong>Nominated by</strong>
251255

astra_app/core/templates/core/election_edit.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ <h3 class="card-title mb-0">Election details</h3>
8787
<div class="col-lg-3">
8888
<div class="card card-outline card-secondary">
8989
<div class="card-body">
90-
<button type="submit" class="btn btn-primary btn-block" onclick="document.getElementById('election-edit-action').value='save_draft'" title="Save changes as a draft">
90+
<button type="submit" data-allow-invalid-submit="true" class="btn btn-primary btn-block" onclick="document.getElementById('election-edit-action').value='save_draft'" title="Save changes as a draft">
9191
Save Draft
9292
</button>
9393

@@ -143,7 +143,7 @@ <h5 class="modal-title" id="start-election-modal-label">Start election?</h5>
143143
</div>
144144
<div class="modal-footer">
145145
<button type="button" class="btn btn-outline-secondary" data-dismiss="modal" title="Close dialog without starting">Cancel</button>
146-
<button type="submit" class="btn btn-success" onclick="document.getElementById('election-edit-action').value='start_election'" title="Start the election and send credentials">Start election &amp; send credentials</button>
146+
<button type="submit" data-allow-invalid-submit="true" class="btn btn-success" onclick="document.getElementById('election-edit-action').value='start_election'" title="Start the election and send credentials">Start election &amp; send credentials</button>
147147
</div>
148148
</div>
149149
</div>

astra_app/core/tests/test_elections_edit_ui.py

Lines changed: 173 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,27 @@ def test_new_election_hides_start_button_until_saved(self) -> None:
161161
self.assertNotContains(resp, 'data-target="#start-election-modal"')
162162
self.assertNotContains(resp, 'id="start-election-modal"')
163163

164+
def test_new_election_save_draft_button_allows_invalid_submit_without_formnovalidate(self) -> None:
165+
self._login_as_freeipa_user("admin")
166+
FreeIPAPermissionGrant.objects.create(
167+
principal_type=FreeIPAPermissionGrant.PrincipalType.user,
168+
principal_name="admin",
169+
permission=ASTRA_ADD_ELECTION,
170+
)
171+
172+
resp = self.client.get(reverse("election-edit", args=[0]))
173+
self.assertEqual(resp.status_code, 200)
174+
175+
html = resp.content.decode("utf-8")
176+
self.assertRegex(
177+
html,
178+
r"<button[^>]*data-allow-invalid-submit=\"true\"[^>]*title=\"Save changes as a draft\"[^>]*>",
179+
)
180+
self.assertNotRegex(
181+
html,
182+
r"<button[^>]*formnovalidate[^>]*title=\"Save changes as a draft\"[^>]*>",
183+
)
184+
164185

165186
class ElectionDraftDeletionTests(TestCase):
166187
def _login_as_freeipa_user(self, username: str) -> None:
@@ -249,7 +270,7 @@ def test_new_election_post_save_draft_creates_election(self) -> None:
249270
self.assertEqual(resp.status_code, 302)
250271
self.assertEqual(Election.objects.filter(name="New draft", status=Election.Status.draft).count(), 1)
251272

252-
def test_new_election_post_rejects_zero_seats(self) -> None:
273+
def test_start_election_submit_button_allows_invalid_submit_for_server_validation(self) -> None:
253274
self._login_as_freeipa_user("admin")
254275
FreeIPAPermissionGrant.objects.create(
255276
principal_type=FreeIPAPermissionGrant.PrincipalType.user,
@@ -258,17 +279,91 @@ def test_new_election_post_rejects_zero_seats(self) -> None:
258279
)
259280

260281
now = timezone.now()
282+
election = Election.objects.create(
283+
name="Draft election",
284+
description="",
285+
url="",
286+
start_datetime=now + datetime.timedelta(days=10),
287+
end_datetime=now + datetime.timedelta(days=11),
288+
number_of_seats=1,
289+
status=Election.Status.draft,
290+
)
291+
292+
resp = self.client.get(reverse("election-edit", args=[election.id]))
293+
self.assertEqual(resp.status_code, 200)
294+
html = resp.content.decode("utf-8")
295+
self.assertRegex(
296+
html,
297+
r"<button(?=[^>]*title=\"Start the election and send credentials\")(?=[^>]*data-allow-invalid-submit=\"true\")[^>]*>",
298+
)
299+
self.assertNotRegex(
300+
html,
301+
r"<button[^>]*title=\"Start the election and send credentials\"[^>]*formnovalidate[^>]*>",
302+
)
303+
304+
def test_start_election_without_candidates_shows_specific_error_without_generic_duplicate(self) -> None:
305+
self._login_as_freeipa_user("admin")
306+
FreeIPAPermissionGrant.objects.create(
307+
principal_type=FreeIPAPermissionGrant.PrincipalType.user,
308+
principal_name="admin",
309+
permission=ASTRA_ADD_ELECTION,
310+
)
311+
312+
now = timezone.now()
313+
election = Election.objects.create(
314+
name="Draft election",
315+
description="",
316+
url="",
317+
start_datetime=now + datetime.timedelta(days=10),
318+
end_datetime=now + datetime.timedelta(days=11),
319+
number_of_seats=1,
320+
quorum=10,
321+
status=Election.Status.draft,
322+
)
323+
324+
resp = self.client.post(
325+
reverse("election-edit", args=[election.id]),
326+
data={
327+
"action": "start_election",
328+
"name": election.name,
329+
"description": election.description,
330+
"url": election.url,
331+
"start_datetime": election.start_datetime.strftime("%Y-%m-%dT%H:%M"),
332+
"end_datetime": election.end_datetime.strftime("%Y-%m-%dT%H:%M"),
333+
"number_of_seats": str(election.number_of_seats),
334+
"quorum": str(election.quorum),
335+
"email_template_id": "",
336+
"subject": "",
337+
"html_content": "",
338+
"text_content": "",
339+
},
340+
follow=False,
341+
)
342+
343+
self.assertEqual(resp.status_code, 200)
344+
self.assertContains(resp, "Add at least one candidate before starting the election.")
345+
self.assertNotContains(resp, "Please correct the errors below.")
346+
347+
def test_new_election_post_save_draft_with_only_name_creates_partial_election(self) -> None:
348+
self._login_as_freeipa_user("admin")
349+
FreeIPAPermissionGrant.objects.create(
350+
principal_type=FreeIPAPermissionGrant.PrincipalType.user,
351+
principal_name="admin",
352+
permission=ASTRA_ADD_ELECTION,
353+
)
354+
261355
resp = self.client.post(
262356
reverse("election-edit", args=[0]),
263357
data={
264358
"action": "save_draft",
265-
"name": "Invalid draft",
359+
"name": "Partial draft",
266360
"description": "",
267361
"url": "",
268-
"start_datetime": (now + datetime.timedelta(days=10)).strftime("%Y-%m-%dT%H:%M"),
269-
"end_datetime": (now + datetime.timedelta(days=11)).strftime("%Y-%m-%dT%H:%M"),
270-
"number_of_seats": "0",
271-
"quorum": "50",
362+
"start_datetime": "",
363+
"end_datetime": "",
364+
"number_of_seats": "",
365+
"quorum": "",
366+
"eligible_group_cn": "",
272367
"email_template_id": "",
273368
"subject": "",
274369
"html_content": "",
@@ -291,12 +386,78 @@ def test_new_election_post_rejects_zero_seats(self) -> None:
291386
follow=False,
292387
)
293388

294-
self.assertEqual(resp.status_code, 200)
295-
self.assertContains(resp, "Number of seats")
296-
self.assertContains(resp, "Ensure this value is greater than or equal to 1")
297-
self.assertContains(resp, 'id="election-edit-form"')
298-
self.assertContains(resp, "was-validated")
299-
self.assertContains(resp, "invalid-feedback")
389+
self.assertEqual(resp.status_code, 302)
390+
election = Election.objects.get(name="Partial draft")
391+
self.assertEqual(election.status, Election.Status.draft)
392+
self.assertEqual(election.start_datetime, election.end_datetime)
393+
394+
def test_start_election_still_requires_required_fields_after_partial_draft_save(self) -> None:
395+
self._login_as_freeipa_user("admin")
396+
FreeIPAPermissionGrant.objects.create(
397+
principal_type=FreeIPAPermissionGrant.PrincipalType.user,
398+
principal_name="admin",
399+
permission=ASTRA_ADD_ELECTION,
400+
)
401+
402+
save_resp = self.client.post(
403+
reverse("election-edit", args=[0]),
404+
data={
405+
"action": "save_draft",
406+
"name": "Draft requiring completion",
407+
"description": "",
408+
"url": "",
409+
"start_datetime": "",
410+
"end_datetime": "",
411+
"number_of_seats": "",
412+
"quorum": "",
413+
"eligible_group_cn": "",
414+
"email_template_id": "",
415+
"subject": "",
416+
"html_content": "",
417+
"text_content": "",
418+
"candidates-TOTAL_FORMS": "1",
419+
"candidates-INITIAL_FORMS": "0",
420+
"candidates-MIN_NUM_FORMS": "0",
421+
"candidates-MAX_NUM_FORMS": "1000",
422+
"candidates-0-id": "",
423+
"candidates-0-freeipa_username": "",
424+
"candidates-0-nominated_by": "",
425+
"candidates-0-description": "",
426+
"candidates-0-url": "",
427+
"candidates-0-DELETE": "",
428+
"groups-TOTAL_FORMS": "0",
429+
"groups-INITIAL_FORMS": "0",
430+
"groups-MIN_NUM_FORMS": "0",
431+
"groups-MAX_NUM_FORMS": "1000",
432+
},
433+
follow=False,
434+
)
435+
self.assertEqual(save_resp.status_code, 302)
436+
election = Election.objects.get(name="Draft requiring completion")
437+
438+
start_resp = self.client.post(
439+
reverse("election-edit", args=[election.id]),
440+
data={
441+
"action": "start_election",
442+
"name": election.name,
443+
"description": election.description,
444+
"url": election.url,
445+
"start_datetime": "",
446+
"end_datetime": "",
447+
"number_of_seats": "",
448+
"quorum": "",
449+
"email_template_id": "",
450+
"subject": "",
451+
"html_content": "",
452+
"text_content": "",
453+
},
454+
follow=False,
455+
)
456+
457+
self.assertEqual(start_resp.status_code, 200)
458+
self.assertContains(start_resp, "Please correct the errors below.")
459+
election.refresh_from_db()
460+
self.assertEqual(election.status, Election.Status.draft)
300461

301462
@override_settings(ELECTION_ELIGIBILITY_MIN_MEMBERSHIP_AGE_DAYS=1)
302463
def test_save_draft_rejects_self_nomination_and_does_not_save_candidate(self) -> None:

astra_app/core/tests/test_elections_ui.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,64 @@ def _get_user(username: str):
150150
self.assertContains(resp, "Nominated by")
151151
self.assertContains(resp, reverse("user-profile", args=["nominator"]))
152152
self.assertContains(resp, "Nominator Person")
153+
154+
def test_candidate_card_divider_clears_avatar_when_description_missing(self) -> None:
155+
self._login_as_freeipa_user("viewer")
156+
157+
now = timezone.now()
158+
election = Election.objects.create(
159+
name="Board election",
160+
description="",
161+
start_datetime=now - datetime.timedelta(days=1),
162+
end_datetime=now + datetime.timedelta(days=1),
163+
number_of_seats=2,
164+
status=Election.Status.open,
165+
)
166+
167+
Candidate.objects.create(
168+
election=election,
169+
freeipa_username="adamnelson",
170+
nominated_by="benjamingarcia",
171+
description="",
172+
url="",
173+
)
174+
175+
viewer = FreeIPAUser("viewer", {"uid": ["viewer"], "memberof_group": []})
176+
candidate_user = FreeIPAUser(
177+
"adamnelson",
178+
{
179+
"uid": ["adamnelson"],
180+
"givenname": ["Adam"],
181+
"sn": ["Nelson"],
182+
"displayname": ["Adam Nelson"],
183+
"memberof_group": [],
184+
},
185+
)
186+
nominator_user = FreeIPAUser(
187+
"benjamingarcia",
188+
{
189+
"uid": ["benjamingarcia"],
190+
"givenname": ["Benjamin"],
191+
"sn": ["Garcia"],
192+
"displayname": ["Benjamin Garcia"],
193+
"memberof_group": [],
194+
},
195+
)
196+
197+
def _get_user(username: str):
198+
if username == "viewer":
199+
return viewer
200+
if username == "adamnelson":
201+
return candidate_user
202+
if username == "benjamingarcia":
203+
return nominator_user
204+
return None
205+
206+
self.assertIsNotNone(election.pk)
207+
with patch("core.backends.FreeIPAUser.get", side_effect=_get_user):
208+
resp = self.client.get(reverse("election-detail", args=[election.pk]))
209+
210+
self.assertEqual(resp.status_code, 200)
211+
self.assertContains(resp, "class=\"candidate-card-divider\"")
212+
self.assertContains(resp, ".candidate-card-divider")
213+
self.assertContains(resp, "clear: both;")

astra_app/core/tests/test_plan093_realtime_form_validation_required_indicators.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ def test_realtime_validation_js_uses_touched_blur_and_invalid_only_states(self)
6969
self.assertIn('field.classList.toggle("is-invalid", !field.checkValidity())', script)
7070
self.assertIn('field.dataset.astraTouched === "1" || field.classList.contains("is-invalid")', script)
7171
self.assertNotIn('classList.add("is-valid")', script)
72+
self.assertIn('event.submitter && event.submitter.dataset.allowInvalidSubmit === "true"', script)
73+
self.assertIn("if (!isValid && !allowInvalidSubmit)", script)
7274

7375
def test_base_css_displays_invalid_feedback_when_field_is_invalid_without_form_was_validated(self) -> None:
7476
css = self._read_core_static("css", "base.css")

0 commit comments

Comments
 (0)