diff --git a/app/Http/Controllers/StudentsCouncil/VotingController.php b/app/Http/Controllers/StudentsCouncil/VotingController.php new file mode 100644 index 000000000..46755d540 --- /dev/null +++ b/app/Http/Controllers/StudentsCouncil/VotingController.php @@ -0,0 +1,233 @@ +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')); + } +} diff --git a/app/Models/Voting/Question.php b/app/Models/Voting/Question.php new file mode 100644 index 000000000..d371f9692 --- /dev/null +++ b/app/Models/Voting/Question.php @@ -0,0 +1,138 @@ +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'); + } + }); + } +} diff --git a/app/Models/Voting/QuestionOption.php b/app/Models/Voting/QuestionOption.php new file mode 100644 index 000000000..8d6d23ad4 --- /dev/null +++ b/app/Models/Voting/QuestionOption.php @@ -0,0 +1,26 @@ +belongsTo(Question::class); + } +} diff --git a/app/Models/Voting/QuestionUser.php b/app/Models/Voting/QuestionUser.php new file mode 100644 index 000000000..431803489 --- /dev/null +++ b/app/Models/Voting/QuestionUser.php @@ -0,0 +1,15 @@ + 'datetime', + 'closed_at' => 'datetime', + ]; + + /** + * @return HasMany The questions that belong to the sitting. + */ + public function questions(): HasMany + { + return $this->hasMany(Question::class); + } + + /** + * @return bool Whether the sitting has been opened (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->isOpen() || $this->isClosed()) { + throw new \Exception("tried to open sitting 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"); + } + foreach ($this->questions()->get() as $question) { + if ($question->isOpen()) { + $question->close(); + } + } + $this->update(['closed_at'=>now()]); + } +} diff --git a/app/Policies/QuestionPolicy.php b/app/Policies/QuestionPolicy.php new file mode 100644 index 000000000..6376e6ca9 --- /dev/null +++ b/app/Policies/QuestionPolicy.php @@ -0,0 +1,36 @@ +isOpen() && $user->isCollegist() && $user->isActive() && !$question->hasVoted($user); + } + + /** + * Whether a user can view the results of a certain question. + * If it is still open, only people authorized to manage sittings can do so. + */ + public function viewResults(User $user, Question $question): bool + { + if ($question->isClosed()) { + return $user->can('viewAny', Sitting::class); + } else { + return $user->can('administer', Sitting::class); + } + } +} diff --git a/app/Policies/SittingPolicy.php b/app/Policies/SittingPolicy.php new file mode 100644 index 000000000..e67dabc7a --- /dev/null +++ b/app/Policies/SittingPolicy.php @@ -0,0 +1,29 @@ +isCollegist(); + } + + /** + * Determine whether the user can administer votings (add sitting, add or change question etc.). + */ + public function administer(User $user) + { + return $user->hasRole([Role::SYS_ADMIN, Role::STUDENT_COUNCIL => Role::PRESIDENT, Role::STUDENT_COUNCIL_SECRETARY]); + } +} diff --git a/database/factories/Voting/QuestionFactory.php b/database/factories/Voting/QuestionFactory.php new file mode 100644 index 000000000..89c729392 --- /dev/null +++ b/database/factories/Voting/QuestionFactory.php @@ -0,0 +1,28 @@ + + */ +class QuestionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'title' => $this->faker->realText($maxNbChars = 50), + 'max_options' => $this->faker->numberBetween(1, 3), + 'opened_at' => now()->addHours($this->faker->numberBetween(-3, -2)), + 'closed_at' => now()->addHours($this->faker->numberBetween(-1, 1)), + 'passcode' => \Str::random(8), + ]; + } +} diff --git a/database/factories/Voting/QuestionOptionFactory.php b/database/factories/Voting/QuestionOptionFactory.php new file mode 100644 index 000000000..3f4849c4d --- /dev/null +++ b/database/factories/Voting/QuestionOptionFactory.php @@ -0,0 +1,25 @@ + + */ +class QuestionOptionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'title' => $this->faker->realText($maxNbChars = 50), + 'votes' => 0 + ]; + } +} diff --git a/database/factories/Voting/SittingFactory.php b/database/factories/Voting/SittingFactory.php new file mode 100644 index 000000000..b1ab4582f --- /dev/null +++ b/database/factories/Voting/SittingFactory.php @@ -0,0 +1,25 @@ + + */ +class SittingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'title' => $this->faker->realText($maxNbChars = 50), + 'opened_at' => now()->addHours($this->faker->numberBetween(-3, -2)), + 'closed_at' => now()->addHours($this->faker->numberBetween(-1, 1)), + ]; + } +} diff --git a/database/migrations/2023_01_18_201909_create_voting_tables.php b/database/migrations/2023_01_18_201909_create_voting_tables.php new file mode 100644 index 000000000..f7f53a8bc --- /dev/null +++ b/database/migrations/2023_01_18_201909_create_voting_tables.php @@ -0,0 +1,60 @@ +id(); + $table->string('title'); + $table->datetime('opened_at'); + $table->datetime('closed_at')->nullable(); + }); + Schema::create('questions', function (Blueprint $table) { + $table->id(); + $table->foreignIdFor(Sitting::class)->onDelete('cascade'); + $table->string('title'); + $table->integer('max_options')->default(1); + $table->char('passcode', 8); + $table->datetime('opened_at')->nullable(); + $table->datetime('closed_at')->nullable(); + }); + Schema::create('question_options', function (Blueprint $table) { + $table->id(); + $table->foreignIdFor(Question::class)->onDelete('cascade'); + $table->string('title'); + $table->integer('votes')->default(0); + }); + Schema::create('question_user', function (Blueprint $table) { + $table->foreignIdFor(Question::class)->onDelete('cascade'); + $table->foreignIdFor(User::class)->onDelete('cascade'); + $table->timestamps(); + $table->unique(['question_id', 'user_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('sittings'); + Schema::dropIfExists('questions'); + Schema::dropIfExists('question_options'); + Schema::dropIfExists('question_user'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index cef64429e..7559a97f5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,5 +18,6 @@ public function run() $this->call(RouterSeeder::class); $this->call(TransactionSeeder::class); $this->call(EpistolaSeeder::class); + $this->call(VotingSeeder::class); } } diff --git a/database/seeders/VotingSeeder.php b/database/seeders/VotingSeeder.php new file mode 100644 index 000000000..c38d8fa4d --- /dev/null +++ b/database/seeders/VotingSeeder.php @@ -0,0 +1,74 @@ + "Today's sitting", + 'opened_at' => now(), + ]); + + $openQuestion = $openSitting->questions()->create([ + 'title' => "I support the election of the new Students' Council.", + 'max_options' => 1, + 'opened_at' => now(), + 'passcode' => \Str::random(8) + ]); + $openQuestion->options()->create([ + 'title' => "Yes", + 'votes' => 100 + ]); + $openQuestion->options()->create([ + 'title' => "No", + 'votes' => 12 + ]); + $openQuestion->options()->create([ + 'title' => "I abstain", + 'votes' => 9 + ]); + + $openCheckboxQuestion = $openSitting->questions()->create([ + 'title' => "Curatorium members", + 'max_options' => 3, + 'opened_at' => now(), + 'passcode' => \Str::random(8) + ]); + $openCheckboxQuestion->options()->create([ + 'title' => "A", + 'votes' => 60 + ]); + $openCheckboxQuestion->options()->create([ + 'title' => "B", + 'votes' => 70 + ]); + $openCheckboxQuestion->options()->create([ + 'title' => "C", + 'votes' => 50 + ]); + $openCheckboxQuestion->options()->create([ + 'title' => "D", + 'votes' => 10 + ]); + $openCheckboxQuestion->options()->create([ + 'title' => "E", + 'votes' => 65 + ]); + $openCheckboxQuestion->options()->create([ + 'title' => "I abstain", + 'votes' => 5 + ]); + } +} diff --git a/resources/lang/en/general.php b/resources/lang/en/general.php index 19ac61d3b..70be32c3c 100644 --- a/resources/lang/en/general.php +++ b/resources/lang/en/general.php @@ -4,6 +4,7 @@ 'active_members' => 'Active members', 'add_new' => 'Add new', 'admin' => 'Admin', + 'cancel' => 'Cancel', 'change_email' => 'Change email', 'change_password' => 'Change password', 'choose_from_menu' => 'Select one of the menu options!', diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 6f20057c2..db1e05de4 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -57,6 +57,7 @@ 'deadline_name' => 'deadline name', 'deadline_date' => 'deadline date', 'picture_path' => 'picture path', + 'option' => 'vote' ], 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', diff --git a/resources/lang/en/voting.php b/resources/lang/en/voting.php new file mode 100644 index 000000000..4e0fa625d --- /dev/null +++ b/resources/lang/en/voting.php @@ -0,0 +1,32 @@ + "You have to provide at least one option.", + "assembly" => "General Assembly", + "close_question" => "Close question", + "close_sitting" => "Close sitting", + "closed" => "Closed", + "closed_at" => "Closed at", + "incorrect_passcode" => "Incorrect passcode", + "is_open" => "Is open?", + "max_options" => "At most how many options should be chosen?", + "name" => "Title", + "new_question" => "New question", + "new_sitting" => "New sitting", + "only_after_closure" => "You are only authorized to see the results after the question gets closed.", + "open" => "Open", + "opened_at" => "Opened at", + "options" => "Options", + "options_instructions" => "Options seperated by Enters", + "passcode" => "Passcode", + "question_closed" => "Question closed", + "question_title" => "Title of question", + "questions" => "Questions", + "sitting_closed" => "Sitting closed", + "sitting_title" => "Title of sitting", + "sittings" => "Sittings", + "successful_voting" => "Voted successfully", + "too_many_options" => "You have checked too many options; please try again.", + "voting" => "Voting", + "warning" => "Warning: you cannot change your vote after you have submitted it." +]; diff --git a/resources/lang/hu/general.php b/resources/lang/hu/general.php index 3fce92cf0..2401fe74b 100644 --- a/resources/lang/hu/general.php +++ b/resources/lang/hu/general.php @@ -4,6 +4,7 @@ 'active_members' => 'Aktív tagok', 'add_new' => 'Új hozzáadása', 'admin' => 'Rendszergazda', + 'cancel' => 'Mégse', 'change_email' => 'Email megváltoztatása', 'change_password' => 'Jelszó megváltoztatása', 'choose_from_menu' => 'Válassz egy menüpontot!', diff --git a/resources/lang/hu/validation.php b/resources/lang/hu/validation.php index 53d1239d2..6ea5703f9 100644 --- a/resources/lang/hu/validation.php +++ b/resources/lang/hu/validation.php @@ -57,6 +57,7 @@ 'deadline_name' => 'határidő neve', 'deadline_date' => 'határidő', 'picture_path' => 'kép linkje', + 'option' => 'szavazat' ], 'before' => 'A(z) :attribute :date előtti dátum kell, hogy legyen!', 'before_or_equal' => 'A(z) :attribute nem lehet későbbi dátum, mint :date!', diff --git a/resources/lang/hu/voting.php b/resources/lang/hu/voting.php new file mode 100644 index 000000000..740d20987 --- /dev/null +++ b/resources/lang/hu/voting.php @@ -0,0 +1,32 @@ + "Legalább egy opciónak lennie kell!", + "assembly" => "Közgyűlés", + "close_question" => "Kérdés lezárása", + "close_sitting" => "Ülés lezárása", + "closed" => "Lezárva", + "closed_at" => "Lezárva", + "incorrect_passcode" => "Incorrect passcode", + "is_open" => "Nyitva?", + "max_options" => "Legfeljebb hány opcióra lehessen szavazni?", + "name" => "Név", + "new_question" => "Új kérdés", + "new_sitting" => "Új ülés", + "only_after_closure" => "Az eredményeket csak a kérdés lezárása után lesz jogosultságod megtekinteni.", + "open" => "Nyitva", + "opened_at" => "Megnyitva", + "options" => "Opciók", + "options_instructions" => "Opciók Enterrel elválasztva", + "passcode" => "A kérdés jelszava", + "question_closed" => "A kérdés lezárva.", + "question_title" => "Kérdés", + "questions" => "Kérdések", + "sitting_closed" => "Az ülés lezárva.", + "sitting_title" => "Ülés neve", + "sittings" => "Közgyűlések", + "successful_voting" => "Sikeres szavazás!", + "too_many_options" => "Túl sok opció lett megjelölve; próbáld újra.", + "voting" => "Szavazás", + "warning" => "Figyelem: a szavazatot utólag már nem lehet módosítani!" +]; diff --git a/resources/views/layouts/navbar.blade.php b/resources/views/layouts/navbar.blade.php index e8f48b1bf..2fed30a6b 100644 --- a/resources/views/layouts/navbar.blade.php +++ b/resources/views/layouts/navbar.blade.php @@ -98,6 +98,12 @@ class="material-icons">menu business_center Közösségi tevékenység + +
  • + + thumbs_up_down @lang('voting.assembly') + +
  • diff --git a/resources/views/student-council/voting/list.blade.php b/resources/views/student-council/voting/list.blade.php new file mode 100644 index 000000000..23d86a0ca --- /dev/null +++ b/resources/views/student-council/voting/list.blade.php @@ -0,0 +1,73 @@ +@extends('layouts.app') + +@section('title') +@lang('voting.assembly') +@endsection +@section('student_council_module') active @endsection + +@section('content') + +
    +
    +
    +
    + @lang('voting.sittings') + + + + + + + + + @can('administer', \App\Models\Sitting::class) + + @endcan + + + + + @foreach ($sittings as $sitting) + + + + + + + + @endforeach + +
    @lang('voting.name')@lang('voting.opened_at')@lang('voting.closed_at')@lang('voting.is_open') + + add + +
    + {{ $sitting->title }} + + {{ $sitting->opened_at }} + + {{ $sitting->closed_at }} + + @if($sitting->isOpen()) + @lang('voting.open') + @else + @lang('voting.closed') + @endif + + + remove_red_eye + +
    +
    +
    +
    +
    +@endsection + +@push('scripts') + +@endpush \ No newline at end of file diff --git a/resources/views/student-council/voting/new_question.blade.php b/resources/views/student-council/voting/new_question.blade.php new file mode 100644 index 000000000..0a30f7952 --- /dev/null +++ b/resources/views/student-council/voting/new_question.blade.php @@ -0,0 +1,41 @@ +@extends('layouts.app') + +@section('title') +@lang('voting.assembly') +{{ $sitting->title }} +@lang('voting.new_question') +@endsection +@section('student_council_module') active @endsection + +@section('content') + +
    +
    +
    +
    + @csrf +
    + @foreach ($errors->all() as $error) +
    {{ $error }}
    + @endforeach + @lang('voting.new_question') +
    + +
    +
    + @livewire('parent-child-form', ['title' => __('voting.options'), 'name' => 'options', 'items' => old('options')]) +
    +
    + +
    +
    +
    + @lang('general.cancel') + +
    +
    +
    +
    +
    +@endsection + diff --git a/resources/views/student-council/voting/new_sitting.blade.php b/resources/views/student-council/voting/new_sitting.blade.php new file mode 100644 index 000000000..31ca1d428 --- /dev/null +++ b/resources/views/student-council/voting/new_sitting.blade.php @@ -0,0 +1,31 @@ +@extends('layouts.app') + +@section('title') +@lang('voting.assembly') +@lang('voting.new_sitting') +@endsection +@section('student_council_module') active @endsection + +@section('content') + +
    +
    +
    +
    + @csrf +
    + @lang('voting.new_sitting') +
    + +
    +
    +
    + @lang('general.cancel') + +
    +
    +
    +
    +
    +@endsection + diff --git a/resources/views/student-council/voting/view_question.blade.php b/resources/views/student-council/voting/view_question.blade.php new file mode 100644 index 000000000..d6fbe18f5 --- /dev/null +++ b/resources/views/student-council/voting/view_question.blade.php @@ -0,0 +1,59 @@ +@extends('layouts.app') + +@section('title') +@lang('voting.assembly') +{{ $question->sitting->title }} +{{ $question->title }} +@endsection +@section('student_council_module') active @endsection + +@section('content') + +
    +
    +
    +
    + {{ $question->title }} + @cannot('viewResults', $question) +

    @lang('voting.only_after_closure')

    + @endcan + @can('administer', \App\Models\Voting\Sitting::class) +

    @lang('voting.passcode'): {{ $question->passcode }}

    + @endcan + + + + + + + + @foreach($question->options as $option) + + + @can('viewResults', $question) + + @endcan + + @endforeach + +
    @lang('voting.options')
    {{$option->title}}{{$option->votes}}
    +
    +
    +
    + @if($question->isOpen()) + @can('vote', $question) + + @endcan + @can('administer', \App\Models\Voting\Sitting::class) +
    + @csrf + + + @endcan + @endif +
    +
    +
    +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/student-council/voting/view_sitting.blade.php b/resources/views/student-council/voting/view_sitting.blade.php new file mode 100644 index 000000000..611524e39 --- /dev/null +++ b/resources/views/student-council/voting/view_sitting.blade.php @@ -0,0 +1,103 @@ +@extends('layouts.app') + +@section('title') +@lang('voting.assembly') +{{ $sitting->title }} +@endsection +@section('student_council_module') active @endsection + +@section('content') + +
    +
    +
    +
    + {{ $sitting->title }} + + + + + + + + + + @if($sitting->isOpen()) + @can('administer', $sitting) + + @endcan + @endif + + +
    @lang('voting.opened_at'){{ $sitting->opened_at }}
    @lang('voting.closed_at'){{ $sitting->closed_at }} +
    + @csrf + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + @lang('voting.questions') + + + + + + + @can('administer', \App\Models\Voting\Sitting::class) + + @endcan + + + + + + + @foreach ($sitting->questions()->orderByDesc('opened_at')->get() as $question) + + + + + @can('administer', \App\Models\Voting\Sitting::class) + + @endcan + + + + + @endforeach + +
    @lang('voting.question_title')@lang('voting.opened_at')@lang('voting.closed_at')@lang('voting.passcode') + @if($sitting->isOpen()) + @can('administer', $sitting) + + @endcan + @endif +
    {{$question->title}}{{$question->opened_at}}{{$question->closed_at}}{{$question->passcode}} + @if($question->isOpen()) + @can('administer', $sitting) +
    + @csrf + + + @endcan + @endif +
    + @can('vote', $question) + + @endcan + + +
    +
    +
    +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/student-council/voting/vote.blade.php b/resources/views/student-council/voting/vote.blade.php new file mode 100644 index 000000000..47912231a --- /dev/null +++ b/resources/views/student-council/voting/vote.blade.php @@ -0,0 +1,47 @@ +@extends('layouts.app') + +@section('title') +@lang('voting.assembly') +{{ $question->sitting->title }} +{{ $question->title }} +@lang('voting.voting') +@endsection +@section('student_council_module') active @endsection + +@section('content') + +
    +
    +
    +
    + @csrf +
    + {{ $question->title }} +
    @lang('voting.warning')
    +

    +
    + @foreach($question->options()->get() as $option) + @if($question->max_options==1) + + @else + + @endif + @endforeach + @foreach ($errors->all() as $error) +
    {{ $error }}
    + @endforeach +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +@endsection \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 5cf8df639..9a44fbaaa 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,6 +30,7 @@ use App\Http\Controllers\StudentsCouncil\EpistolaController; use App\Http\Controllers\StudentsCouncil\MrAndMissController; use App\Http\Controllers\StudentsCouncil\CommunityServiceController; +use App\Http\Controllers\StudentsCouncil\VotingController; use App\Http\Controllers\Dormitory\RoomController; use App\Models\Room; use Illuminate\Support\Facades\Auth; @@ -210,4 +211,17 @@ Route::post('/community_service/reject/{community_service}', [CommunityServiceController::class, 'reject'])->name('community_service.reject'); Route::post('/community_service/create', [CommunityServiceController::class, 'create'])->name('community_service.create'); Route::get('/community_service/search', [CommunityServiceController::class, 'search'])->name('community_service.search'); + + /** voting */ + Route::get('/sittings', [VotingController::class, 'index'])->name('sittings.index'); + Route::get('/sittings/create', [VotingController::class, 'newSitting'])->name('sittings.create'); + Route::post('/sittings', [VotingController::class, 'addSitting'])->name('sittings.store'); + Route::get('/sittings/{sitting}', [VotingController::class, 'viewSitting'])->name('sittings.show'); + Route::post('/sittings/{sitting}/close', [VotingController::class, 'closeSitting'])->name('sittings.close'); + Route::get('/questions/create', [VotingController::class, 'newQuestion'])->name('questions.create'); + Route::post('/questions', [VotingController::class, 'addQuestion'])->name('questions.store'); + Route::post('/questions/{question}/close', [VotingController::class, 'closeQuestion'])->name('questions.close'); + Route::get('/questions/{question}/votes/create', [VotingController::class, 'vote'])->name('questions.votes.create'); + Route::post('/questions/{question}/votes', [VotingController::class, 'saveVote'])->name('questions.votes.store'); + Route::get('/questions/{question}', [VotingController::class, 'viewQuestion'])->name('questions.show'); }); diff --git a/tests/Unit/VotingTest.php b/tests/Unit/VotingTest.php new file mode 100644 index 000000000..43dcfcd11 --- /dev/null +++ b/tests/Unit/VotingTest.php @@ -0,0 +1,146 @@ +create(); + + $sitting = Sitting::factory()->create(); + $question = Question::factory() + ->for($sitting) + ->hasOptions(3) + ->create(['opened_at' => now()->subDay(), 'closed_at' => now()->subDay()]); + + $this->expectException(\Exception::class); + $question->vote($user, [$question->options->first()]); + } + + /** + * @return void + */ + public function test_voting_on_not_opened_question() + { + $user = User::factory()->create(); + + $sitting = Sitting::factory()->create(); + $question = Question::factory() + ->for($sitting) + ->hasOptions(3) + ->create(['opened_at' => null, 'closed_at' => null]); + + $this->expectException(\Exception::class); + $question->vote($user, [$question->options->first()]); + } + + /** + * @return void + */ + public function test_voting_twice() + { + $user = User::factory()->create(); + + $sitting = Sitting::factory()->create(); + $question = Question::factory() + ->for($sitting) + ->hasOptions(3) + ->create(['opened_at' => now()->subDay(), 'closed_at' => now()->addDay()]); + + $this->expectException(\Exception::class); + $question->vote($user, [$question->options->first()]); + $question->vote($user, [$question->options->first()]); + } + + /** + * @return void + */ + public function test_voting_radio() + { + $user = User::factory()->create(); + + $sitting = Sitting::factory()->create(); + $question = Question::factory() + ->for($sitting) + ->hasOptions(3) + ->create(['opened_at' => now()->subDay(), 'closed_at' => now()->addDay(), 'max_options' => 1]); + + $question->vote($user, [$question->options->first()]); + + $this->assertEquals(1, $question->options->first()->votes); + $this->assertEquals(0, $question->options->get(1)->votes); + $this->assertEquals(0, $question->options->get(2)->votes); + } + + /** + * @return void + */ + public function test_voting_radio_with_more_options() + { + $user = User::factory()->create(); + + $sitting = Sitting::factory()->create(); + $question = Question::factory() + ->for($sitting) + ->hasOptions(3) + ->create(['opened_at' => now()->subDay(), 'closed_at' => now()->addDay(), 'max_options' => 1]); + + $this->expectException(\Exception::class); + $question->vote($user, [$question->options->first(), $question->options->get(1)]); + } + + /** + * @return void + */ + public function test_voting_checkbox() + { + $user = User::factory()->create(); + + $sitting = Sitting::factory()->create(); + $question = Question::factory() + ->for($sitting) + ->hasOptions(3) + ->create(['opened_at' => now()->subDay(), 'closed_at' => now()->addDay(), 'max_options' => 3]); + + $question->vote($user, [$question->options->first(), $question->options->get(1)]); + + $this->assertEquals(1, $question->options->first()->votes); + $this->assertEquals(1, $question->options->get(1)->votes); + $this->assertEquals(0, $question->options->get(2)->votes); + } + + /** + * @return void + */ + public function test_voting_checkbox_with_more_options() + { + $user = User::factory()->create(); + + $sitting = Sitting::factory()->create(); + $question = Question::factory() + ->for($sitting) + ->hasOptions(3) + ->create(['opened_at' => now()->subDay(), 'closed_at' => now()->addDay(), 'max_options' => 2]); + + $this->expectException(\Exception::class); + $question->vote($user, [$question->options->first(), $question->options->get(1), $question->options->get(2)]); + } +}