diff --git a/app/Actions/DisqualifyUserAction.php b/app/Actions/DisqualifyUserAction.php new file mode 100644 index 00000000..0e24901b --- /dev/null +++ b/app/Actions/DisqualifyUserAction.php @@ -0,0 +1,28 @@ +reason = $reason; + $disqualification->userQuiz()->associate($userQuiz); + $disqualification->save(); + + if ($sendEmail) { + $userQuiz->user->notify(new DisqualificationNotification($userQuiz, $reason)); + } + + return $disqualification; + } +} diff --git a/app/Actions/UndisqualifyUserAction.php b/app/Actions/UndisqualifyUserAction.php new file mode 100644 index 00000000..6bee2626 --- /dev/null +++ b/app/Actions/UndisqualifyUserAction.php @@ -0,0 +1,17 @@ +disqualification()->delete(); + } +} diff --git a/app/Http/Controllers/RankingController.php b/app/Http/Controllers/RankingController.php index a0e1bc53..83046688 100644 --- a/app/Http/Controllers/RankingController.php +++ b/app/Http/Controllers/RankingController.php @@ -4,8 +4,11 @@ namespace App\Http\Controllers; +use App\Actions\DisqualifyUserAction; use App\Actions\PublishQuizRankingAction; +use App\Actions\UndisqualifyUserAction; use App\Actions\UnpublishQuizRankingAction; +use App\Http\Requests\DisqualifyUserRequest; use App\Http\Resources\QuizResource; use App\Http\Resources\RankingResource; use App\Models\Quiz; @@ -69,4 +72,26 @@ public function unpublish(Quiz $quiz, UnpublishQuizRankingAction $unpublishQuizR ->back() ->with("status", "Ranking został wycofany."); } + + public function disqualify(DisqualifyUserAction $action, UserQuiz $userQuiz, DisqualifyUserRequest $request): RedirectResponse + { + $this->authorize("disqualify", $userQuiz); + + $action->execute($userQuiz, $request->validated()["reason"], $request->validated()["sendEmail"]); + + return redirect() + ->back() + ->with("status", "Użytkownik został zdyskwalifikowany."); + } + + public function undisqualify(UndisqualifyUserAction $action, UserQuiz $userQuiz): RedirectResponse + { + $this->authorize("undisqualify", $userQuiz); + + $action->execute($userQuiz); + + return redirect() + ->back() + ->with("status", "Dyskwalifikacją użytkownika została cofnięta."); + } } diff --git a/app/Http/Requests/DisqualifyUserRequest.php b/app/Http/Requests/DisqualifyUserRequest.php new file mode 100644 index 00000000..3ffd1d3a --- /dev/null +++ b/app/Http/Requests/DisqualifyUserRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + "reason" => ["required", "string"], + "sendEmail" => ["required", "boolean"], + ]; + } +} diff --git a/app/Models/Disqualification.php b/app/Models/Disqualification.php new file mode 100644 index 00000000..8c31d0be --- /dev/null +++ b/app/Models/Disqualification.php @@ -0,0 +1,28 @@ +belongsTo(UserQuiz::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c7f79e9a..e7ceb103 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Spatie\Permission\Traits\HasRoles; @@ -33,6 +34,7 @@ * @property boolean $is_anonymized * @property boolean $force_password_change * @property Collection $userQuizzes + * @property Collection $disqualifications * @property Collection $assignedQuizzes */ class User extends Authenticatable implements MustVerifyEmail, CanResetPassword @@ -73,6 +75,11 @@ public function userQuizzes(): HasMany return $this->hasMany(UserQuiz::class); } + public function disqualifications(): HasManyThrough + { + return $this->hasManyThrough(Disqualification::class, UserQuiz::class); + } + public function assignedQuizzes(): BelongsToMany { return $this->belongsToMany(Quiz::class, "quiz_assignments"); diff --git a/app/Models/UserQuestion.php b/app/Models/UserQuestion.php index 9f5e9fa2..db4c7b61 100644 --- a/app/Models/UserQuestion.php +++ b/app/Models/UserQuestion.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; /** * @property int $id @@ -21,6 +22,7 @@ * @property bool $isCorrect * @property UserQuiz $userQuiz * @property Question $question + * @property ?Disqualification $disqualification * @property ?Answer $answer */ class UserQuestion extends Model @@ -46,6 +48,11 @@ public function question(): BelongsTo return $this->belongsTo(Question::class); } + public function disqualifications(): HasOne + { + return $this->hasOne(Disqualification::class); + } + public function isClosed(): Attribute { return Attribute::get(fn(): bool => $this->userQuiz->isClosed); diff --git a/app/Models/UserQuiz.php b/app/Models/UserQuiz.php index 25366ab0..9b185362 100644 --- a/app/Models/UserQuiz.php +++ b/app/Models/UserQuiz.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Collection; /** @@ -24,6 +25,7 @@ * @property bool $isClosed * @property Quiz $quiz * @property User $user + * @property ?Disqualification $disqualification * @property Collection $userQuestions */ class UserQuiz extends Model @@ -40,6 +42,11 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + public function disqualification(): HasOne + { + return $this->hasOne(Disqualification::class); + } + public function userQuestions(): HasMany { return $this->hasMany(UserQuestion::class); diff --git a/app/Notifications/DisqualificationNotification.php b/app/Notifications/DisqualificationNotification.php new file mode 100644 index 00000000..8e2d7ebc --- /dev/null +++ b/app/Notifications/DisqualificationNotification.php @@ -0,0 +1,37 @@ +subject("Dyskwalifikacja z konkursu " . $this->userQuiz->quiz->title) + ->view("emails.disqualification", [ + "user" => $notifiable, + "userQuiz" => $this->userQuiz, + "reason" => $this->reason, + ]); + } +} diff --git a/app/Policies/UserQuizPolicy.php b/app/Policies/UserQuizPolicy.php index a15b0e23..4d1a9d32 100644 --- a/app/Policies/UserQuizPolicy.php +++ b/app/Policies/UserQuizPolicy.php @@ -23,4 +23,14 @@ public function result(User $user, UserQuiz $userQuiz): bool { return $user->id === $userQuiz->user_id && $userQuiz->isClosed; } + + public function disqualify(User $user, UserQuiz $userQuiz): bool + { + return $userQuiz->disqualification()->doesntExist(); + } + + public function undisqualify(User $user, UserQuiz $userQuiz): bool + { + return $userQuiz->disqualification()->exists(); + } } diff --git a/database/migrations/2025_01_17_184724_create_disqualifications_table.php b/database/migrations/2025_01_17_184724_create_disqualifications_table.php new file mode 100644 index 00000000..35dc513a --- /dev/null +++ b/database/migrations/2025_01_17_184724_create_disqualifications_table.php @@ -0,0 +1,25 @@ +id(); + $table->string("reason"); + $table->foreignIdFor(UserQuiz::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists("disqualifications"); + } +}; diff --git a/resources/views/emails/disqualification.blade.php b/resources/views/emails/disqualification.blade.php new file mode 100644 index 00000000..53d2f017 --- /dev/null +++ b/resources/views/emails/disqualification.blade.php @@ -0,0 +1,32 @@ + + + + +
+
+ interns2024b Logo +
+ +
+

Cześć, {{ $user->firstname }}!

+ +

+ Z przykrością informujemy, że zostałeś/-aś zdyskwalifikowany/-a z konkursu {{ $userQuiz->quiz->title }}.
+ Powodem tej decyzji jest {{ $reason }}. +

+ +

Pozdrawiamy,
{{ config('app.name') }}

+ +
+ +
+ © 2024 {{ config('app.name') }}. Wszelkie prawa zastrzeżone. +
+
+
+ diff --git a/routes/web.php b/routes/web.php index d48a95e0..bd7a247c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -64,6 +64,8 @@ Route::post("/quizzes/{quiz}/ranking/publish", [RankingController::class, "publish"])->can("publish,quiz")->name("admin.quizzes.ranking.publish"); Route::post("/quizzes/{quiz}/ranking/unpublish", [RankingController::class, "unpublish"])->can("publish,quiz")->name("admin.quizzes.ranking.unpublish"); + Route::post("/quizzes/ranking/disqualify/{userQuiz}", [RankingController::class, "disqualify"])->name("admin.quizzes.ranking.disqualify"); + Route::post("/quizzes/ranking/undisqualify/{userQuiz}", [RankingController::class, "undisqualify"])->name("admin.quizzes.ranking.undisqualify"); Route::post("/quizzes/{quiz}/questions", [QuizQuestionController::class, "store"])->can("create," . Question::class . ",quiz")->name("admin.questions.store"); Route::patch("/questions/{question}", [QuizQuestionController::class, "update"])->can("update,question")->name("admin.questions.update"); diff --git a/tests/Feature/RankingTest.php b/tests/Feature/RankingTest.php index f38b5fa7..34839ac0 100644 --- a/tests/Feature/RankingTest.php +++ b/tests/Feature/RankingTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; +use App\Models\Disqualification; use App\Models\Quiz; use App\Models\User; use App\Models\UserQuiz; @@ -20,6 +21,7 @@ class RankingTest extends TestCase protected User $admin; protected Quiz $quiz; protected Quiz $unlockedQuiz; + protected UserQuiz $userQuiz; protected function setUp(): void { @@ -31,7 +33,7 @@ protected function setUp(): void $this->admin = User::factory()->admin()->create(); $this->quiz = Quiz::query()->firstOrFail(); - UserQuizSeeder::createUserQuizForUser($this->quiz, $this->user, 2); + $this->userQuiz = UserQuizSeeder::createUserQuizForUser($this->quiz, $this->user, 2); $this->unlockedQuiz = Quiz::factory()->create(); } @@ -196,6 +198,64 @@ public function testUserCannotViewQuizRankingThatQuizDoesNotExist(): void ->assertNotFound(); } + public function testAdminCannotDisqualifyUserTestThatDoesNotExist(): void + { + $this->actingAs($this->admin) + ->post("/admin/quizzes/ranking/disqualify/123123") + ->assertNotFound(); + } + + public function testAdminCannotUndisqualifyUserTestThatDoesNotExist(): void + { + $this->actingAs($this->admin) + ->post("/admin/quizzes/ranking/undisqualify/123123") + ->assertNotFound(); + } + + public function testAdminCannotUndisqualifyNotDisqualifiedUserTest(): void + { + $this->actingAs($this->admin) + ->post("/admin/quizzes/ranking/undisqualify/{$this->userQuiz->id}") + ->assertForbidden(); + } + + public function testAdminCanUndisqualifyUserTest(): void + { + $disqualification = new Disqualification(); + $disqualification->reason = "Reason"; + $disqualification->userQuiz()->associate($this->userQuiz); + $disqualification->save(); + + $this->actingAs($this->admin) + ->post("/admin/quizzes/ranking/undisqualify/{$this->userQuiz->id}") + ->assertSessionHas("status", "Dyskwalifikacją użytkownika została cofnięta."); + + $this->quiz->refresh(); + $this->assertNull($this->userQuiz->disqualification); + } + + public function testAdminCannotDisqualifyDisqualifiedUserTest(): void + { + $disqualification = new Disqualification(); + $disqualification->reason = "Reason"; + $disqualification->userQuiz()->associate($this->userQuiz); + $disqualification->save(); + + $this->actingAs($this->admin) + ->post("/admin/quizzes/ranking/disqualify/{$this->userQuiz->id}", ["reason" => "Disqualification", "sendEmail" => false]) + ->assertForbidden(); + } + + public function testAdminCanDisqualifyUserTest(): void + { + $this->actingAs($this->admin) + ->post("/admin/quizzes/ranking/disqualify/{$this->userQuiz->id}", ["reason" => "Disqualification", "sendEmail" => false]) + ->assertSessionHas("status", "Użytkownik został zdyskwalifikowany."); + + $this->quiz->refresh(); + $this->assertTrue($this->userQuiz->disqualification()->exists()); + } + public function testUserCannotAccessToAdminRanking(): void { $this->actingAs($this->user) @@ -209,5 +269,13 @@ public function testUserCannotAccessToAdminRanking(): void $this->actingAs($this->user) ->post("/admin/quizzes/{$this->quiz->id}/ranking/unpublish") ->assertForbidden(); + + $this->actingAs($this->user) + ->post("/admin/quizzes/ranking/disqualify/{$this->userQuiz->id}") + ->assertForbidden(); + + $this->actingAs($this->user) + ->post("/admin/quizzes/ranking/undisqualify/{$this->userQuiz->id}") + ->assertForbidden(); } } diff --git a/tests/Unit/DisqualifyUserActionTest.php b/tests/Unit/DisqualifyUserActionTest.php new file mode 100644 index 00000000..aae88b8e --- /dev/null +++ b/tests/Unit/DisqualifyUserActionTest.php @@ -0,0 +1,54 @@ +userQuiz = UserQuiz::factory()->closed()->create(); + $this->action = new DisqualifyUserAction(); + } + + public function testDisqualifyUserWithoutEmailNotification(): void + { + Notification::fake(); + + $this->action->execute($this->userQuiz, "reason"); + + $this->assertDatabaseHas("disqualifications", [ + "reason" => "reason", + ]); + + Notification::assertNotSentTo($this->userQuiz->user, DisqualificationNotification::class); + } + + public function testDisqualifyUserWithEmailNotification(): void + { + Notification::fake(); + + $this->action->execute($this->userQuiz, "reason", true); + + $this->assertDatabaseHas("disqualifications", [ + "reason" => "reason", + ]); + + Notification::assertSentTo($this->userQuiz->user, DisqualificationNotification::class); + } +} diff --git a/tests/Unit/UndisqualifyUserActionTest.php b/tests/Unit/UndisqualifyUserActionTest.php new file mode 100644 index 00000000..f75d54e6 --- /dev/null +++ b/tests/Unit/UndisqualifyUserActionTest.php @@ -0,0 +1,43 @@ +userQuiz = UserQuiz::factory()->closed()->create(); + $this->disqualification = new Disqualification(); + $this->disqualification->reason = "Reason"; + + $this->disqualification->userQuiz()->associate($this->userQuiz); + $this->disqualification->save(); + + $this->action = new UndisqualifyUserAction(); + } + + public function testUndisqualifyUser(): void + { + $this->action->execute($this->userQuiz); + + $this->assertDatabaseMissing("disqualifications", [ + "id" => $this->disqualification->id, + ]); + } +}