Skip to content

Commit

Permalink
Voting system (#199)
Browse files Browse the repository at this point in the history
* Created migration, the Sitting and Question models and seeders for them.

* Finished models (factories not yet); created skeleton controller and policy

* Starting with basic UI at /voting

* Applying small changes to database structure; fixing new question panel

* Bringing it to a working state

* Format code with php-cs-fixer

* Format code with php-cs-fixer

* Applying most of the requested changes

* Applying Deepsource suggestions

* Adding doc comments

* Format code with php-cs-fixer

* Using CRUD route name conventions

* Moving voting to Question model; updating migration accordingly

* Format code with php-cs-fixer

* Revert "Merge branch 'development' into voting"

This reverts commit e1a7dcf, reversing
changes made to 72e0169.

* improve migration

* improve backend

* improve frontend

* Format code with php-cs-fixer

* tests

* Format code with php-cs-fixer

* Revert "Revert "Merge branch 'development' into voting""

This reverts commit 5e79389.

* fix tests

* Adding passcode to questions

* Adding random passcode generation to QuestionFactory

* Opening view_question UI to all voters

---------

Co-authored-by: deepsource-autofix[bot] <62050782+deepsource-autofix[bot]@users.noreply.github.com>
Co-authored-by: Katkó Dominik <[email protected]>
Co-authored-by: katkodominik <[email protected]>
  • Loading branch information
4 people authored Feb 18, 2023
1 parent 2b9bb00 commit 2c5c7d9
Show file tree
Hide file tree
Showing 28 changed files with 1,371 additions and 0 deletions.
233 changes: 233 additions & 0 deletions app/Http/Controllers/StudentsCouncil/VotingController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
<?php

namespace App\Http\Controllers\StudentsCouncil;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use App\Http\Controllers\Controller;
use App\Models\Voting\Sitting;
use App\Models\Voting\Question;
use App\Models\Voting\QuestionOption;

class VotingController extends Controller
{
/**
* Lists sittings.
*/
public function index()
{
$this->authorize('viewAny', Sitting::class);
return view('student-council.voting.list', [
"sittings" => Sitting::orderByDesc('opened_at')->get()
]);
}

/**
* Returns the 'new sitting' page.
*/
public function newSitting()
{
$this->authorize('administer', Sitting::class);
return view('student-council.voting.new_sitting');
}

/**
* Saves a new sitting.
*/
public function addSitting(Request $request)
{
$this->authorize('administer', Sitting::class);

$validator = Validator::make($request->all(), [
'title' => 'required|string',
]);
$validator->validate();

$sitting = Sitting::create([
'title' => $request->title,
'opened_at' => now(),
]);

return view('student-council.voting.view_sitting', [
"sitting" => $sitting
]);
}

/**
* Returns a page with the details and questions of a sitting.
*/
public function viewSitting(Sitting $sitting)
{
$this->authorize('viewAny', Sitting::class);

return view('student-council.voting.view_sitting', [
"sitting" => $sitting
]);
}

/**
* Closes a sitting.
*/
public function closeSitting(Sitting $sitting)
{
$this->authorize('administer', Sitting::class);
if (!$sitting->isOpen()) {
abort(401, "tried to close a sitting which was not open");
}
$sitting->close();
return back()->with('message', __('voting.sitting_closed'));
}

/**
* Returns the 'new question' page.
*/
public function newQuestion(Request $request)
{
$this->authorize('administer', Sitting::class);

$validator = Validator::make($request->all(), [
'sitting' => 'exists:sittings,id',
]);
$validator->validate();
$sitting = Sitting::findOrFail($request->sitting);

if (!$sitting->isOpen()) {
abort(401, "tried to modify a sitting which was not open");
}
return view('student-council.voting.new_question', [
"sitting" => $sitting
]);
}

/**
* Saves a new question.
*/
public function addQuestion(Request $request)
{
$this->authorize('administer', Sitting::class);

$validator = Validator::make($request->all(), [
'sitting' => 'exists:sittings,id',
'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();
$sitting = Sitting::findOrFail($request->sitting);

if (!$sitting->isOpen()) {
abort(401, "tried to modify a sitting which was not open");
}

$question = $sitting->questions()->create([
'title' => $request->title,
'max_options' => $request->max_options,
'opened_at' => now(),
'passcode' => \Str::random(8)
]);
foreach ($options as $option) {
$question->options()->create([
'title' => $option,
'votes' => 0
]);
}

return redirect()->route('questions.show', $question)->with('message', __('general.successful_modification'));
}

/**
* Closes a question.
*/
public function closeQuestion(Question $question)
{
$this->authorize('administer', Sitting::class);
if (!$question->isOpen()) {
abort(401, "tried to close a question which was not open");
}
$question->close();
return back()->with('message', __('voting.question_closed'));
}

/**
* Returns a page with the options (and results, if authorized) of a question.
*/
public function viewQuestion(Question $question)
{
$this->authorize('viewAny', Sitting::class);
return view('student-council.voting.view_question', [
"question" => $question
]);
}

/**
* Returns the voting page.
*/
public function vote(Question $question)
{
$this->authorize('vote', $question);
return view('student-council.voting.vote', [
"question" => $question
]);
}

/**
* Saves a vote.
*/
public function saveVote(Question $question, Request $request)
{
$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 ($request->passcode!=$question->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");
}
array_push($options, $option);
}
$question->vote(Auth::user(), $options);
} else {
$validator = Validator::make($request->all(), [
'option' => 'exists:question_options,id',
'passcode' => 'string'
]);
if ($request->passcode!=$question->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->vote(Auth::user(), array($option));
}

return redirect()->route('sittings.show', $question->sitting)->with('message', __('voting.successful_voting'));
}
}
138 changes: 138 additions & 0 deletions app/Models/Voting/Question.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
<?php

namespace App\Models\Voting;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
use App\Models\Voting\Sitting;
use App\Models\Voting\QuestionOption;
use App\Models\Voting\QuestionUser;
use App\Models\User;

class Question extends Model
{
use HasFactory;

protected $fillable = ['title', 'sitting_id', 'max_options', 'opened_at', 'closed_at', 'passcode'];

public $timestamps = false;

/**
* @return BelongsTo The parent sitting.
*/
public function sitting(): BelongsTo
{
return $this->belongsTo(Sitting::class);
}

/**
* @return HasMany the options belonging to the question
*/
public function options(): HasMany
{
return $this->hasMany(QuestionOption::class);
}

/**
* @return bool Whether the question has already been opened once (regardless of whether it has been closed since then).
*/
public function hasBeenOpened(): bool
{
return $this->opened_at!=null && $this->opened_at<=now();
}

/**
* @return bool Whether the question is currently open.*
*/
public function isOpen(): bool
{
return $this->hasBeenOpened() &&
!$this->isClosed();
}
/**
* @return bool Whether the question has been closed.
*/
public function isClosed(): bool
{
return $this->closed_at!=null && $this->closed_at<=now();
}

/**
* Opens the question.
* @throws Exception if it has already been opened.
*/
public function open(): void
{
if (!$this->sitting->isOpen()) {
throw new \Exception("tried to open question when sitting was not open");
}
if ($this->isOpen() || $this->isClosed()) {
throw new \Exception("tried to open question when it has already been opened");
}
$this->update(['opened_at'=>now()]);
}

/**
* Closes the question.
* @throws Exception if it has already been closed or if it is not even open.
*/
public function close(): void
{
if ($this->isClosed()) {
throw new \Exception("tried to close sitting when it has already been closed");
}
if (!$this->isOpen()) {
throw new \Exception("tried to close sitting when it was not open");
}
$this->update(['closed_at'=>now()]);
}

/**
* @return book Whether the question is a multiple-choice question (with checkboxes).
*/
public function isMultipleChoice(): bool
{
return $this->max_options>1;
}

/**
* @param User $user
* @return bool Whether a certain user has already voted in the question.
*/
public function hasVoted(User $user): bool
{
return QuestionUser::where('question_id', $this->id)->where('user_id', $user->id)->exists();
}

/**
* Votes for a list of given options in the name of the user.
* @param User $user
* @param array $options QuestionOption array
* @throws Exception if an option does not belong to the question or if too many options are selected.
*/
public function vote(User $user, array $options): void
{
if (!$this->isOpen()) {
throw new \Exception("question not open");
}
if ($this->max_options < count($options)) {
throw new \Exception("too many options given");
}
DB::transaction(function () use ($user, $options) {
QuestionUser::create([
'question_id' => $this->id,
'user_id' => $user->id,
]);
foreach ($options as $option) {
if ($option->question_id != $this->id) {
throw new \Exception("Received an option which does not belong to the question");
}
$option->increment('votes');
}
});
}
}
26 changes: 26 additions & 0 deletions app/Models/Voting/QuestionOption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace App\Models\Voting;

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

use App\Models\Voting\Question;

class QuestionOption extends Model
{
use HasFactory;

public $timestamps = false;

protected $fillable = ['question_id', 'title', 'votes'];

/**
* @return BelongsTo The question the option belongs to.
*/
public function question(): BelongsTo
{
return $this->belongsTo(Question::class);
}
}
Loading

0 comments on commit 2c5c7d9

Please sign in to comment.