From 049487b17b8d434bbcd4f3e5e2e499fb1b8a7838 Mon Sep 17 00:00:00 2001 From: Fabian Schmidt <fabian@ds-fs.de> Date: Fri, 18 Nov 2022 16:37:34 +0100 Subject: [PATCH] random voting algorithm --- app/Http/Controllers/ChallengeController.php | 3 +- app/Http/Controllers/VotingController.php | 24 +++-- app/Models/Challenge.php | 3 +- .../Voting/RandomAlgorithmVotingService.php | 97 +++++++++++++++++++ .../SwissAlgorithmVotingService.php} | 5 +- .../Voting/VotingServiceInterface.php | 23 +++++ database/factories/ChallengeFactory.php | 1 + ...1_10_22_165347_create_challenges_table.php | 1 + resources/js/Pages/Admin/Challenges/Edit.tsx | 79 +++++++++++---- tests/Feature/VotingAlgorithmTest.php | 7 +- types/shared.ts | 1 + 11 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 app/Services/Voting/RandomAlgorithmVotingService.php rename app/Services/{VotingService.php => Voting/SwissAlgorithmVotingService.php} (98%) create mode 100644 app/Services/Voting/VotingServiceInterface.php diff --git a/app/Http/Controllers/ChallengeController.php b/app/Http/Controllers/ChallengeController.php index 08ceb99c..404f82ca 100644 --- a/app/Http/Controllers/ChallengeController.php +++ b/app/Http/Controllers/ChallengeController.php @@ -76,7 +76,8 @@ class ChallengeController extends Controller 'endDateVoting' => ['required'], 'preVotingPage' => ['required'], 'postVotingPage' => ['required'], - 'acceptConditionsCheckboxLabel' => ['required'] + 'acceptConditionsCheckboxLabel' => ['required'], + 'votingAlgorithm' => ['required'] ]) ); diff --git a/app/Http/Controllers/VotingController.php b/app/Http/Controllers/VotingController.php index 7052f21e..6096cdac 100644 --- a/app/Http/Controllers/VotingController.php +++ b/app/Http/Controllers/VotingController.php @@ -5,9 +5,10 @@ namespace App\Http\Controllers; use App\Models\Challenge; use App\Models\ChallengeCandidate; use App\Models\ChallengeQuestion; -use App\Models\User; use App\Services\CSVService; -use App\Services\VotingService; +use App\Services\Voting\RandomAlgorithmVotingService; +use App\Services\Voting\SwissAlgorithmVotingService; +use App\Services\Voting\VotingServiceInterface; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -19,9 +20,18 @@ class VotingController extends Controller { const SESSION_CHALLENGE_QUESTION = 'challenge_question'; - public function startVoting($challenge) + private function getVotingService(Challenge $challenge): VotingServiceInterface { - $votingService = new VotingService(); + if ($challenge->votingAlgorithm === 'SWISS') { + return new SwissAlgorithmVotingService(); + } else { + return new RandomAlgorithmVotingService(); + } + } + + public function startVoting(Challenge $challenge) + { + $votingService = $this->getVotingService($challenge); foreach ($challenge->questions as $question) { foreach ($challenge->candidates as $candidate) { if ($candidate->approved) { @@ -39,7 +49,7 @@ class VotingController extends Controller return Redirect::route('public.defaultChallenge'); } - $votingService = new VotingService(); + $votingService = $this->getVotingService($challenge); if (!$votingService->hasChallengeCandidates($challenge->questions->get(0)->id)) { $this->startVoting($challenge); } @@ -85,15 +95,15 @@ class VotingController extends Controller if (!$challenge->isInVotingPhase()) { throw ValidationException::withMessages(['date' => 'Cannot vote in this challenge now!']); } - $votingService = new VotingService(); + $votingService = $this->getVotingService($challenge); $votingService->storeResult($request->id, $request->result, $request->ip(), Auth::user() ? Auth::user()->id : null); return redirect()->route('public.challenges.voting', ['challengeId' => $challengeId]); } public function getRanking($challengeId) { - $votingService = new VotingService(); $challenge = Challenge::findOrFail($challengeId); + $votingService = $this->getVotingService($challenge); $rankinglists = []; foreach ($challenge->questions as $question) { $votingCandidatesRanked = $votingService->getRanking($question->id); diff --git a/app/Models/Challenge.php b/app/Models/Challenge.php index cfa9cc5c..0b81ff03 100644 --- a/app/Models/Challenge.php +++ b/app/Models/Challenge.php @@ -19,7 +19,8 @@ class Challenge extends Model 'organizer', 'preVotingPage', 'postVotingPage', - 'acceptConditionsCheckboxLabel' + 'acceptConditionsCheckboxLabel', + 'votingAlgorithm' ]; protected $dates = [ diff --git a/app/Services/Voting/RandomAlgorithmVotingService.php b/app/Services/Voting/RandomAlgorithmVotingService.php new file mode 100644 index 00000000..20cbaf1e --- /dev/null +++ b/app/Services/Voting/RandomAlgorithmVotingService.php @@ -0,0 +1,97 @@ +<?php + + +namespace App\Services\Voting; + +use App\Models\VotingCandidate; +use App\Models\VotingMatch; + +class RandomAlgorithmVotingService implements VotingServiceInterface +{ + public function store($challengeId, $referenceId) + { + return VotingCandidate::create(['challengeId' => $challengeId, 'referenceId' => $referenceId]); + } + + public function hasChallengeCandidates($challengeId) + { + return VotingCandidate::where('challengeId', '=', $challengeId)->count() > 0; + } + + /** + * Returns a random VotingMatch of the current round for the challenge given by $challengeId + * @param $challengeId + * @return mixed + */ + public function getNextPair($challengeId) + { + $randomCandidate1 = VotingCandidate::inRandomOrder()->first(); + $randomCandidate2 = VotingCandidate::where('id', '<>', $randomCandidate1->id)->inRandomOrder()->first(); + + + $match = VotingMatch::lockForUpdate()->create(['id1' => $randomCandidate1->id, 'id2' => $randomCandidate2->id, 'challengeId' => $challengeId, 'round' => 0]); + $randomCandidate1->games = $randomCandidate1->games + 1; + $randomCandidate2->games = $randomCandidate2->games + 1; + $randomCandidate1->save(); + $randomCandidate2->save(); + + return (object)[ + 'id' => $match->id, + 'candidate1' => $randomCandidate1, + 'candidate2' => $randomCandidate2, + ]; + } + + + /** + * Saves the Voting Decision + * $result is the id of the winner, if draw -> -1 + * @param $id + * @param $result + */ + public function storeResult($id, $result, $userIpAddress, $userId) + { + $match = VotingMatch::find($id); + if ($match->winner) { + return; + } + if ($result === -1) { + $candidate = VotingCandidate::find($match->id1); + $candidate->points = $candidate->points + 1; + $candidate->save(); + + $candidate = VotingCandidate::find($match->id2); + $candidate->points = $candidate->points + 1; + $candidate->save(); + + } else { + if ($result === $match->id1) { + $winner = VotingCandidate::find($match->id1); + } elseif ($result === $match->id2) { + $winner = VotingCandidate::find($match->id2); + } else { + throw new \Exception("Id and Result do not match"); + } + $winner->points = $winner->points + 2; + $winner->save(); + } + + $match->winner = $result; + $match->user_ip_address = $userIpAddress; + $match->user_id = $userId; + $match->save(); + } + + + public function getRanking($challengeId) + { + $candidatesOrderedByPointsDesc = VotingCandidate::where('challengeId', $challengeId)->orderBy('points', 'desc')->get(); + foreach ($candidatesOrderedByPointsDesc as $candidate) { + $candidate->wins = VotingMatch::where('challengeId', $challengeId)->where('winner', $candidate->id)->count(); + $candidate->draws = $candidate->points - 2 * $candidate->wins; + $candidate->losses = $candidate->games - $candidate->wins - $candidate->draws; + } + + return $candidatesOrderedByPointsDesc; + } +} diff --git a/app/Services/VotingService.php b/app/Services/Voting/SwissAlgorithmVotingService.php similarity index 98% rename from app/Services/VotingService.php rename to app/Services/Voting/SwissAlgorithmVotingService.php index bd6a811b..8e7c02c3 100644 --- a/app/Services/VotingService.php +++ b/app/Services/Voting/SwissAlgorithmVotingService.php @@ -1,13 +1,13 @@ <?php -namespace App\Services; +namespace App\Services\Voting; use App\Models\VotingCandidate; use App\Models\VotingMatch; use Illuminate\Support\Facades\DB; -class VotingService +class SwissAlgorithmVotingService implements VotingServiceInterface { public function store($challengeId, $referenceId) { @@ -139,7 +139,6 @@ class VotingService { $candidatesOrderedByPointsDesc = VotingCandidate::where('challengeId', $challengeId)->orderBy('points', 'desc')->get(); - foreach ($candidatesOrderedByPointsDesc as $candidate) { $votingMatchesNotPlayedCount = VotingMatch::where(function ($query) use ($candidate, $challengeId) { $query->where('challengeId', $challengeId)->where('id1', '=', $candidate->id)->whereNull('winner'); diff --git a/app/Services/Voting/VotingServiceInterface.php b/app/Services/Voting/VotingServiceInterface.php new file mode 100644 index 00000000..a1e3b438 --- /dev/null +++ b/app/Services/Voting/VotingServiceInterface.php @@ -0,0 +1,23 @@ +<?php + +namespace App\Services\Voting; + +interface VotingServiceInterface +{ + public function store($challengeId, $referenceId); + + public function hasChallengeCandidates($challengeId); + + public function getNextPair($challengeId); + + /** + * Saves the Voting Decision + * $result is the id of the winner, if draw -> -1 + * @param $id + * @param $result + */ + public function storeResult($id, $result, $userIpAddress, $userId); + + public function getRanking($challengeId); + +} diff --git a/database/factories/ChallengeFactory.php b/database/factories/ChallengeFactory.php index ee13fb64..2e95c649 100644 --- a/database/factories/ChallengeFactory.php +++ b/database/factories/ChallengeFactory.php @@ -25,6 +25,7 @@ class ChallengeFactory extends Factory 'preVotingPage' => $this->faker->markdown(), 'postVotingPage' => $this->faker->markdown(), 'acceptConditionsCheckboxLabel' => $this->faker->sentence(), + 'votingAlgorithm' => 'SWISS' ]; } } diff --git a/database/migrations/2021_10_22_165347_create_challenges_table.php b/database/migrations/2021_10_22_165347_create_challenges_table.php index e13c8099..ba274b0a 100644 --- a/database/migrations/2021_10_22_165347_create_challenges_table.php +++ b/database/migrations/2021_10_22_165347_create_challenges_table.php @@ -26,6 +26,7 @@ class CreateChallengesTable extends Migration $table->dateTime('endDateSubmission'); $table->dateTime('startDateVoting'); $table->dateTime('endDateVoting'); + $table->enum('votingAlgorithm', ['RANDOM', 'SWISS']); }); } diff --git a/resources/js/Pages/Admin/Challenges/Edit.tsx b/resources/js/Pages/Admin/Challenges/Edit.tsx index 3ae4fee2..f859761c 100644 --- a/resources/js/Pages/Admin/Challenges/Edit.tsx +++ b/resources/js/Pages/Admin/Challenges/Edit.tsx @@ -54,6 +54,7 @@ const ChallengeEdit = ({ challenge }: ChallengeEditProps) => { acceptConditionsCheckboxLabel: challenge?.acceptConditionsCheckboxLabel || "Ich bin mit den AGB einverstanden.", + votingAlgorithm: challenge?.votingAlgorithm || "SWISS", }; const { data, setData, errors, post, processing } = useForm<ChallengeClientToServer>(defaultValues); @@ -296,33 +297,71 @@ const ChallengeEdit = ({ challenge }: ChallengeEditProps) => { })} </div> )} - <div className="flex justify-between pt-10 border-t border-gray-400 w-full"> + + <div className="pt-10 border-t border-gray-400 w-full"> <div> <h3 className="text-lg leading-6 font-medium text-gray-900"> - Questions + Voting algorithm </h3> <p className="mt-1 text-sm text-gray-500"> - Set one or multiple questions that will be - presented to the users in the voting screen. - Every question will result in a separate - voting round. + Select a voting algorithm, once a voting has + been started with an algorithm, it can not + be changed. </p> </div> - <IconButton - onClick={(e) => { - e.preventDefault(); - setData("questions", [ - ...data.questions, - { - question: "", - }, - ]); - }} - className="self-start" - icon="plus" - title="Add Question" - disabled={votingHasStarted} + + <Select + value={data.votingAlgorithm} + name="votingAlgorithm" + className="pt-4" + onChange={(e) => + setData( + "votingAlgorithm", + e.currentTarget.value as + | "SWISS" + | "RANDOM" + ) + } + options={[ + { + value: "SWISS", + caption: "Swiss", + }, + { + value: "RANDOM", + caption: "Random", + }, + ]} /> + + <div className="flex justify-between pt-10"> + <div> + <h3 className="text-lg leading-6 font-medium text-gray-900"> + Questions + </h3> + <p className="mt-1 text-sm text-gray-500"> + Set one or multiple questions that will + be presented to the users in the voting + screen. Every question will result in a + separate voting round. + </p> + </div> + <IconButton + onClick={(e) => { + e.preventDefault(); + setData("questions", [ + ...data.questions, + { + question: "", + }, + ]); + }} + className="self-start" + icon="plus" + title="Add Question" + disabled={votingHasStarted} + /> + </div> </div> <div className="flex flex-col py-4 w-full divide-y divide-y-gray-400"> {data.questions.map((questionObj, i) => { diff --git a/tests/Feature/VotingAlgorithmTest.php b/tests/Feature/VotingAlgorithmTest.php index 9759b1a8..078643ab 100644 --- a/tests/Feature/VotingAlgorithmTest.php +++ b/tests/Feature/VotingAlgorithmTest.php @@ -3,8 +3,7 @@ namespace Tests\Feature; use App\Models\VotingCandidate; -use App\Models\VotingMatch; -use App\Services\VotingService; +use App\Services\Voting\SwissAlgorithmVotingService; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -20,7 +19,7 @@ class VotingAlgorithmTest extends TestCase */ public function test_example() { - $controller = new VotingService(); + $controller = new SwissAlgorithmVotingService(); for ($i = 0; $i < 10; $i++) { $controller->store(1, $i); } @@ -46,7 +45,7 @@ class VotingAlgorithmTest extends TestCase public function test_random() { - $controller = new VotingService(); + $controller = new SwissAlgorithmVotingService(); for ($i = 0; $i < 10; $i++) { $controller->store(1, $i); } diff --git a/types/shared.ts b/types/shared.ts index d99a46ba..305b54da 100644 --- a/types/shared.ts +++ b/types/shared.ts @@ -14,6 +14,7 @@ export interface ChallengeBase { postVotingPage: string; acceptConditionsCheckboxLabel: string; questions: Question[]; + votingAlgorithm: "SWISS" | "RANDOM"; } export interface ChallengeServerToClient extends Readonly<ChallengeBase> { -- GitLab