Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions v3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,16 @@ present in the person database. Management of an existing election is restricted
to its `owner_pid`. The `authz` field is reserved for a future delegation policy
and currently grants no additional management access.

### Voter Lists

From an election's management page, use **Manage voters** to view its eligible voters and the number of issues assigned to each. While the election is editable, paste ASF IDs separated by spaces, commas, or newlines and select the issues they may vote on. IDs must exactly match existing person-directory records; an unknown ID or invalid issue rejects the entire batch and keeps the input for correction. Repeated IDs and existing grants are accepted without duplicates, and adding voters preserves their other grants.

Use **Edit eligibility** beside a voter to change their assigned issues, or **Remove voter** to remove their eligibility from every issue in this election. Removal leaves the global person record and other elections untouched. The web editor requires at least one selected issue when saving; removal is a separate explicit action.

Eligibility belongs to individual issues, rather than an independent election-wide roster. Create issues before assigning voters. A newly added issue starts with no eligible voters and never automatically inherits another issue's list. Review its eligibility explicitly before opening. Once the election opens, the voter list and each person's issue assignments remain visible to the owner but cannot be changed, including after closure. These pages do not display participation or ballots.

### Mutation and Storage Contracts

All `/do-*` mutations require POST and a session CSRF token. HTML forms send
`csrf_token`; JSON requests send `X-CSRFToken`. Opening and closing elections
via GET is not supported. State conflicts return HTTP 409, invalid ballots
Expand Down
2 changes: 1 addition & 1 deletion v3/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Community Self-Service

- Add voter roster management to the web UI, including ASF ID validation and a clear policy for issues added after a roster is configured.
- Add group-based voter selection once person-directory group membership and refresh semantics are defined. The web UI currently accepts explicit ASF IDs and issue assignments.
- Add STV creation and editing for candidate labels, names, ASF IDs, and seats. The current web issue creation flow supports YNA only.
- Define delegated management semantics for `authz` before granting access beyond the election owner.
- Expose closed-election tallying through an owner-authorized workflow with execution status, failure reporting, stored results, and controlled downloads. Separate summary results from participation lists and anonymized raw ballots.
Expand Down
9 changes: 9 additions & 0 deletions v3/queries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ election:
WHERE i.eid = ?
ORDER BY m.pid

q_voters: |
SELECT m.pid, p.name, COUNT(*) AS issue_count
FROM mayvote m
JOIN issue i ON m.iid = i.iid
JOIN person p ON m.pid = p.pid
WHERE i.eid = ?
GROUP BY m.pid, p.name
ORDER BY m.pid

q_closed_election_ids: SELECT eid FROM election WHERE closed = 1
q_open_election_ids: SELECT eid FROM election WHERE salt IS NOT NULL AND (closed IS NULL OR closed = 0)

Expand Down
134 changes: 134 additions & 0 deletions v3/server/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import logging
import random
import secrets
from urllib.parse import quote
from contextlib import closing

from easydict import EasyDict as edict
Expand Down Expand Up @@ -58,6 +59,8 @@
T_BAD_EID = APP.load_template(TEMPLATES / 'e_bad_eid.ezt')
T_BAD_IID = APP.load_template(TEMPLATES / 'e_bad_iid.ezt')
T_BAD_PID = APP.load_template(TEMPLATES / 'e_bad_pid.ezt')
T_MANAGE_VOTERS = APP.load_template(TEMPLATES / 'manage-voters.ezt')
T_MANAGE_VOTER = APP.load_template(TEMPLATES / 'manage-voter.ezt')


def rewrite_description(issue):
Expand Down Expand Up @@ -258,6 +261,9 @@ async def loader(eid, *args, **kwargs):
except steve.election.ElectionBadState:
quart.abort(409, 'The election is not in the required state')

except steve.election.VoterNotFound:
quart.abort(404, 'This person is not on the election voter list')

return loader


Expand Down Expand Up @@ -410,6 +416,134 @@ async def manage_page(election):
return result


async def voter_management_data(election, *, pids='', selected=None, error=None):
result = await basic_info()
result.title = 'Manage Voters'
result.eid = election.eid
result.election = election.get_metadata()
result.issues = election.list_issues()
result.voters = election.list_voters()
for voter in result.voters:
voter.name = voter.name or voter.pid
voter.url_pid = quote(voter.pid, safe='')
result.voter_count = len(result.voters)
result.pids = pids
result.error = error
for issue in result.issues:
issue.selected = ezt.boolean(selected is None or issue.iid in selected)
return result


@APP.get('/manage-voters/<eid>')
@asfquart.auth.require({R.committer})
@load_election
@require_election_owner
@APP.use_template(TEMPLATES / 'manage-voters.ezt')
async def manage_voters_page(election):
return await voter_management_data(election)


@APP.post('/do-add-voters/<eid>')
@asfquart.auth.require({R.committer})
@load_election
@require_election_owner
async def do_add_voters_endpoint(election):
form = await quart.request.form
raw_pids = form.get('pids', '')
pids = list(dict.fromkeys(raw_pids.replace(',', ' ').split()))
iids = form.getlist('issue')
try:
election.add_voters(pids, iids)
except (
ValueError,
steve.persondb.PersonNotFound,
steve.election.IssueNotFound,
) as ex:
if isinstance(ex, steve.persondb.PersonNotFound):
error = f'ASF ID "{ex.pid}" is not in the voter directory. No voters were added.'
elif isinstance(ex, steve.election.IssueNotFound):
error = (
'An issue is no longer available. Review your selection and try again.'
)
else:
error = str(ex)
data = await voter_management_data(
election, pids=raw_pids, selected=iids, error=error
)
return quart.Response(
asfquart.utils.render(T_MANAGE_VOTERS, data),
status=400,
mimetype='text/html',
)
await flash_success(f'Updated voter eligibility for {len(pids)} ASF IDs.')
return quart.redirect(f'/manage-voters/{election.eid}', code=303)


async def voter_editor_data(election, pid, *, selected=None, error=None):
eligible = election.get_voter_issues(pid)
result = await basic_info()
result.title = 'Edit Voter Eligibility'
result.eid = election.eid
result.election = election.get_metadata()
result.pid = pid
result.url_pid = quote(pid, safe='')
result.error = error
result.issues = election.list_issues()
for issue in result.issues:
issue.selected = ezt.boolean(
issue.iid in (eligible if selected is None else selected)
)
return result


@APP.get('/manage-voter/<eid>/<pid>')
@asfquart.auth.require({R.committer})
@load_election
@require_election_owner
@APP.use_template(TEMPLATES / 'manage-voter.ezt')
async def manage_voter_page(election, pid):
return await voter_editor_data(election, pid)


@APP.post('/do-set-voter/<eid>/<pid>')
@asfquart.auth.require({R.committer})
@load_election
@require_election_owner
async def do_set_voter_endpoint(election, pid):
if not election.is_editable():
quart.abort(409, 'The voter list is frozen')
form = await quart.request.form
iids = form.getlist('issue')
if not iids:
error = (
'Select at least one issue, or use Remove voter to remove all eligibility.'
)
else:
try:
election.set_voter_issues(pid, iids)
except steve.election.IssueNotFound:
error = (
'An issue is no longer available. Review your selection and try again.'
)
else:
await flash_success(f'Updated eligibility for {pid}.')
return quart.redirect(f'/manage-voters/{election.eid}', code=303)
data = await voter_editor_data(election, pid, selected=iids, error=error)
return quart.Response(
asfquart.utils.render(T_MANAGE_VOTER, data), status=400, mimetype='text/html'
)


@APP.post('/do-remove-voter/<eid>/<pid>')
@asfquart.auth.require({R.committer})
@load_election
@require_election_owner
async def do_remove_voter_endpoint(election, pid):
election.set_voter_issues(pid, [])
await flash_success(f'Removed {pid} from this election.')
return quart.redirect(f'/manage-voters/{election.eid}', code=303)


@APP.get('/manage-stv/<eid>/<iid>')
@asfquart.auth.require({R.committer}) ### need general solution
@load_election_issue
Expand Down
40 changes: 40 additions & 0 deletions v3/server/templates/manage-voter.ezt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
[include "header.ezt"]
<main class="container py-4">
<a href="/manage-voters/[eid]">Back to voter list</a>
<h1 class="mt-3">[title]</h1>
<p class="lead">[pid] &middot; [election.title]</p>
[include "flashes.ezt"]
[if-any error]<div class="alert alert-danger" role="alert">[error]</div>[end]
[is election.state "editable"]
<form method="POST" action="/do-set-voter/[eid]/[url_pid]">
<input type="hidden" name="csrf_token" value="[csrf_token]">
<fieldset class="mb-3">
<legend class="h4">Eligible issues</legend>
[for issues]
<label class="d-flex gap-2 align-items-start py-2" for="edit-issue-[issues.iid]">
<input id="edit-issue-[issues.iid]" class="form-check-input flex-shrink-0" type="checkbox" name="issue" value="[issues.iid]" [if-any issues.selected]checked[end]>
<span>[issues.title]</span>
</label>
[end]
</fieldset>
<button type="submit" class="btn btn-primary">Save eligibility</button>
</form>
<section class="border-top mt-4 pt-4" aria-labelledby="remove-voter-heading">
<h2 id="remove-voter-heading" class="h4">Remove voter</h2>
<p>This removes [pid] from every issue in this election. Their directory record and eligibility in other elections are kept.</p>
<form method="POST" action="/do-remove-voter/[eid]/[url_pid]">
<input type="hidden" name="csrf_token" value="[csrf_token]">
<button type="submit" class="btn btn-outline-danger">Remove voter</button>
</form>
</section>
[else]
<div class="alert alert-info">This election has opened. Its voter list is frozen and cannot be changed.</div>
<h2 class="h4">Eligible issues</h2>
<ul class="list-group">
[for issues]
[if-any issues.selected]<li class="list-group-item">[issues.title]</li>[end]
[end]
</ul>
[end]
</main>
[include "footer.ezt"]
68 changes: 68 additions & 0 deletions v3/server/templates/manage-voters.ezt
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
[include "header.ezt"]
<main class="container py-4">
<a href="/manage/[eid]">Back to election</a>
<h1 class="mt-3">[title]</h1>
<p class="lead">[election.title]</p>
[include "flashes.ezt"]
[if-any error]<div class="alert alert-danger" role="alert">[error]</div>[end]
[is election.state "editable"]
<p class="text-muted">Eligibility is assigned per issue. New issues start with no voters; adding an issue does not extend existing eligibility.</p>
[else]
<div class="alert alert-info">This election has opened. Its voter list is frozen and cannot be changed.</div>
[end]
<div class="row g-4">
<section class="col-lg-8" aria-labelledby="voter-list-heading">
<h2 id="voter-list-heading" class="h4">Eligible voters <span class="text-muted">([voter_count])</span></h2>
[if-any voters]
<div class="table-responsive">
<table class="table align-middle">
<thead><tr><th scope="col">ASF ID</th><th scope="col">Name</th><th scope="col">Eligible issues</th><th scope="col"><span class="visually-hidden">Actions</span></th></tr></thead>
<tbody>
[for voters]
<tr>
<th scope="row">[voters.pid]</th>
<td>[voters.name]</td>
<td>[voters.issue_count]</td>
<td><a class="btn btn-outline-primary" href="/manage-voter/[eid]/[voters.url_pid]">[is election.state "editable"]Edit eligibility[else]View eligibility[end]<span class="visually-hidden"> for [voters.pid]</span></a></td>
</tr>
[end]
</tbody>
</table>
</div>
[else]
<p class="text-muted py-3">No voters are eligible for this election yet.</p>
[end]
</section>
[is election.state "editable"]
<section class="col-lg-4" aria-labelledby="add-voters-heading">
<div class="card">
<div class="card-body">
<h2 id="add-voters-heading" class="h4">Add voters</h2>
[if-any issues]
<form method="POST" action="/do-add-voters/[eid]">
<input type="hidden" name="csrf_token" value="[csrf_token]">
<label for="voter-ids" class="form-label">ASF IDs</label>
<textarea id="voter-ids" name="pids" class="form-control" rows="5" required aria-describedby="voter-ids-help">[pids]</textarea>
<p id="voter-ids-help" class="form-text">Separate IDs with spaces, commas, or new lines. IDs must match the voter directory exactly. Unknown IDs reject the whole batch.</p>
<fieldset class="mb-3">
<legend class="h6">Eligible issues</legend>
[for issues]
<label class="d-flex gap-2 align-items-start py-2" for="add-issue-[issues.iid]">
<input id="add-issue-[issues.iid]" class="form-check-input flex-shrink-0" type="checkbox" name="issue" value="[issues.iid]" [if-any issues.selected]checked[end]>
<span>[issues.title]</span>
</label>
[end]
</fieldset>
<p class="form-text">Existing eligibility is preserved. Repeating an ID does not create a duplicate.</p>
<button type="submit" class="btn btn-primary">Add voters</button>
</form>
[else]
<p class="mb-0">Add an issue on the <a href="/manage/[eid]">election page</a> before assigning voters.</p>
[end]
</div>
</div>
</section>
[end]
</div>
</main>
[include "footer.ezt"]
7 changes: 7 additions & 0 deletions v3/server/templates/manage.ezt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
<h2>[e_title]</h2>
</div>

<div class="my-3">
<a href="/manage-voters/[eid]" class="btn btn-outline-primary">Manage voters</a>
[is e_state "editable"]
<p class="form-text mb-0">New issues start with no eligible voters. Assign their voters before opening the election.</p>
[end]
</div>

<!-- CSRF token for JS fetch calls -->
<input type="hidden" id="csrf-token" value="[csrf_token]">

Expand Down
Loading
Loading