Skip to content

Implement Single Transferable Voting #708

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 39 commits into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b522630
Make multiple question types
horcsinbalint Feb 6, 2025
48aef5a
.
horcsinbalint Feb 6, 2025
3930701
.
horcsinbalint Feb 6, 2025
81d93c1
Add sortable.js dependency
BertalanD Feb 6, 2025
cffda85
Display ranked choice anonymous questions
BertalanD Feb 6, 2025
e48317f
Hook up ranked choice to general assembly UI
BertalanD Feb 7, 2025
41c52f2
.
BertalanD Feb 7, 2025
915f9d5
.
horcsinbalint Feb 7, 2025
6524b7e
.
horcsinbalint Feb 7, 2025
f484e7f
.
horcsinbalint Feb 7, 2025
a17ab1d
.
horcsinbalint Feb 8, 2025
ffedd65
Update seeders
BertalanD Feb 9, 2025
d201b33
Display ties
BertalanD Feb 9, 2025
69180e5
Allow abstentions during ranked choice voting
BertalanD Feb 9, 2025
517c6e5
Actually use STV (and set quota)
BertalanD Feb 9, 2025
5f3c9d3
Refactor result viewing
BertalanD Feb 9, 2025
c7c90f2
Localize question creation
BertalanD Feb 9, 2025
df32ac9
Voting system should not be configurable
BertalanD Feb 9, 2025
a55a1d4
Localize ranked choice voting
BertalanD Feb 9, 2025
a12d814
Change Condorcet to custom fork
BertalanD Feb 10, 2025
7b8068f
Ensure that ballots with no votes don't contribute to quota
BertalanD Feb 12, 2025
56eddfc
Adding a table detailing the rounds of an STV count
viktorcsimma Feb 12, 2025
09fc13d
style: format code with PHP CS Fixer
deepsource-autofix[bot] Feb 12, 2025
81dfd94
style: format code with PHP CS Fixer
deepsource-autofix[bot] Feb 12, 2025
67b2927
Fix initials for multibyte UTF-8; full name in tooltip
BertalanD Feb 12, 2025
8865886
Bump condorcet to 026aa984
BertalanD Feb 12, 2025
cba3f1f
.
BertalanD Feb 12, 2025
af63219
Allow empty ranked choice votes
BertalanD Feb 12, 2025
d30078e
Fix number staying on in abstention -> ranking -> abstention drag
BertalanD Feb 12, 2025
8f37ae7
Add option to sort items by mouse click
BertalanD Feb 12, 2025
0768886
Update database/factories/QuestionFactory.php
BertalanD Feb 12, 2025
39be459
Merge branch 'development' into voting
BertalanD Feb 12, 2025
1dcd34f
Prettifying arrow buttons for ranking questions
viktorcsimma Feb 12, 2025
519362f
Fix nits
BertalanD Feb 12, 2025
096869b
.
BertalanD Feb 12, 2025
c8067e9
Adding an STV question with random ballots to the seeder
viktorcsimma Feb 13, 2025
2941e63
Further details to stats
viktorcsimma Feb 13, 2025
3bcc077
style: format code with PHP CS Fixer
deepsource-autofix[bot] Feb 13, 2025
17774e0
Revert regression
horcsinbalint Feb 13, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;

use Illuminate\Validation\Rule;
use App\Models\AnonymousQuestions\AnswerSheet;
use App\Models\Semester;
use App\Models\Question;
Expand All @@ -19,7 +19,7 @@
/**
* Controls actions related to anonymous questions.
*/
class AnonymousQuestionController extends Controller
class AnonymousQuestionController extends QuestionController
{
use HasPeriodicEvent;
/**
Expand Down Expand Up @@ -70,43 +70,7 @@ public function store(Request $request, Semester $semester)
abort(403, "tried to add a question to a closed semester");
}

$validator = Validator::make($request->all(), [
'title' => 'required|string',
'has_long_answers' => 'nullable|in:on',
'max_options' => 'exclude_if:has_long_answers,on|required|min:1',
'options' => 'exclude_if:has_long_answers,on|required|array|min:1',
'options.*' => 'exclude_if:has_long_answers,on|nullable|string|max:255',
]);
$hasLongAnswers = isset($request->has_long_answers);
if (!$hasLongAnswers) {
$options = array_filter($request->options, function ($s) {
return $s != null;
});
if (count($options) == 0) {
$validator->after(function ($validator) {
$validator->errors()->add('options', __('voting.at_least_one_option'));
});
}
}
$validator->validate();

$event = $this->periodicEventForSemester($semester);

$question = $semester->questions()->create([
'title' => $request->title,
'max_options' => $hasLongAnswers ? 0 : $request->max_options,
'has_long_answers' => $hasLongAnswers,
'opened_at' => $event?->start_date ?? null,
'closed_at' => $event?->end_date ?? null
]);
if (!$hasLongAnswers) {
foreach ($options as $option) {
$question->options()->create([
'title' => $option,
'votes' => 0
]);
}
}
$question = $this->createQuestion($request, $semester);

session()->put('section', $semester->id);
return redirect()->route('anonymous_questions.index_semesters')
Expand Down Expand Up @@ -157,22 +121,8 @@ public function storeAnswerSheet(Request $request, Semester $semester)
// we have to create a new one.
$answerSheet = AnswerSheet::createForCurrentUser($semester);

foreach($semester->questionsNotAnsweredBy(user()) as $question) {
// validation ensures we have answers
// to all of these questions
$answer = $validatedData[$question->formKey()];
if ($question->has_long_answers) {
$question->storeAnswers(user(), $answer, $answerSheet);
} elseif ($question->isMultipleChoice()) {
$options = array_map(
function (int $id) {return QuestionOption::find($id);},
$answer
);
$question->storeAnswers(user(), $options, $answerSheet);
} else {
$option = QuestionOption::find($answer);
$question->storeAnswers(user(), $option, $answerSheet);
}
foreach ($semester->questionsNotAnsweredBy(user()) as $question) {
$this->saveVoteForQuestion($question, $validatedData, $answerSheet);
}
});

Expand All @@ -193,4 +143,17 @@ public function exportAnswerSheets(Semester $semester)
'anonymous_questions_' . $semester->year . '_' . $semester->part . '.xlsx'
);
}

public function delete(Semester $semester, Question $question)
{
$this->authorize('administer', AnswerSheet::class);

if($question['parent_type'] != AnswerSheet::class) {
abort(400);
}
Comment on lines +151 to +153
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably irrrelevant with scopeBindings() as that ensurers the question is the child of the specified general assembly.


$question->delete();

return redirect(route('anonymous_questions.index_semesters'));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
use App\Models\QuestionOption;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;

class GeneralAssemblyQuestionController extends Controller
class GeneralAssemblyQuestionController extends QuestionController
{
/**
* Returns the 'new question' page.
Expand Down Expand Up @@ -37,33 +38,7 @@ public function store(Request $request, GeneralAssembly $generalAssembly)
abort(403, "tried to modify a general assembly which has been closed");
}

$validator = Validator::make($request->all(), [
'title' => 'required|string',
'max_options' => 'required|min:1',
'options' => 'required|array|min:1',
'options.*' => 'nullable|string|max:255',
]);
$options = array_filter($request->options, function ($s) {
return $s != null;
});
if (count($options) == 0) {
$validator->after(function ($validator) {
$validator->errors()->add('options', __('voting.at_least_one_option'));
});
}
$validator->validate();

$question = $generalAssembly->questions()->create([
'title' => $request->title,
'max_options' => $request->max_options,
'has_long_answers' => false
]);
foreach ($options as $option) {
$question->options()->create([
'title' => $option,
'votes' => 0
]);
}
$question = $this->createQuestion($request, $generalAssembly);

return redirect()->route('general_assemblies.questions.show', [
"general_assembly" => $generalAssembly,
Expand Down Expand Up @@ -121,47 +96,32 @@ public function saveVote(Request $request, GeneralAssembly $generalAssembly, Que
{
$this->authorize('vote', $question); //this also checks whether the user has already voted

if ($question->isMultipleChoice()) {
$validator = Validator::make($request->all(), [
'option' => 'array|max:' . $question->max_options,
'option.*' => 'exists:question_options,id',
'passcode' => 'string'
]);
if (!GeneralAssembly::isTemporaryPasscode($request->passcode)) {
$validator->after(function ($validator) {
$validator->errors()->add('passcode', __('voting.incorrect_passcode'));
});
}
$validator->validate();

$options = array();
foreach ($request->option as $oid) {
$option = QuestionOption::findOrFail($oid);
if ($option->question_id != $question->id) {
abort(401, "Tried to vote for an option which does not belong to the question");
$passcode_request = $request->validate(['passcode' => ['required', 'string',
function ($attribute, $value, $fail) {
if(!GeneralAssembly::isTemporaryPasscode($value)) {
$fail(__('voting.incorrect_passcode'));
}
}
array_push($options, $option);
}
$question->storeAnswers(user(), $options);
} else {
$validator = Validator::make($request->all(), [
'option' => 'exists:question_options,id',
'passcode' => 'string'
]);
if (!GeneralAssembly::isTemporaryPasscode($request->passcode)) {
$validator->after(function ($validator) {
$validator->errors()->add('passcode', __('voting.incorrect_passcode'));
});
}
$validator->validate();

$option = QuestionOption::findOrFail($request->option);
if ($option->question->id != $question->id) {
abort(401, "Tried to vote for an option which does not belong to the question");
}
$question->storeAnswers(user(), $option);
}
]
]);

$validatedData = $request->validate($question->validationRules());

$this->saveVoteForQuestion($question, $validatedData);

return redirect()->route('general_assemblies.show', $question->parent)->with('message', __('voting.successful_voting'));
}

public function delete(GeneralAssembly $generalAssembly, Question $question)
{
$this->authorize('administer', GeneralAssembly::class);

if($question['parent_type'] != GeneralAssembly::class) {
abort(400);
}

$question->delete();

return redirect(route('general_assemblies.show', ["general_assembly" => $generalAssembly]));
}
}
89 changes: 89 additions & 0 deletions app/Http/Controllers/StudentsCouncil/QuestionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace App\Http\Controllers\StudentsCouncil;

use Illuminate\Http\Request;
use Carbon\Carbon;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Validation\Rule;
use App\Models\AnonymousQuestions\AnswerSheet;
use App\Models\Semester;
use App\Models\Question;
use App\Models\QuestionOption;
use App\Models\GeneralAssemblies\GeneralAssembly;
use App\Utils\HasPeriodicEvent;
use App\Exports\UsersSheets\AnonymousQuestionsExport;

/**
* Controls actions related to anonymous questions.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment up-to-date?

*/
class QuestionController extends Controller
{
/**
* Saves a new question.
*/
protected function createQuestion(Request $request, Semester|GeneralAssembly $parent = null): Question
{
$validatedData = $request->validate([
'title' => 'required|string',
'question_type' => [
'required',
Rule::in(Question::QUESTION_TYPES)
],
'max_options' => ['required', 'min:1', Rule::excludeIf($request['question_type'] == QUESTION::TEXT_ANSWER), 'integer'],
'options' => ['required', 'min:1', Rule::excludeIf($request['question_type'] != QUESTION::SELECTION && $request['question_type'] != QUESTION::RANKING), 'array'],
'options.*' => ['required', 'min:1', 'max:255', Rule::excludeIf($request['question_type'] != QUESTION::SELECTION && $request['question_type'] != QUESTION::RANKING), 'string'],
]);
if ($validatedData['question_type'] == Question::SELECTION || $validatedData['question_type'] == Question::RANKING) {
$options = array_filter($validatedData['options'], function ($s) {
return $s != null;
});
if (count($options) == 0) {
$validator->after(function ($validator) {
$validator->errors()->add('options', __('voting.at_least_one_option'));
});
}
}
Comment on lines +28 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Potential reference to undefined $validator object
It appears that the method relies on $request->validate(...) but then attempts to use $validator->after(...) at lines 45–48, though $validator was never defined. This could cause an error at runtime. Consider explicitly creating a validator instance via Validator::make(...) if you need to add an after() callback.

- $validatedData = $request->validate([
-     ...
- ]);
- ...
- if (count($options) == 0) {
-     $validator->after(function ($validator) {
-         ...
-     });
- }

+ $validator = Validator::make($request->all(), [
+     ...
+ ]);
+ $validatedData = $validator->validate();
+ if (count($options) == 0) {
+     $validator->after(function ($validator) {
+         ...
+     });
+ }

Committable suggestion skipped: line range outside the PR's diff.

$question = $parent->questions()->create([
'title' => $validatedData['title'],
'max_options' => isset($validatedData['max_options']) ? $validatedData['max_options'] : null,
'question_type' => $validatedData['question_type'],
]);
if ($validatedData['question_type'] == Question::SELECTION || $validatedData['question_type'] == Question::RANKING) {
foreach ($options as $option) {
$question->options()->create([
'title' => $option,
'votes' => 0
]);
}
}
return $question;
}

protected function saveVoteForQuestion(Question $question, $validatedData, ?AnswerSheet $answerSheet = null)
{
// validation ensures we have answers
// to all of these questions
$answer = $validatedData[$question->formKey()];
if ($question->question_type == Question::TEXT_ANSWER ||
$question->question_type == Question::RANKING) {
$question->storeAnswers(user(), $answer, $answerSheet);
} elseif ($question->question_type == Question::SELECTION) {
if ($question->isMultipleChoice()) {
$options = array_map(
function (int $id) {return QuestionOption::find($id);},
$answer
);
$question->storeAnswers(user(), $options, $answerSheet);
} else {
$option = QuestionOption::find($answer);
$question->storeAnswers(user(), $option, $answerSheet);
}
} else {
throw new \Exception("Unknown question type");
}
}
}
1 change: 1 addition & 0 deletions app/Livewire/ParentChildForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function addItem()
public function removeItem($index)
{
unset($this->items[$index]);
$this->items = array_values($this->items);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion app/Models/AnonymousQuestions/AnswerSheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use App\Models\User;
use App\Models\Semester;
use App\Models\Question;
use App\Models\QuestionOption;
use App\Models\AnonymousQuestions\LongAnswer;

Expand Down Expand Up @@ -97,7 +98,7 @@ public function toArray(): array
$this->year_of_acceptance
];
foreach ($this->semester->questions()->orderBy('id')->get() as $question) {
if ($question->has_long_answers) {
if ($question->question_type == Question::TEXT_ANSWER) {
$row[] = $this->longAnswers()
->where('question_id', $question->id)
->first()->text ?? '';
Expand Down
Loading
Loading