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