Fixed a number of bugs that arose from merging the code from Bens fork. Added an extra step when voting which asks the user to confirm their voting choices. The voter now gets an email when they've successfully submitted thier vote.

This commit is contained in:
vince0656 2018-08-31 19:39:12 +01:00
parent fc0d0ea21c
commit a168ba9e28
6 changed files with 281 additions and 75 deletions

View file

@ -211,7 +211,7 @@ class Ballot(models.Model):
class EncBallot(models.Model): class EncBallot(models.Model):
handle = models.CharField(primary_key=True, default=uuid.uuid4, editable=False, max_length=255) handle = models.CharField(primary_key=True, default=uuid.uuid4, editable=False, max_length=255)
ballot = models.CharField(max_length=4096) ballot = models.CharField(max_length=10240)
# Implements the new binary encoding scheme # Implements the new binary encoding scheme

View file

@ -212,6 +212,22 @@ def email_voters_vote_url(voters, event):
voter.send_email(email_subject, email_body) voter.send_email(email_subject, email_body)
@task()
def email_voting_success(voter, ballotHandle, eventTitle):
email_subject = "Vote(s) received for Event '" + eventTitle + "'"
# Plain text email - this could be replaced for a HTML-based email in the future
email_body_base = str("")
email_body_base += "Dear Voter,\n\n"
email_body_base += "Thank you for your vote(s) for the event: " + eventTitle + ". This has been securely encrypted "
email_body_base += "and anonymously stored in our system.\n\n"
email_body_base += "For your reference, the identifier for your selected ballot is:\n"
email_body_base += ballotHandle
email_body_base += get_email_sign_off()
voter.send_email(email_subject, email_body_base)
''' '''
Updates the EID of an event to contain 2 event IDs: a human readable one (hr) and a crypto one (GP from param()) Updates the EID of an event to contain 2 event IDs: a human readable one (hr) and a crypto one (GP from param())
''' '''

View file

@ -18,6 +18,7 @@ from allauthdemo.auth.models import DemoUser
from .tasks import email_trustees_prep, update_EID, generate_combpk, event_ended, create_ballots from .tasks import email_trustees_prep, update_EID, generate_combpk, event_ended, create_ballots
from .tasks import create_ballots_for_poll, email_voters_vote_url, combine_decryptions_and_tally, combine_encrypted_votes from .tasks import create_ballots_for_poll, email_voters_vote_url, combine_decryptions_and_tally, combine_encrypted_votes
from .tasks import email_voting_success
from .utils.EventModelAdaptor import EventModelAdaptor from .utils.EventModelAdaptor import EventModelAdaptor
@ -220,7 +221,9 @@ def event_vote(request, event_id, poll_id):
ballot.selection = selection ballot.selection = selection
ballot.save() ballot.save()
combine_encrypted_votes.delay(email_key[0].user, poll) voter = email_key[0].user
combine_encrypted_votes.delay(voter, poll)
email_voting_success.delay(voter, handle_json, event.title)
if next_poll_uuid: if next_poll_uuid:
return HttpResponseRedirect(reverse('polls:event-vote', kwargs={'event_id': event.uuid, return HttpResponseRedirect(reverse('polls:event-vote', kwargs={'event_id': event.uuid,

View file

@ -34,8 +34,6 @@
{% endif %} {% endif %}
</span> </span>
<br/> <br/>
<span><strong>Number of polls for this event:</strong> {{ poll_count }}</span>
<br/>
<br/> <br/>
<span><strong>Instructions:</strong> <span><strong>Instructions:</strong>
You will be shown each poll for this event one by one where you will need to make a selection for the current You will be shown each poll for this event one by one where you will need to make a selection for the current
@ -61,26 +59,21 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Options</strong></div> <div class="panel-heading"><strong>Options</strong></div>
<div class="panel panel-body"> <div class="panel panel-body">
{% comment %}<select class="radio-inline select form-control" id="poll-options" name="options">
{% load custom_filters_tags %}
<option value="{{ -1|get_ballot_value:object.options.all.count }}">Please Select...</option>
{% for option in object.options.all %}
<option value="{{forloop.counter|get_ballot_value:object.options.all.count}}">{{ option.choice_text }}</option>
{% endfor %}
</select>{% endcomment %}
{% for option in object.options.all %} {% for option in object.options.all %}
<div class="checkbox"> <div class="checkbox">
{% load custom_filters_tags %} {% load custom_filters_tags %}
<label><input type="checkbox" value="{{forloop.counter|get_ballot_value:object.options.all.count}}">{{ option.choice_text }}</label> <label id="{{forloop.counter|get_ballot_value:object.options.all.count}}">
<input type="checkbox" value="{{forloop.counter|get_ballot_value:object.options.all.count}}">{{ option.choice_text }}
</label>
</div> </div>
{% endfor %} {% endfor %}
<hr/> <hr/>
<div id="ballot-gen-progress-area"> <div id="ballot-gen-progress-area">
<button id="gen-ballots-btn" class="btn btn-primary">Generate Ballots</button> <button id="gen-ballots-btn" class="btn btn-primary">Begin Voting</button>
<!-- Progress bar which is used during encryption --> <!-- Progress bar which is used during encryption -->
<h4 id="progress-bar-description" class="hidden">Generating Ballots...</h4> <h4 id="progress-bar-description" class="hidden">Generating 2 Digital Ballots. Please wait...</h4>
<div id="progress-bar-container" class="progress hidden"> <div id="progress-bar-container" class="progress hidden">
<div id="progress-bar" class="progress-bar" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" style="width: 0%;"> <div id="progress-bar" class="progress-bar progress-bar-striped" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" style="width: 0%;">
<span class="sr-only">70% Complete</span> <span class="sr-only">70% Complete</span>
</div> </div>
</div> </div>
@ -124,8 +117,11 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="cancelVoteBtn" type="button" class="btn btn-danger" data-dismiss="modal">Cancel</button> <button id="nextDialogBtn" type="button" class="btn btn-primary">Next</button>
<button id="cancelDialogBtn" type="button" class="btn btn-danger" data-dismiss="modal">Cancel</button>
<button id="closeDialogBtn" type="button" class="btn btn-primary hidden" data-dismiss="modal">Close</button> <button id="closeDialogBtn" type="button" class="btn btn-primary hidden" data-dismiss="modal">Close</button>
<button id="startOverDialogBtn" type="button" class="btn btn-danger hidden" data-dismiss="modal">Start Over</button>
<button id="submitDialogBtn" type="button" class="btn btn-success hidden">Submit</button>
</div> </div>
</div> </div>

View file

@ -209,3 +209,29 @@ input[type="file"] {
width: 54%; width: 54%;
margin: 0 auto; margin: 0 auto;
} }
img[src^="data"] {
width: 45%;
margin-left: auto;
margin-right: auto;
}
.containerMarginTop {
margin-top: 1em;
}
.skDIV {
width: 75%;
margin: 0 auto;
height: 3em;
background-color: darkslategrey;
color: white;
font-weight: bold;
}
.skDIV > p {
text-align: center;
height: 3em;
vertical-align: middle;
line-height: 2.9em;
}

View file

@ -1,4 +1,11 @@
var dialogOpen = false; var dialogOpen = false;
var DIALOG_BTN_STATES = {
STEP_1: 1,
STEP_2: 2,
STEP_3: 3,
VOTE_SUCCESS: 4,
VOTE_ERROR: 5
};
function showDialogWithText(titleTxt, bodyTxt) { function showDialogWithText(titleTxt, bodyTxt) {
var modalDialog = $('#modalDialog'); var modalDialog = $('#modalDialog');
@ -10,14 +17,55 @@ function showDialogWithText(titleTxt, bodyTxt) {
var p = document.createElement("p"); var p = document.createElement("p");
p.innerHTML = bodyTxt; p.innerHTML = bodyTxt;
body.empty(); body.empty();
body.append( p ); body.append(p);
if(!dialogOpen) { if (!dialogOpen) {
modalDialog.modal('toggle'); modalDialog.modal('toggle');
dialogOpen = true; dialogOpen = true;
} }
} }
function updateDialogButtons(state) {
// Trigger the btn selectors once here
let nextDialogBtn = $('#nextDialogBtn');
let cancelDialogBtn = $('#cancelDialogBtn');
let closeDialogBtn = $('#closeDialogBtn');
let startOverDialogBtn = $('#startOverDialogBtn');
let submitDialogBtn = $('#submitDialogBtn');
switch(state) {
case DIALOG_BTN_STATES.STEP_1:
nextDialogBtn.removeClass("hidden");
cancelDialogBtn.removeClass("hidden");
closeDialogBtn.addClass("hidden");
startOverDialogBtn.addClass("hidden");
submitDialogBtn.addClass("hidden");
break;
case DIALOG_BTN_STATES.STEP_2:
nextDialogBtn.addClass("hidden");
cancelDialogBtn.removeClass("hidden");
closeDialogBtn.addClass("hidden");
startOverDialogBtn.addClass("hidden");
submitDialogBtn.addClass("hidden");
break;
case DIALOG_BTN_STATES.STEP_3:
nextDialogBtn.addClass("hidden");
cancelDialogBtn.addClass("hidden");
closeDialogBtn.addClass("hidden");
startOverDialogBtn.removeClass("hidden");
submitDialogBtn.removeClass("hidden");
break;
case DIALOG_BTN_STATES.VOTE_SUCCESS:
case DIALOG_BTN_STATES.VOTE_ERROR:
nextDialogBtn.addClass("hidden");
cancelDialogBtn.addClass("hidden");
closeDialogBtn.removeClass("hidden");
startOverDialogBtn.addClass("hidden");
submitDialogBtn.addClass("hidden");
break;
}
}
// This should stop people ticking more than the maximum permitted // This should stop people ticking more than the maximum permitted
function updateCheckboxInteractivity() { function updateCheckboxInteractivity() {
var inputs = $("label input[type=checkbox]"); var inputs = $("label input[type=checkbox]");
@ -65,10 +113,6 @@ function isVotingInputValid() {
valid = false; valid = false;
} }
if(selectedCount < MAX_SELECTIONS) {
valid = false;
}
// This will highlight when people haven't selected enough options // This will highlight when people haven't selected enough options
if(!valid) { if(!valid) {
@ -86,6 +130,7 @@ function isVotingInputValid() {
let titleTxt = 'Voting Error'; let titleTxt = 'Voting Error';
showDialogWithText(titleTxt, errText); showDialogWithText(titleTxt, errText);
updateDialogButtons(DIALOG_BTN_STATES.VOTE_ERROR);
return; return;
} }
@ -259,6 +304,18 @@ function SHA256Hash(bytes, toStr) {
} }
function generateBallots() { function generateBallots() {
// Get the user's selected option
let inputs = $("label input[type=checkbox]");
let selectedOption = "";
inputs.each(function() {
let input = $(this);
if(input.prop('checked')) {
selectedOption = input.val();
selectedOption = document.getElementById(selectedOption).innerText;
}
});
// Generate Ballot A and Ballot B to be displayed to the user // Generate Ballot A and Ballot B to be displayed to the user
// This fn starts the process // This fn starts the process
var ballotA = generateBallot(); var ballotA = generateBallot();
@ -271,11 +328,12 @@ function generateBallots() {
var ballotB = generateBallot(); var ballotB = generateBallot();
progressBar.setAttribute("style", "width: 100%;"); progressBar.setAttribute("style", "width: 100%;");
showFirstQRCode(ballotA, ballotB); showFirstQRCode(ballotA, ballotB, selectedOption);
}, 150); }, 150);
} }
function showFirstQRCode(ballotA, ballotB) { // Called in stage 1 of 3 in the voting process
function showFirstQRCode(ballotA, ballotB, selectedOption) {
var ballots = new Array(ballotA, ballotB); var ballots = new Array(ballotA, ballotB);
var ballotHashes = new Array(2); var ballotHashes = new Array(2);
@ -287,14 +345,19 @@ function showFirstQRCode(ballotA, ballotB) {
var modalDialog = $('#modalDialog'); var modalDialog = $('#modalDialog');
var title = modalDialog.find('.modal-title'); var title = modalDialog.find('.modal-title');
var body = modalDialog.find('.modal-body'); var body = modalDialog.find('.modal-body');
var footer = modalDialog.find('.modal-footer');
body.empty(); body.empty();
title.text('Please Scan this QR Code'); title.text('Step 1 of 3: Link Your Vote');
let pleaseScanP = document.createElement('p');
pleaseScanP.innerHTML = "Please scan the following QR code from your DEMOS 2 mobile application:";
let QRDiv = document.createElement('div');
var QRCodeImg = document.createElement('img'); var QRCodeImg = document.createElement('img');
QRCodeImg.setAttribute('class', 'QR-code'); QRCodeImg.setAttribute('class', 'QR-code');
QRCodeImg.setAttribute('id', "qr-img");
new QRCode(QRCodeImg, ballotHashes[0] + ';' + ballotHashes[1]); new QRCode(QRCodeImg, ballotHashes[0] + ';' + ballotHashes[1]);
QRDiv.append(QRCodeImg);
// ---------------------------------------------- // ----------------------------------------------
@ -315,37 +378,31 @@ function showFirstQRCode(ballotA, ballotB) {
// ----------------------------------------------- // -----------------------------------------------
body.append(QRCodeImg); body.append(pleaseScanP);
body.append(QRDiv);
body.append(hashGroupDiv); body.append(hashGroupDiv);
var closeButton = $('close-button'); // Prepare the appropriate dialog buttons
closeButton.removeClass('btn-success'); updateDialogButtons(DIALOG_BTN_STATES.STEP_1);
closeButton.addClass('btn-danger');
closeButton.text("Close without submitting vote");
var nextButton = document.createElement('button'); if(!dialogOpen) {
nextButton.setAttribute('type', 'button'); modalDialog.modal('toggle');
nextButton.setAttribute('id', 'next-button'); dialogOpen = true;
nextButton.setAttribute('class', 'btn btn-default'); }
nextButton.innerHTML = "Next";
footer.prepend(nextButton); $('#nextDialogBtn').click(function(e) {
showBallotChoiceDialog(ballots, ballotHashes, selectedOption, modalDialog);
modalDialog.modal('show');
$('#next-button').click(function(e) {
showBallotChoiceDialog(ballots);
}); });
} }
function showBallotChoiceDialog(ballots) { // Called in stage 2 of 3 in the voting process
function showBallotChoiceDialog(ballots, ballotHashes, selectedOption, dialog) {
// Display the ballot choice dialog // Display the ballot choice dialog
var modalDialog = $('#modalDialog'); var title = dialog.find('.modal-title');
var title = modalDialog.find('.modal-title'); var body = dialog.find('.modal-body');
var body = modalDialog.find('.modal-body');
body.empty(); body.empty();
title.text('Please Select a Ballot'); title.text('Step 2 of 3: Select a Ballot');
// Generate the body of the dialog which consists of a button for A and for B // Generate the body of the dialog which consists of a button for A and for B
var choiceGroupDiv = document.createElement('div'); var choiceGroupDiv = document.createElement('div');
@ -363,21 +420,104 @@ function showBallotChoiceDialog(ballots) {
btnChoiceB.innerHTML = 'B'; btnChoiceB.innerHTML = 'B';
choiceGroupDiv.append(btnChoiceB); choiceGroupDiv.append(btnChoiceB);
body.append(choiceGroupDiv); // ----------------------------------------------
modalDialog.modal('show'); var hashGroupDiv = document.createElement('div');
var br = document.createElement('br');
hashGroupDiv.append( br );
var hashA = document.createElement("span");
hashA.innerHTML = "Hash A: " + ballotHashes[0];
hashGroupDiv.append( hashA );
var br2 = document.createElement('br');
hashGroupDiv.append( br2 );
var hashB = document.createElement("span");
hashB.innerHTML = "Hash B: " + ballotHashes[1];
hashGroupDiv.append( hashB );
// -----------------------------------------------
body.append(choiceGroupDiv);
body.append(hashGroupDiv);
// Register callback functions for the selection of either A or B // Register callback functions for the selection of either A or B
$('#choice-A').click(function(e) { $('#choice-A').click(function(e) {
sendBallotsToServer(ballots[0], ballots[1]); showSelectionConfirmationDialog("A", ballots[0], ballotHashes[0], ballots[1], selectedOption, dialog);
}); });
$('#choice-B').click(function(e) { $('#choice-B').click(function(e) {
sendBallotsToServer(ballots[1], ballots[0]); showSelectionConfirmationDialog("B", ballots[1], ballotHashes[1], ballots[0], selectedOption, dialog);
}); });
updateDialogButtons(DIALOG_BTN_STATES.STEP_2);
if(!dialogOpen) {
modalDialog.modal('toggle');
dialogOpen = true;
}
} }
function sendBallotsToServer(selection, alt) { // Called in stage 3 of 3 in the voting process
function showSelectionConfirmationDialog(selection, selectedBallot, selectedBallotHash,
otherBallot, selectedOption, dialog) {
let title = dialog.find('.modal-title');
let body = dialog.find('.modal-body');
body.empty();
title.text("Step 3 of 3: Confirm Ballot Selection");
// Ballot detail section
let selectedInfoSecDiv = document.createElement('div');
let detailsP = document.createElement('p');
detailsP.innerHTML = "Please check the following details are correct: ";
selectedInfoSecDiv.append(detailsP);
let ul = document.createElement('ul');
let selectedOptionLi = document.createElement('li');
selectedOptionLi.innerHTML = "Selected Option: " + selectedOption;
let ballotSelectionLi = document.createElement('li');
ballotSelectionLi.innerHTML = "Selected Ballot: " + selection;
let ballotHashLi = document.createElement('li');
ballotHashLi.innerHTML = "SHA256 Ballot Fingerprint: " + selectedBallotHash;
ul.append(selectedOptionLi);
ul.append(ballotSelectionLi);
ul.append(ballotHashLi);
selectedInfoSecDiv.append(ul);
// Instruction section
let instructionsP = document.createElement('p');
instructionsP.innerHTML = "If you are happy with your selection you can click on the 'Submit' button below to store"
+ " your vote. Otherwise you can select 'Start Over' to go through the voting process again.";
selectedInfoSecDiv.append(instructionsP);
let additionalInstructionsP = document.createElement('p');
additionalInstructionsP.innerHTML = "You can overwrite your vote later by re-visiting this page and voting again.";
selectedInfoSecDiv.append(additionalInstructionsP);
body.append(selectedInfoSecDiv);
// Update the dialog buttons accordingly
updateDialogButtons(DIALOG_BTN_STATES.STEP_3);
$('#submitDialogBtn').click(function() {
// Dispatch the ballot to the server
sendBallotsToServer(selection, selectedBallot, otherBallot);
});
if(!dialogOpen) {
modalDialog.modal('toggle');
dialogOpen = true;
}
}
function sendBallotsToServer(selection, selectedBallot, otherBallot) {
$.ajaxSetup({ $.ajaxSetup({
beforeSend: function(xhr, settings) { beforeSend: function(xhr, settings) {
if (!csrfSafeMethod(settings.type) && !this.crossDomain) { if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
@ -416,14 +556,15 @@ function sendBallotsToServer(selection, alt) {
var pollNum = $('#poll-num').text(); var pollNum = $('#poll-num').text();
var ballotID = encodeURIComponent(btoa(JSON.stringify({voterID: voterID, eventID: eventID, pollNum: pollNum}))); var ballotID = encodeURIComponent(btoa(JSON.stringify({voterID: voterID, eventID: eventID, pollNum: pollNum})));
// TODO: Generate a SK rather than using a static one. UUID generated server side and then injected JS side?
var SK = "temporary"; var SK = "temporary";
var encAlt = sjcl.encrypt(SK, JSON.stringify(alt)); var encAlt = sjcl.encrypt(SK, JSON.stringify(otherBallot));
selection = JSON.stringify(selection); let selectedBallotAsStr = JSON.stringify(selectedBallot);
$.ajax({ $.ajax({
type : "POST", type : "POST",
url : window.location, url : window.location,
data : { handle: ballotID, encBallot: encAlt, ballot: selection }, data : { handle: ballotID, encBallot: encAlt, ballot: selectedBallotAsStr, selection: selection },
success : function(){ success : function(){
onAfterBallotSend(ballotID, SK); onAfterBallotSend(ballotID, SK);
} }
@ -432,40 +573,64 @@ function sendBallotsToServer(selection, alt) {
// Called once the ballot has been sent to the back-end and dialog has closed // Called once the ballot has been sent to the back-end and dialog has closed
function onAfterBallotSend(ballotID, SK) { function onAfterBallotSend(ballotID, SK) {
let titleText = 'Vote Successfully Received';
let bodyText = "Thank you for voting! Your secret key is '"+SK+"'. Make sure to scan this QR code with your phone before closing this window.";
if(POLL_NUM !== POLL_COUNT) {
bodyText += " You can vote on the next poll by closing down this dialog and clicking 'Next Poll'.";
}
// With one ballot selected, we can display a QR code of the ballot ID // With one ballot selected, we can display a QR code of the ballot ID
var modalDialog = $('#modalDialog'); var modalDialog = $('#modalDialog');
var title = modalDialog.find('.modal-title'); var title = modalDialog.find('.modal-title');
var body = modalDialog.find('.modal-body'); var body = modalDialog.find('.modal-body');
title.text(titleText);
body.empty(); body.empty();
var p = document.createElement("p"); let titleText = 'Vote Successfully Received';
p.innerHTML = bodyText; title.text(titleText);
body.append(p);
// Generate the body of the dialog which displays the unselected ballot QR code // Add the first section: Instructions on next steps
let instructions1Txt = "Thank you for voting! Please note down the ballot identifier by scanning " +
"this QR code using the DEMOS2 mobile application: ";
var instructions1P = document.createElement("p");
instructions1P.innerHTML = instructions1Txt;
body.append(instructions1P);
// Add the second section: QR code that contains the ballot identifier
var QRCodeImg = document.createElement('img'); var QRCodeImg = document.createElement('img');
QRCodeImg.setAttribute('class', 'QR-code'); QRCodeImg.setAttribute('class', 'QR-code');
new QRCode(QRCodeImg, ballotID); new QRCode(QRCodeImg, ballotID);
body.append(QRCodeImg); body.append(QRCodeImg);
var closeButton = $('#close-button'); // Add the third section: instructions on Ballot ID and SK
closeButton.removeClass('btn-danger'); let instructions2Div = document.createElement('div');
closeButton.addClass('btn-success'); instructions2Div.setAttribute('class', 'containerMarginTop');
closeButton.text("Close");
if(POLL_NUM == POLL_COUNT) { let instructions2Txt = "You will also be emailed the ballot identifier. However, you will need to note down the following " +
$('#next-button').hide(); "secret in order to later verify your ballot was recorded as cast: ";
let instructions2P = document.createElement('p');
instructions2P.innerHTML = instructions2Txt;
instructions2Div.append(instructions2P);
body.append(instructions2Div);
// Add the fourth section: SK plain text
let SKContainerDiv = document.createElement('div');
SKContainerDiv.setAttribute("class", "containerMarginTop");
let SKDiv = document.createElement('div');
SKDiv.setAttribute("class", "skDIV");
let SKP = document.createElement('p');
SKP.innerHTML = SK;
SKDiv.append(SKP);
SKContainerDiv.append(SKDiv);
body.append(SKContainerDiv);
// Conditional fifth section: Instructions on how to vote on the next poll for the event
if(POLL_NUM !== POLL_COUNT) {
let instructions3Txt = "You can vote on the next poll by closing down this dialog and clicking 'Next Poll'.";
let instructions3P = document.createElement('p');
instructions3P.innerHTML = instructions3Txt;
body.append(instructions3P);
} }
modalDialog.modal('show'); updateDialogButtons(DIALOG_BTN_STATES.VOTE_SUCCESS);
} }
$('#modalDialog').on('hide.bs.modal', function (e) { $('#modalDialog').on('hide.bs.modal', function (e) {
@ -474,7 +639,7 @@ $('#modalDialog').on('hide.bs.modal', function (e) {
if(titleText.indexOf("Received") > -1) { if(titleText.indexOf("Received") > -1) {
// Update page to reflect the fact that a vote has taken place // Update page to reflect the fact that a vote has taken place
location.reload(); location.reload();
} else { } else if (titleText.indexOf("Error") === -1) {
// Reset poll voting to allow user to vote again // Reset poll voting to allow user to vote again
progressBar.setAttribute("style", "width: 0%;"); progressBar.setAttribute("style", "width: 0%;");
$('#gen-ballots-btn').toggleClass("hidden"); $('#gen-ballots-btn').toggleClass("hidden");