-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
2b9bb00
commit 2c5c7d9
Showing
28 changed files
with
1,371 additions
and
0 deletions.
There are no files selected for viewing
233 changes: 233 additions & 0 deletions
233
app/Http/Controllers/StudentsCouncil/VotingController.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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')); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.