From 2ee0cf23cc80bad421959e1d2676198a91b7f7ef Mon Sep 17 00:00:00 2001 From: Matt Young Date: Sun, 14 Jul 2024 23:36:46 -0500 Subject: [PATCH 01/16] Original migrations and models #20 Implement bonus scores --- app/Models/BonusScore.php | 11 +++++++ app/Models/BonusScoreDefinition.php | 11 +++++++ ...2_create_bonus_score_definitions_table.php | 32 ++++++++++++++++++ ..._bonus_score_audition_assignment_table.php | 31 +++++++++++++++++ ...07_15_042700_create_bonus_scores_table.php | 33 +++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 app/Models/BonusScore.php create mode 100644 app/Models/BonusScoreDefinition.php create mode 100644 database/migrations/2024_07_15_040312_create_bonus_score_definitions_table.php create mode 100644 database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php create mode 100644 database/migrations/2024_07_15_042700_create_bonus_scores_table.php diff --git a/app/Models/BonusScore.php b/app/Models/BonusScore.php new file mode 100644 index 0000000..6629a7f --- /dev/null +++ b/app/Models/BonusScore.php @@ -0,0 +1,11 @@ +id(); + $table->string('name'); + $table->integer('max_score'); + $table->float('weight'); + $table->boolean('for_seating')->default(true); + $table->boolean('for_attendance')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bonus_score_definitions'); + } +}; diff --git a/database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php b/database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php new file mode 100644 index 0000000..abc600d --- /dev/null +++ b/database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(BonusScoreDefinition::class)->constrained()->onDelete('cascade')->onUpdate('cascade'); + $table->foreignIdFor(Audition::class)->constrained()->onDelete('cascade')->onUpdate('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bonus_score_audition_assignment'); + } +}; diff --git a/database/migrations/2024_07_15_042700_create_bonus_scores_table.php b/database/migrations/2024_07_15_042700_create_bonus_scores_table.php new file mode 100644 index 0000000..c44e38c --- /dev/null +++ b/database/migrations/2024_07_15_042700_create_bonus_scores_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignIdFor(Entry::class); + $table->foreignIdFor(User::class); + $table->foreignId('originally_scored_entry')->nullable()->constrained('entries')->nullOnDelete(); + $table->integer('score'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bonus_scores'); + } +}; -- 2.39.5 From 192191c0794f4b9fd26e227c067b4cc7dc5f3c5e Mon Sep 17 00:00:00 2001 From: Matt Young Date: Sun, 14 Jul 2024 23:54:20 -0500 Subject: [PATCH 02/16] Begin Bonus Score Index #20 Implement bonus scores Setup controller and index method Index view Add menu item to set up menu to access --- .../Admin/BonusScoreDefinitionController.php | 14 ++++++++++ .../views/admin/bonus-scores/index.blade.php | 4 +++ .../layout/navbar/menus/setup.blade.php | 1 + routes/admin.php | 10 +++++++ .../Pages/Setup/BonusScoreIndexTest.php | 28 +++++++++++++++++++ 5 files changed, 57 insertions(+) create mode 100644 app/Http/Controllers/Admin/BonusScoreDefinitionController.php create mode 100644 resources/views/admin/bonus-scores/index.blade.php create mode 100644 tests/Feature/Pages/Setup/BonusScoreIndexTest.php diff --git a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php new file mode 100644 index 0000000..a83b356 --- /dev/null +++ b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php @@ -0,0 +1,14 @@ + + + + diff --git a/resources/views/components/layout/navbar/menus/setup.blade.php b/resources/views/components/layout/navbar/menus/setup.blade.php index 42c4d80..92a3191 100644 --- a/resources/views/components/layout/navbar/menus/setup.blade.php +++ b/resources/views/components/layout/navbar/menus/setup.blade.php @@ -26,6 +26,7 @@ Ensembles Seating Limits Scoring + Bonus Scores Rooms Judges Run Draw diff --git a/routes/admin.php b/routes/admin.php index f0f9c55..bd44f5a 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -13,6 +13,16 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> Route::get('/settings', [\App\Http\Controllers\Admin\AuditionSettings::class, 'index'])->name('audition-settings'); Route::post('/settings', [\App\Http\Controllers\Admin\AuditionSettings::class, 'save'])->name('audition-settings-save'); + // Admin Bonus Scores Routes + Route::prefix('bonus-scores')->controller(\App\Http\Controllers\Admin\BonusScoreDefinitionController::class)->group(function () { + Route::get('/', 'index')->name('admin.bonus-scores.index'); +// Route::get('/create', 'create')->name('admin.bonus-scores.create'); +// Route::post('/', 'store')->name('admin.bonus-scores.store'); +// Route::get('/{bonusScoreDefinition}/edit', 'edit')->name('admin.bonus-scores.edit'); +// Route::patch('/{bonusScoreDefinition}', 'update')->name('admin.bonus-scores.update'); +// Route::delete('/{bonusScoreDefinition}', 'destroy')->name('admin.bonus-scores.destroy'); + }); + // Admin Ensemble Routes Route::prefix('ensembles')->controller(\App\Http\Controllers\Admin\EnsembleController::class)->group(function () { Route::get('/', 'index')->name('admin.ensembles.index'); diff --git a/tests/Feature/Pages/Setup/BonusScoreIndexTest.php b/tests/Feature/Pages/Setup/BonusScoreIndexTest.php new file mode 100644 index 0000000..384da24 --- /dev/null +++ b/tests/Feature/Pages/Setup/BonusScoreIndexTest.php @@ -0,0 +1,28 @@ +get(route('admin.bonus-scores.index')) + ->assertRedirect(route('home')); + + actAsNormal(); + $this->get(route('admin.bonus-scores.index')) + ->assertRedirect(route('dashboard')) + ->assertSessionHas('error', 'You are not authorized to perform this action'); + + actAsTab(); + $this->get(route('admin.bonus-scores.index')) + ->assertRedirect(route('dashboard')) + ->assertSessionHas('error', 'You are not authorized to perform this action'); +}); +it('grants access to an administrator', function () { + // Arrange + actAsAdmin(); + // Act & Assert + $this->get(route('admin.bonus-scores.index')) + ->assertOk() + ->assertViewIs('admin.bonus-scores.index'); +}); -- 2.39.5 From 0f1ca583dd56734a7639d1f2c0bb4f954e9f4137 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 01:41:06 -0500 Subject: [PATCH 03/16] Work on Admin Bonus Score Index #20 Implement bonus scores Index shows a card for each bonus score Include a help modal Include a form to create a new bonus score --- .../Admin/BonusScoreDefinitionController.php | 21 +++++++- app/Models/BonusScoreDefinition.php | 2 + .../factories/BonusScoreDefinitionFactory.php | 25 ++++++++++ ..._bonus_score_audition_assignment_table.php | 7 ++- .../index-add-bonus-score-modal.blade.php | 16 ++++++ .../bonus-scores/index-help-modal.blade.php | 24 +++++++++ .../index-no-bonus-scores-message.blade.php | 22 ++++++++ .../views/admin/bonus-scores/index.blade.php | 20 +++++++- .../views/components/modal-body.blade.php | 8 +-- routes/admin.php | 10 ++-- .../Pages/Setup/BonusScoreIndexTest.php | 50 +++++++++++++++++++ 11 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 database/factories/BonusScoreDefinitionFactory.php create mode 100644 resources/views/admin/bonus-scores/index-add-bonus-score-modal.blade.php create mode 100644 resources/views/admin/bonus-scores/index-help-modal.blade.php create mode 100644 resources/views/admin/bonus-scores/index-no-bonus-scores-message.blade.php diff --git a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php index a83b356..59c8635 100644 --- a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php +++ b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php @@ -3,12 +3,29 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use Illuminate\Http\Request; +use App\Models\BonusScoreDefinition; + +use function to_route; class BonusScoreDefinitionController extends Controller { public function index() { - return view('admin.bonus-scores.index'); + $bonusScores = BonusScoreDefinition::all(); + + return view('admin.bonus-scores.index', compact('bonusScores')); + } + + public function store() + { + $validData = request()->validate([ + 'name' => 'required', + 'max_score' => 'required|numeric', + 'weight' => 'required|numeric', + ]); + + BonusScoreDefinition::create($validData); + + return to_route('admin.bonus-scores.index')->with('success', 'Bonus Score Created'); } } diff --git a/app/Models/BonusScoreDefinition.php b/app/Models/BonusScoreDefinition.php index 89cd89a..abe0d32 100644 --- a/app/Models/BonusScoreDefinition.php +++ b/app/Models/BonusScoreDefinition.php @@ -8,4 +8,6 @@ use Illuminate\Database\Eloquent\Model; class BonusScoreDefinition extends Model { use HasFactory; + + protected $fillable = ['name', 'max_score', 'weight']; } diff --git a/database/factories/BonusScoreDefinitionFactory.php b/database/factories/BonusScoreDefinitionFactory.php new file mode 100644 index 0000000..4dbb036 --- /dev/null +++ b/database/factories/BonusScoreDefinitionFactory.php @@ -0,0 +1,25 @@ + + */ +class BonusScoreDefinitionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => $this->faker->word, + 'max_score' => $this->faker->randomNumber(2), + 'weight' => $this->faker->randomFloat(2, 0, 2), + ]; + } +} diff --git a/database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php b/database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php index abc600d..e229bb4 100644 --- a/database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php +++ b/database/migrations/2024_07_15_042419_create_bonus_score_audition_assignment_table.php @@ -15,8 +15,11 @@ return new class extends Migration { Schema::create('bonus_score_audition_assignment', function (Blueprint $table) { $table->id(); - $table->foreignIdFor(BonusScoreDefinition::class)->constrained()->onDelete('cascade')->onUpdate('cascade'); - $table->foreignIdFor(Audition::class)->constrained()->onDelete('cascade')->onUpdate('cascade'); + $table->foreignIdFor(BonusScoreDefinition::class) + ->constrained('bonus_score_definitions', 'id', 'bs_audition_assignment_bonus_score_definition_id') + ->onDelete('cascade')->onUpdate('cascade'); + $table->foreignIdFor(Audition::class) + ->constrained()->onDelete('cascade')->onUpdate('cascade'); $table->timestamps(); }); } diff --git a/resources/views/admin/bonus-scores/index-add-bonus-score-modal.blade.php b/resources/views/admin/bonus-scores/index-add-bonus-score-modal.blade.php new file mode 100644 index 0000000..9f514e3 --- /dev/null +++ b/resources/views/admin/bonus-scores/index-add-bonus-score-modal.blade.php @@ -0,0 +1,16 @@ + + + Add Bonus Score + + + + + + +
+ Create Bonus Score +
+ +
+
+
diff --git a/resources/views/admin/bonus-scores/index-help-modal.blade.php b/resources/views/admin/bonus-scores/index-help-modal.blade.php new file mode 100644 index 0000000..1235f11 --- /dev/null +++ b/resources/views/admin/bonus-scores/index-help-modal.blade.php @@ -0,0 +1,24 @@ + +

Bonus scores are most often used for an improvisation score for jazz band auditions. A bonus score + earned by an entry will be directly added + to that entries final score. When you create a bonus score, you will also specify to which auditions that bonus + score should apply. When a student + earns a bonus score for one entry, that bonus will be applied to all entries that receive that bonus score.

+ +

+ Let's say you create a bonus score called, "Saxophone Improvisation," and assign Jazz Alto, Jazz Tenor, and Jazz + Bari auditions to that bonus + score. If a student is entered on all three saxes, when they receive an improv score on one sax, that score will + apply to all 3. The system + will not allow another improv score to be assigned by the same judge unless the first one is deleted. If you + want that student to improv on each instrument + separately, you will need to create a separate bonus score for each instrument. +

+ +

+ The weight allows you to control how much influence the bonus score has on the outcome of the audition. The + bonus score is + multiplied by the weight then added to the final score. The weight may be any positive number, including + decimals. +

+
diff --git a/resources/views/admin/bonus-scores/index-no-bonus-scores-message.blade.php b/resources/views/admin/bonus-scores/index-no-bonus-scores-message.blade.php new file mode 100644 index 0000000..ed3c5fe --- /dev/null +++ b/resources/views/admin/bonus-scores/index-no-bonus-scores-message.blade.php @@ -0,0 +1,22 @@ +
+ + + +

No bonus scores have been created

+

Get started by creating a new bonus score.

+
+ +
+
+ + diff --git a/resources/views/admin/bonus-scores/index.blade.php b/resources/views/admin/bonus-scores/index.blade.php index b3c1995..60a4797 100644 --- a/resources/views/admin/bonus-scores/index.blade.php +++ b/resources/views/admin/bonus-scores/index.blade.php @@ -1,4 +1,22 @@ - + +Bonus Score Management + + @include('admin.bonus-scores.index-help-modal') + + @if($bonusScores->count() === 0) + @include('admin.bonus-scores.index-no-bonus-scores-message') + @endif + @foreach($bonusScores as $bonusScore) + + + {{ $bonusScore->name }} + + Max Points: {{ $bonusScore->max_score }} | Weight: {{ $bonusScore->weight }} + + + + @endforeach + @include('admin.bonus-scores.index-add-bonus-score-modal') diff --git a/resources/views/components/modal-body.blade.php b/resources/views/components/modal-body.blade.php index 01584c7..a18c886 100644 --- a/resources/views/components/modal-body.blade.php +++ b/resources/views/components/modal-body.blade.php @@ -1,12 +1,12 @@ -@props(['title'=>false]) +@props(['title'=>false, 'showVar'=>'showModal'])
attributes->merge(['class' => 'mr-3 text-black max-w-none']) }}>{{ $title ?? '' }} @endif - + + {{ $audition->name }} +
+ @endforeach + +
+ + Add Auditions to {{ $bonusScore->name }} + @endforeach - + @if($bonusScores->count() !== 0) + Add Bonus Score + @endif + @include('admin.bonus-scores.index-add-auditions-to-bonus-modal') @include('admin.bonus-scores.index-add-bonus-score-modal')
diff --git a/resources/views/components/icons/circled-x.blade.php b/resources/views/components/icons/circled-x.blade.php new file mode 100644 index 0000000..49cf742 --- /dev/null +++ b/resources/views/components/icons/circled-x.blade.php @@ -0,0 +1,4 @@ +@props(['color' => 'currentColor', 'title'=>false]) + diff --git a/routes/admin.php b/routes/admin.php index 0ff8a52..b06b06c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -16,11 +16,11 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> // Admin Bonus Scores Routes Route::prefix('bonus-scores')->controller(\App\Http\Controllers\Admin\BonusScoreDefinitionController::class)->group(function () { Route::get('/', 'index')->name('admin.bonus-scores.index'); - // Route::get('/create', 'create')->name('admin.bonus-scores.create'); Route::post('/', 'store')->name('admin.bonus-scores.store'); - // Route::get('/{bonusScoreDefinition}/edit', 'edit')->name('admin.bonus-scores.edit'); - // Route::patch('/{bonusScoreDefinition}', 'update')->name('admin.bonus-scores.update'); - // Route::delete('/{bonusScoreDefinition}', 'destroy')->name('admin.bonus-scores.destroy'); + Route::post('/assign_auditions', 'assignAuditions')->name('admin.bonus-scores.addAuditions'); + Route::delete('/{audition}/unassign_audition', 'unassignAudition')->name('admin.bonus-scores.unassignAudition'); + Route::delete('/{bonusScore}', 'destroy')->name('admin.bonus-scores.destroy'); + }); // Admin Ensemble Routes diff --git a/tests/Feature/Pages/Setup/BonusScoreIndexTest.php b/tests/Feature/Pages/Setup/BonusScoreIndexTest.php index 6aa1f4d..ab52651 100644 --- a/tests/Feature/Pages/Setup/BonusScoreIndexTest.php +++ b/tests/Feature/Pages/Setup/BonusScoreIndexTest.php @@ -1,5 +1,6 @@ containsInput(['name' => 'weight']); }); }); -it('can create a new subscore', function () { +it('can create a new bonus score', function () { // Arrange $submissionData = [ 'name' => 'New Bonus Score', @@ -76,3 +77,75 @@ it('shows existing bonus scores', function () { $response->assertOk(); $bonusScores->each(fn ($bonusScore) => $response->assertSee($bonusScore->name)); }); +it('can delete a bonus score with no auditions', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + actAsAdmin(); + // Act & Assert + $this->delete(route('admin.bonus-scores.destroy', $bonusScore)) + ->assertRedirect(route('admin.bonus-scores.index')) + ->assertSessionHas('success', 'Bonus Score Deleted'); + expect(BonusScoreDefinition::count())->toBe(0); +}); +it('will not delete a bonus score that has auditions attached', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->hasAuditions(1)->create(); + actAsAdmin(); + // Act & Assert + $this->delete(route('admin.bonus-scores.destroy', $bonusScore)) + ->assertRedirect(route('admin.bonus-scores.index')) + ->assertSessionHas('error', 'Bonus Score has auditions attached'); + expect(BonusScoreDefinition::count())->toBe(1); +}); +it('can assign auditions to a bonus score', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + $auditions = Audition::factory()->count(3)->create(); + $submissionData = [ + 'bonus_score_id' => $bonusScore->id, + ]; + foreach ($auditions as $audition) { + $submissionData['audition'][$audition->id] = 'on'; + } + // Act & Assert + actAsAdmin(); + $this->post(route('admin.bonus-scores.addAuditions'), $submissionData) + ->assertRedirect(route('admin.bonus-scores.index')) + ->assertSessionHas('success', 'Auditions assigned to bonus score'); + $bonusScore->refresh(); + $auditions->each(fn ($audition) => expect($bonusScore->auditions->contains($audition))->toBeTrue()); +}); +it('can unassign auditions from a bonus score', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->hasAuditions(3)->create(); + $audition = $bonusScore->auditions->first(); + // Act & Assert + actAsAdmin(); + $this->delete(route('admin.bonus-scores.unassignAudition', $audition)) + ->assertRedirect(route('admin.bonus-scores.index')) + ->assertSessionHas('success', 'Audition unassigned from bonus score'); + $bonusScore->refresh(); + expect($bonusScore->auditions->contains($audition))->toBeFalse(); +}); +it('sends a message when attempting to unassign an audition that is not assigned', function () { + $bonusScore = BonusScoreDefinition::factory()->create(); + $audition = Audition::factory()->create(); + actAsAdmin(); + $this->delete(route('admin.bonus-scores.unassignAudition', $audition)) + ->assertRedirect(route('admin.bonus-scores.index')) + ->assertSessionHas('error', 'Audition does not have a bonus score'); +}); +it('will not allow an audition to be assigned to multiple bonus scores', function () { + $bonusScore1 = BonusScoreDefinition::factory()->create(); + $bonusScore2 = BonusScoreDefinition::factory()->create(); + $audition = Audition::factory()->create(); + $bonusScore1->auditions()->attach($audition); + $submissionData = [ + 'bonus_score_id' => $bonusScore2->id, + 'audition' => [$audition->id => 'on'], + ]; + actAsAdmin(); + $this->post(route('admin.bonus-scores.addAuditions'), $submissionData) + ->assertRedirect(route('admin.bonus-scores.index')) + ->assertSessionHas('error', 'Error assigning auditions to bonus score'); +}); -- 2.39.5 From 7e0b8f51d96f7996c68b46bf7aaaa7d121639eb3 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 14:06:11 -0500 Subject: [PATCH 05/16] Tidying Up --- .../Admin/BonusScoreDefinitionController.php | 4 +- routes/admin.php | 72 +++++++++++++------ 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php index 43cf6a5..a0b9f45 100644 --- a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php +++ b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php @@ -58,9 +58,9 @@ class BonusScoreDefinitionController extends Controller foreach ($validData['audition'] as $auditionId => $value) { try { $bonusScore->auditions()->attach($auditionId); - } catch (Exception $ex) { + } catch (Exception) { return redirect()->route('admin.bonus-scores.index')->with('error', - 'Error assigning auditions to bonus score - '.$ex->getMessage()); + 'Error assigning auditions to bonus score'); } } diff --git a/routes/admin.php b/routes/admin.php index b06b06c..2ba942d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,20 +1,38 @@ prefix('admin/')->group(function () { Route::view('/', 'admin.dashboard')->name('admin.dashboard'); - Route::post('/auditions/roomUpdate', [\App\Http\Controllers\Admin\AuditionController::class, 'roomUpdate']); // Endpoint for JS assigning auditions to rooms - Route::post('/scoring/assign_guide_to_audition', [\App\Http\Controllers\Admin\AuditionController::class, 'scoringGuideUpdate'])->name('ajax.assignScoringGuideToAudition'); // Endpoint for JS assigning scoring guides to auditions + Route::post('/auditions/roomUpdate', [ + AuditionController::class, 'roomUpdate', + ]); // Endpoint for JS assigning auditions to rooms + Route::post('/scoring/assign_guide_to_audition', [ + AuditionController::class, 'scoringGuideUpdate', + ])->name('ajax.assignScoringGuideToAudition'); // Endpoint for JS assigning scoring guides to auditions - Route::get('/settings', [\App\Http\Controllers\Admin\AuditionSettings::class, 'index'])->name('audition-settings'); - Route::post('/settings', [\App\Http\Controllers\Admin\AuditionSettings::class, 'save'])->name('audition-settings-save'); + Route::get('/settings', [AuditionSettings::class, 'index'])->name('audition-settings'); + Route::post('/settings', + [AuditionSettings::class, 'save'])->name('audition-settings-save'); // Admin Bonus Scores Routes - Route::prefix('bonus-scores')->controller(\App\Http\Controllers\Admin\BonusScoreDefinitionController::class)->group(function () { + Route::prefix('bonus-scores')->controller(BonusScoreDefinitionController::class)->group(function ( + ) { Route::get('/', 'index')->name('admin.bonus-scores.index'); Route::post('/', 'store')->name('admin.bonus-scores.store'); Route::post('/assign_auditions', 'assignAuditions')->name('admin.bonus-scores.addAuditions'); @@ -24,7 +42,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> }); // Admin Ensemble Routes - Route::prefix('ensembles')->controller(\App\Http\Controllers\Admin\EnsembleController::class)->group(function () { + Route::prefix('ensembles')->controller(EnsembleController::class)->group(function () { Route::get('/', 'index')->name('admin.ensembles.index'); Route::post('/', 'store')->name('admin.ensembles.store'); Route::delete('/{ensemble}', 'destroy')->name('admin.ensembles.destroy'); @@ -32,45 +50,52 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> Route::patch('/{ensemble}', 'updateEnsemble')->name('admin.ensembles.update'); Route::get('/seating-limits', 'seatingLimits')->name('admin.ensembles.seatingLimits'); Route::get('/seating-limits/{ensemble}', 'seatingLimits')->name('admin.ensembles.seatingLimits.ensemble'); - Route::post('/seating-limits/{ensemble}', 'seatingLimitsSet')->name('admin.ensembles.seatingLimits.ensemble.set'); + Route::post('/seating-limits/{ensemble}', + 'seatingLimitsSet')->name('admin.ensembles.seatingLimits.ensemble.set'); }); // Admin Event Routes - Route::prefix('events')->controller(\App\Http\Controllers\Admin\EventController::class)->group(function () { + Route::prefix('events')->controller(EventController::class)->group(function () { Route::get('/', 'index')->name('admin.events.index'); Route::post('/', 'store')->name('admin.events.store'); Route::delete('/{event}', 'destroy')->name('admin.events.destroy'); }); // Admin Rooms Routes - Route::prefix('rooms')->controller(\App\Http\Controllers\Admin\RoomController::class)->group(function () { + Route::prefix('rooms')->controller(RoomController::class)->group(function () { Route::get('/', 'index')->name('admin.rooms.index'); Route::get('/create', 'create')->name('admin.rooms.create'); Route::post('/', 'store')->name('admin.rooms.store'); Route::post('/{room}/edit', 'edit')->name('admin.rooms.edit'); Route::patch('/{room}', 'update')->name('admin.rooms.update'); Route::delete('/{room}', 'destroy')->name('admin.rooms.destroy'); - Route::get('/judging_assignments', 'judgingAssignment')->name('admin.rooms.judgingAssignment'); // Screen to assign judges to rooms - Route::match(['post', 'delete'], '/{room}/judge', 'updateJudgeAssignment')->name('admin.rooms.updateJudgeAssignment'); + Route::get('/judging_assignments', + 'judgingAssignment')->name('admin.rooms.judgingAssignment'); // Screen to assign judges to rooms + Route::match(['post', 'delete'], '/{room}/judge', + 'updateJudgeAssignment')->name('admin.rooms.updateJudgeAssignment'); }); // Admin Scoring Guides - Route::prefix('scoring')->controller(\App\Http\Controllers\Admin\ScoringGuideController::class)->group(function () { + Route::prefix('scoring')->controller(ScoringGuideController::class)->group(function () { Route::get('/', 'index')->name('admin.scoring.index'); // Scoring Setup Homepage Route::post('/guides', 'store')->name('admin.scoring.store'); // Save a new scoring guide Route::get('/guides/{guide}/edit/{tab?}', 'edit')->name('admin.scoring.edit'); // Edit scoring guide - Route::patch('/guides/{guide}/edit', 'update')->name('admin.scoring.update'); // Save changes to audition guide (rename) - Route::post('/guides/{guide}/subscore', 'subscore_store')->name('admin.scoring.subscore_store'); // Save a new subscore - Route::patch('/guides/{guide}/subscore/{subscore}', 'subscore_update')->name('admin.scoring.subscore_update'); // Modify a subscore - Route::delete('/guides/{guide}/subscore/{subscore}', 'subscore_destroy')->name('admin.scoring.subscore_destroy'); // Delete a subscore + Route::patch('/guides/{guide}/edit', + 'update')->name('admin.scoring.update'); // Save changes to audition guide (rename) + Route::post('/guides/{guide}/subscore', + 'subscore_store')->name('admin.scoring.subscore_store'); // Save a new subscore + Route::patch('/guides/{guide}/subscore/{subscore}', + 'subscore_update')->name('admin.scoring.subscore_update'); // Modify a subscore + Route::delete('/guides/{guide}/subscore/{subscore}', + 'subscore_destroy')->name('admin.scoring.subscore_destroy'); // Delete a subscore Route::post('/reorder-display', 'reorder_display')->name('admin.scoring.reorder_display'); Route::post('/reorder-tiebreak', 'reorder_tiebreak')->name('admin.scoring.reorder_tiebreak'); Route::delete('/guides/{guide}', 'destroy')->name('admin.scoring.destroy'); // Delete a scoring guide }); // Admin Auditions Routes - Route::prefix('auditions')->controller(\App\Http\Controllers\Admin\AuditionController::class)->group(function () { + Route::prefix('auditions')->controller(AuditionController::class)->group(function () { Route::get('/', 'index')->name('admin.auditions.index'); Route::get('/create', 'create')->name('admin.auditions.create'); Route::post('/', 'store')->name('admin.auditions.store'); @@ -81,15 +106,16 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> }); // Admin Audition Draw Routes - Route::prefix('draw')->controller(\App\Http\Controllers\Admin\DrawController::class)->group(function () { + Route::prefix('draw')->controller(DrawController::class)->group(function () { Route::get('/', 'index')->name('admin.draw.index'); Route::post('/', 'store')->name('admin.draw.store'); - Route::get('/clear', 'edit')->name('admin.draw.edit'); // Select auditions for which the user would like to clear the draw + Route::get('/clear', + 'edit')->name('admin.draw.edit'); // Select auditions for which the user would like to clear the draw Route::delete('/', 'destroy')->name('admin.draw.destroy'); // Clear the draw for the selected auditions }); // Admin Entries Routes - Route::prefix('entries')->controller(\App\Http\Controllers\Admin\EntryController::class)->group(function () { + Route::prefix('entries')->controller(EntryController::class)->group(function () { Route::get('/', 'index')->name('admin.entries.index'); Route::get('/create', 'create')->name('admin.entries.create'); Route::post('/', 'store')->name('admin.entries.store'); @@ -100,7 +126,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> }); // Admin Student Routes - Route::prefix('students')->controller(\App\Http\Controllers\Admin\StudentController::class)->group(function () { + Route::prefix('students')->controller(StudentController::class)->group(function () { Route::get('/', 'index')->name('admin.students.index'); Route::get('/create', 'create')->name('admin.students.create'); Route::post('/', 'store')->name('admin.students.store'); @@ -110,7 +136,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> }); // Admin School Routes - Route::prefix('schools')->controller(\App\Http\Controllers\Admin\SchoolController::class)->group(function () { + Route::prefix('schools')->controller(SchoolController::class)->group(function () { Route::post('/{school}/add_domain', 'add_domain')->name('admin.schools.add_domain'); Route::get('/', 'index')->name('admin.schools.index'); Route::get('/create', 'create')->name('admin.schools.create'); @@ -125,7 +151,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> }); // Admin User Routes - Route::prefix('users')->controller(\App\Http\Controllers\Admin\UserController::class)->group(function () { + Route::prefix('users')->controller(UserController::class)->group(function () { Route::get('/', 'index')->name('admin.users.index'); Route::get('/create', 'create')->name('admin.users.create'); Route::post('/', 'store')->name('admin.users.store'); -- 2.39.5 From bfb4b54e18798e1364ffdbd74e26206cb7e2c6dc Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 14:29:39 -0500 Subject: [PATCH 06/16] Work on assigning bonus judges #20 Implement bonus scores Add controls on judge assignment page to access a screen for bonus score judges. --- .../Admin/BonusScoreDefinitionController.php | 5 +++++ app/Http/Controllers/Admin/RoomController.php | 6 ++++-- .../views/admin/rooms/judge_assignments.blade.php | 14 +++++++++++++- routes/admin.php | 1 + 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php index a0b9f45..f47c60d 100644 --- a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php +++ b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php @@ -79,4 +79,9 @@ class BonusScoreDefinitionController extends Controller return redirect()->route('admin.bonus-scores.index')->with('success', 'Audition unassigned from bonus score'); } + + public function judges() + { + echo 'boo'; + } } diff --git a/app/Http/Controllers/Admin/RoomController.php b/app/Http/Controllers/Admin/RoomController.php index 0b64b1f..6d301d0 100644 --- a/app/Http/Controllers/Admin/RoomController.php +++ b/app/Http/Controllers/Admin/RoomController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Models\BonusScoreDefinition; use App\Models\Room; use App\Models\User; use Illuminate\Http\Request; @@ -17,7 +18,7 @@ class RoomController extends Controller if (! Auth::user()->is_admin) { abort(403); } - $rooms = Room::with('auditions.entries','entries')->orderBy('name')->get(); + $rooms = Room::with('auditions.entries', 'entries')->orderBy('name')->get(); return view('admin.rooms.index', ['rooms' => $rooms]); } @@ -27,8 +28,9 @@ class RoomController extends Controller $usersWithoutRooms = User::doesntHave('rooms')->orderBy('last_name')->orderBy('first_name')->get(); $usersWithRooms = User::has('rooms')->orderBy('last_name')->orderBy('first_name')->get(); $rooms = Room::with(['judges.school', 'auditions'])->get(); + $bonusScoresExist = BonusScoreDefinition::count() > 0; - return view('admin.rooms.judge_assignments', compact('usersWithoutRooms', 'usersWithRooms', 'rooms')); + return view('admin.rooms.judge_assignments', compact('usersWithoutRooms', 'usersWithRooms', 'rooms', 'bonusScoresExist')); } public function updateJudgeAssignment(Request $request, Room $room) diff --git a/resources/views/admin/rooms/judge_assignments.blade.php b/resources/views/admin/rooms/judge_assignments.blade.php index 6ae2aec..eada06f 100644 --- a/resources/views/admin/rooms/judge_assignments.blade.php +++ b/resources/views/admin/rooms/judge_assignments.blade.php @@ -1,5 +1,17 @@ -
    + @if($bonusScoresExist) +
    + +
    + @endif + +
      @foreach($rooms as $room) @if($room->id == 0) diff --git a/routes/admin.php b/routes/admin.php index 2ba942d..b89835d 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -38,6 +38,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> Route::post('/assign_auditions', 'assignAuditions')->name('admin.bonus-scores.addAuditions'); Route::delete('/{audition}/unassign_audition', 'unassignAudition')->name('admin.bonus-scores.unassignAudition'); Route::delete('/{bonusScore}', 'destroy')->name('admin.bonus-scores.destroy'); + Route::get('/judges', 'judges')->name('admin.bonus-scores.judges'); }); -- 2.39.5 From 551491ea87935e3c100b47193af2bd2521803c63 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 15:19:29 -0500 Subject: [PATCH 07/16] Bonus Score Judge Management #20 Implement bonus scores Bonus score judge management complete. --- .../Admin/BonusScoreDefinitionController.php | 32 ++++- app/Models/BonusScoreDefinition.php | 5 + ...ate_bonus_score_judge_assignment_table.php | 34 ++++++ .../bonus-scores/judge-assignments.blade.php | 111 ++++++++++++++++++ routes/admin.php | 2 + .../Pages/Setup/BonusScoreJudgesTest.php | 70 +++++++++++ 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2024_07_15_194558_create_bonus_score_judge_assignment_table.php create mode 100644 resources/views/admin/bonus-scores/judge-assignments.blade.php create mode 100644 tests/Feature/Pages/Setup/BonusScoreJudgesTest.php diff --git a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php index f47c60d..b7f600c 100644 --- a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php +++ b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Audition; use App\Models\BonusScoreDefinition; +use App\Models\User; use App\Rules\ValidateAuditionKey; use Exception; use Illuminate\Http\Request; @@ -82,6 +83,35 @@ class BonusScoreDefinitionController extends Controller public function judges() { - echo 'boo'; + $bonusScores = BonusScoreDefinition::all(); + $users = User::all(); + + return view('admin.bonus-scores.judge-assignments', compact('bonusScores', 'users')); + } + + public function assignJudge(BonusScoreDefinition $bonusScore) + { + if (! $bonusScore->exists()) { + return redirect()->route('admin.bonus-scores.judges')->with('error', 'Bonus Score not found'); + } + $validData = request()->validate([ + 'judge' => 'required|exists:users,id', + ]); + $bonusScore->judges()->attach($validData['judge']); + + return redirect()->route('admin.bonus-scores.judges')->with('success', 'Judge assigned to bonus score'); + } + + public function removeJudge(BonusScoreDefinition $bonusScore) + { + if (! $bonusScore->exists()) { + return redirect()->route('admin.bonus-scores.judges')->with('error', 'Bonus Score not found'); + } + $validData = request()->validate([ + 'judge' => 'required|exists:users,id', + ]); + $bonusScore->judges()->detach($validData['judge']); + + return redirect()->route('admin.bonus-scores.judges')->with('success', 'Judge removed from bonus score'); } } diff --git a/app/Models/BonusScoreDefinition.php b/app/Models/BonusScoreDefinition.php index 590df30..8cc82a9 100644 --- a/app/Models/BonusScoreDefinition.php +++ b/app/Models/BonusScoreDefinition.php @@ -16,4 +16,9 @@ class BonusScoreDefinition extends Model { return $this->belongsToMany(Audition::class, 'bonus_score_audition_assignment')->orderBy('score_order'); } + + public function judges(): BelongsToMany + { + return $this->belongsToMany(User::class, 'bonus_score_judge_assignment'); + } } diff --git a/database/migrations/2024_07_15_194558_create_bonus_score_judge_assignment_table.php b/database/migrations/2024_07_15_194558_create_bonus_score_judge_assignment_table.php new file mode 100644 index 0000000..8e19376 --- /dev/null +++ b/database/migrations/2024_07_15_194558_create_bonus_score_judge_assignment_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignIdFor(BonusScoreDefinition::class) + ->constrained('bonus_score_definitions', 'id', 'bs_judge_assignment_bonus_score_definition_id') + ->onDelete('cascade')->onUpdate('cascade'); + $table->foreignIdFor(User::class) + ->constrained()->onDelete('cascade')->onUpdate('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bonus_score_judge_assignment'); + } +}; diff --git a/resources/views/admin/bonus-scores/judge-assignments.blade.php b/resources/views/admin/bonus-scores/judge-assignments.blade.php new file mode 100644 index 0000000..116d14d --- /dev/null +++ b/resources/views/admin/bonus-scores/judge-assignments.blade.php @@ -0,0 +1,111 @@ + +
      + +
      + +
        + + @foreach($bonusScores as $bonusScore) +
      • {{-- card wrapper --}} +
        {{-- card header --}} +
        +

        {{ $bonusScore->name }}

        +
        + +
        {{-- Auditions Dropdown --}} + + + + +
        +
        {{-- End Card Header --}} + + +
        {{-- Judge Listing --}} + @foreach($bonusScore->judges as $judge) +
        {{-- Judge Line --}} +
        +

        + {{ $judge->full_name() }} + {{ $judge->school->name ?? '' }} +

        +

        {{ $judge->judging_preference }}

        +
        +
        +
        + @csrf + @method('DELETE') + + +
        +
        +
        + @endforeach + +
        {{-- Add Judge Form --}} +
        + @csrf + +
        +
        +
        +
      • + @endforeach +
      +
      diff --git a/routes/admin.php b/routes/admin.php index b89835d..f126e75 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -39,6 +39,8 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> Route::delete('/{audition}/unassign_audition', 'unassignAudition')->name('admin.bonus-scores.unassignAudition'); Route::delete('/{bonusScore}', 'destroy')->name('admin.bonus-scores.destroy'); Route::get('/judges', 'judges')->name('admin.bonus-scores.judges'); + Route::delete('{bonusScore}/judges/', 'removeJudge')->name('admin.bonus-scores.judges.remove'); + Route::post('{bonusScore}/judges/', 'assignJudge')->name('admin.bonus-scores.judges.assign'); }); diff --git a/tests/Feature/Pages/Setup/BonusScoreJudgesTest.php b/tests/Feature/Pages/Setup/BonusScoreJudgesTest.php new file mode 100644 index 0000000..4108e96 --- /dev/null +++ b/tests/Feature/Pages/Setup/BonusScoreJudgesTest.php @@ -0,0 +1,70 @@ +get(route('admin.bonus-scores.judges')) + ->assertRedirect(route('home')); + + actAsNormal(); + $this->get(route('admin.bonus-scores.judges')) + ->assertRedirect(route('dashboard')) + ->assertSessionHas('error', 'You are not authorized to perform this action'); + + actAsTab(); + $this->get(route('admin.bonus-scores.judges')) + ->assertRedirect(route('dashboard')) + ->assertSessionHas('error', 'You are not authorized to perform this action'); +}); +it('grants access to an administrator', function () { + // Arrange + actAsAdmin(); + // Act & Assert + $this->get(route('admin.bonus-scores.judges')) + ->assertOk() + ->assertViewIs('admin.bonus-scores.judge-assignments'); +}); +it('shows a link to the room judge assignment screen', function () { + // Arrange + actAsAdmin(); + // Act & Assert + $this->get(route('admin.bonus-scores.judges')) + ->assertOk() + ->assertSee(route('admin.rooms.judgingAssignment')); +}); +it('shows a card for each bonus score', function () { + // Arrange + $bonusScores = BonusScoreDefinition::factory()->count(3)->create(); + actAsAdmin(); + // Act & Assert + $response = $this->get(route('admin.bonus-scores.judges')); + $response->assertOk(); + $bonusScores->each(fn ($bonus) => $response->assertElementExists('#bonus-'.$bonus->id.'-card')); +}); +it('can assign a judge to a bonus score', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + $judge = User::factory()->create(); + actAsAdmin(); + // Act & Assert + $this->post(route('admin.bonus-scores.judges.assign', $bonusScore), ['judge' => $judge->id]) + ->assertRedirect(route('admin.bonus-scores.judges')) + ->assertSessionHas('success', 'Judge assigned to bonus score'); + expect($bonusScore->judges()->count())->toBe(1); +}); +it('can assign a judge to a room', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + $judge = User::factory()->create(); + $bonusScore->judges()->attach($judge->id); + actAsAdmin(); + // Act & Assert + $this->delete(route('admin.bonus-scores.judges.remove', $bonusScore), ['judge' => $judge->id]) + ->assertRedirect(route('admin.bonus-scores.judges')) + ->assertSessionHas('success', 'Judge removed from bonus score'); + expect($bonusScore->judges()->count())->toBe(0); +}); -- 2.39.5 From 8d225ed08cc19480d4f175d1a3adeccada3c313d Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 16:31:42 -0500 Subject: [PATCH 08/16] Bonus Score Entry #20 Implement bonus scores EnterBonusScore action is functioning properly. --- app/Actions/Tabulation/EnterBonusScore.php | 75 ++++++++++ app/Models/BonusScore.php | 3 +- app/Models/User.php | 6 +- tests/Feature/Actions/EnterBonusScoreTest.php | 134 ++++++++++++++++++ 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 app/Actions/Tabulation/EnterBonusScore.php create mode 100644 tests/Feature/Actions/EnterBonusScoreTest.php diff --git a/app/Actions/Tabulation/EnterBonusScore.php b/app/Actions/Tabulation/EnterBonusScore.php new file mode 100644 index 0000000..042abe5 --- /dev/null +++ b/app/Actions/Tabulation/EnterBonusScore.php @@ -0,0 +1,75 @@ +basicValidations($judge, $entry); + $this->validateJudgeValidity($judge, $entry, $score); + $entries = $this->getRelatedEntries($entry); + + // Create the score for each related entry + foreach ($entries as $relatedEntry) { + BonusScore::create([ + 'entry_id' => $relatedEntry->id, + 'user_id' => $judge->id, + 'originally_scored_entry' => $entry->id, + 'score' => $score, + ]); + } + } + + protected function getRelatedEntries(Entry $entry): Collection + { + $bonusScore = $entry->audition->bonusScore->first(); + $relatedAuditions = $bonusScore->auditions; + + // Get all entries that have a student_id equal to that of entry and an audition_id in the related auditions + return Entry::where('student_id', $entry->student_id) + ->whereIn('audition_id', $relatedAuditions->pluck('id')) + ->get(); + } + + protected function basicValidations(User $judge, Entry $entry): void + { + if (! $judge->exists) { + throw new ScoreEntryException('Invalid judge provided'); + } + if (! $entry->exists) { + throw new ScoreEntryException('Invalid entry provided'); + } + if ($entry->audition->bonusScore->count() === 0) { + throw new ScoreEntryException('Entry does not have a bonus score'); + } + + } + + protected function validateJudgeValidity(User $judge, Entry $entry, $score): void + { + if (BonusScore::where('entry_id', $entry->id)->where('user_id', $judge->id)->exists()) { + throw new ScoreEntryException('That judge has already scored that entry'); + } + + $bonusScore = $entry->audition->bonusScore->first(); + if (! $bonusScore->judges->contains($judge)) { + throw new ScoreEntryException('That judge is not assigned to judge that bonus score'); + } + if ($score > $bonusScore->max_score) { + throw new ScoreEntryException('That score exceeds the maximum'); + } + } +} diff --git a/app/Models/BonusScore.php b/app/Models/BonusScore.php index 6629a7f..f75fba5 100644 --- a/app/Models/BonusScore.php +++ b/app/Models/BonusScore.php @@ -2,10 +2,9 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class BonusScore extends Model { - use HasFactory; + protected $guarded = []; } diff --git a/app/Models/User.php b/app/Models/User.php index eeb74cb..4c0a9ab 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; -use App\Models\ScoreSheet; class User extends Authenticatable implements MustVerifyEmail { @@ -118,6 +117,11 @@ class User extends Authenticatable implements MustVerifyEmail return $this->rooms(); } + public function bonusJudgingAssignments(): BelongsToMany + { + return $this->belongsToMany(BonusScoreDefinition::class, 'bonus_score_judge_assignment'); + } + public function advancementVotes(): HasMany { return $this->hasMany(JudgeAdvancementVote::class); diff --git a/tests/Feature/Actions/EnterBonusScoreTest.php b/tests/Feature/Actions/EnterBonusScoreTest.php new file mode 100644 index 0000000..f29af82 --- /dev/null +++ b/tests/Feature/Actions/EnterBonusScoreTest.php @@ -0,0 +1,134 @@ +enterBonusScore = App::make(EnterBonusScore::class); +}); + +it('rejects a non existent entry', function () { + $judge = User::factory()->create(); + $entry = Entry::factory()->make(); + $this->enterBonusScore->__invoke($judge, $entry, 42); +})->throws(ScoreEntryException::class, 'Invalid entry provided'); +it('rejects a non existent judge', function () { + $judge = User::factory()->make(); + $entry = Entry::factory()->create(); + $this->enterBonusScore->__invoke($judge, $entry, 42); +})->throws(ScoreEntryException::class, 'Invalid judge provided'); +it('rejects a submission if the entries audition does not have a bonus score', function () { + $judge = User::factory()->create(); + $entry = Entry::factory()->create(); + $this->enterBonusScore->__invoke($judge, $entry, 42); +})->throws(ScoreEntryException::class, 'Entry does not have a bonus score'); +it('rejects a submission if the entry already has a score from the given judge', function () { + // Arrange + $judge = User::factory()->create(); + $bonusScore = BonusScoreDefinition::factory()->create(); + $entry = Entry::factory()->create(); + $entry->audition->bonusScore()->attach($bonusScore->id); + $score = BonusScore::create([ + 'entry_id' => $entry->id, + 'user_id' => $judge->id, + 'originally_scored_entry' => $entry->id, + 'score' => 42, + ]); + // Act & Assert + $this->enterBonusScore->__invoke($judge, $entry, 43); +})->throws(ScoreEntryException::class, 'That judge has already scored that entry'); +it('rejects a submission for a judge not assigned to judge that bonus score', function () { + // Arrange + $judge = User::factory()->create(); + $bonusScore = BonusScoreDefinition::factory()->create(); + $entry = Entry::factory()->create(); + $entry->audition->bonusScore()->attach($bonusScore->id); + // Act & Assert + $this->enterBonusScore->__invoke($judge, $entry, 43); +})->throws(ScoreEntryException::class, 'That judge is not assigned to judge that bonus score'); +it('rejects a submission for a score that exceeds the maximum', function () { + // Arrange + $judge = User::factory()->create(); + $bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 50]); + $bonusScore->judges()->attach($judge); + $entry = Entry::factory()->create(); + $entry->audition->bonusScore()->attach($bonusScore->id); + // Act & Assert + $this->enterBonusScore->__invoke($judge, $entry, 51); +})->throws(ScoreEntryException::class, 'That score exceeds the maximum'); + +it('records a valid bonus score submission on the submitted entry', function () { + // Arrange + $judge = User::factory()->create(); + $bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 100]); + $entry = Entry::factory()->create(); + $entry->audition->bonusScore()->attach($bonusScore->id); + $bonusScore->judges()->attach($judge); + // Act & Assert + $this->enterBonusScore->__invoke($judge, $entry, 42); + expect( + BonusScore::where('entry_id', $entry->id) + ->where('user_id', $judge->id) + ->where('score', 42)->exists()) + ->toBeTrue(); +}); +it('records a valid bonus score on all related entries', function () { + // Arrange + $judge = User::factory()->create(); + $bonusScore = BonusScoreDefinition::factory()->create(['name' => 'Saxophone Improvisation', 'max_score' => 100]); + $bonusScore->judges()->attach($judge); + $jazzAltoAudition = Audition::factory()->create(['name' => 'Jazz Alto Saxophone']); + $jazzTenorAudition = Audition::factory()->create(['name' => 'Jazz Tenor Saxophone']); + $jazzBariAudition = Audition::factory()->create(['name' => 'Jazz Bari Saxophone']); + $bonusScore->auditions()->attach($jazzAltoAudition->id); + $bonusScore->auditions()->attach($jazzTenorAudition->id); + $bonusScore->auditions()->attach($jazzBariAudition->id); + $saxStudent = Student::factory()->create(); + $jazzAltoEntry = Entry::factory()->create([ + 'student_id' => $saxStudent->id, 'audition_id' => $jazzAltoAudition->id, + ]); + $jazzTenorEntry = Entry::factory()->create(['student_id' => $saxStudent->id, + 'audition_id' => $jazzTenorAudition->id, + ]); + $jazzBariEntry = Entry::factory()->create(['student_id' => $saxStudent->id, 'audition_id' => $jazzBariAudition->id, + ]); + Entry::factory()->count(4)->create(['audition_id' => $jazzAltoAudition->id]); + Entry::factory()->count(4)->create(['audition_id' => $jazzTenorAudition->id]); + Entry::factory()->count(4)->create(['audition_id' => $jazzBariAudition->id]); + // Act + $this->enterBonusScore->__invoke($judge, $jazzAltoEntry, 42); + // Assert + expect( + BonusScore::where('entry_id', $jazzAltoEntry->id) + ->where('user_id', $judge->id) + ->where('originally_scored_entry', $jazzAltoEntry->id) + ->where('score', 42)->exists()) + ->toBeTrue() + + ->and(BonusScore::count())->toBe(3) + + ->and( + BonusScore::where('entry_id', $jazzTenorEntry->id) + ->where('user_id', $judge->id) + ->where('originally_scored_entry', $jazzAltoEntry->id) + ->where('score', 42)->exists()) + ->toBeTrue() + + ->and( + BonusScore::where('entry_id', $jazzBariEntry->id) + ->where('user_id', $judge->id) + ->where('originally_scored_entry', $jazzAltoEntry->id) + ->where('score', 42)->exists()) + ->toBeTrue(); + +}); -- 2.39.5 From cd877db36c2ef37730970fe97b5933d864166ff1 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 16:57:16 -0500 Subject: [PATCH 09/16] Bonus Score Entry #20 Implement bonus scores Allow users assigned to judge bonus scores access to the judging screen. --- app/Http/Controllers/Admin/BonusScoreDefinitionController.php | 2 +- app/Models/User.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php index b7f600c..73ad346 100644 --- a/app/Http/Controllers/Admin/BonusScoreDefinitionController.php +++ b/app/Http/Controllers/Admin/BonusScoreDefinitionController.php @@ -84,7 +84,7 @@ class BonusScoreDefinitionController extends Controller public function judges() { $bonusScores = BonusScoreDefinition::all(); - $users = User::all(); + $users = User::orderBy('last_name')->orderBy('first_name')->get(); return view('admin.bonus-scores.judge-assignments', compact('bonusScores', 'users')); } diff --git a/app/Models/User.php b/app/Models/User.php index 4c0a9ab..b1c4697 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -129,7 +129,7 @@ class User extends Authenticatable implements MustVerifyEmail public function isJudge(): bool { - return $this->judgingAssignments()->count() > 0; + return $this->judgingAssignments()->count() > 0 || $this->bonusJudgingAssignments()->count() > 0; } /** -- 2.39.5 From 475ac181c6d9bde03d20c4f304886b89d9214490 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 19:02:32 -0500 Subject: [PATCH 10/16] Bonus Score Entry #20 Implement bonus scores Show bonus score judging assignments on judging screen. --- app/Http/Controllers/JudgingController.php | 7 +++-- resources/views/judging/index.blade.php | 18 ++++++++++- tests/Feature/Pages/JudgingIndexTest.php | 35 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/JudgingController.php b/app/Http/Controllers/JudgingController.php index 292617e..257d611 100644 --- a/app/Http/Controllers/JudgingController.php +++ b/app/Http/Controllers/JudgingController.php @@ -30,10 +30,11 @@ class JudgingController extends Controller public function index() { - $rooms = Auth::user()->judgingAssignments; - $rooms->load('auditions'); + $rooms = Auth::user()->judgingAssignments()->with('auditions')->get(); + $bonusScoresToJudge = Auth::user()->bonusJudgingAssignments()->with('auditions')->get(); - return view('judging.index', compact('rooms')); + //$rooms->load('auditions'); + return view('judging.index', compact('rooms', 'bonusScoresToJudge')); } public function auditionEntryList(Request $request, Audition $audition) diff --git a/resources/views/judging/index.blade.php b/resources/views/judging/index.blade.php index abd34eb..43a655f 100644 --- a/resources/views/judging/index.blade.php +++ b/resources/views/judging/index.blade.php @@ -1,7 +1,8 @@ Judging Dashboard -

      Choose auditon to judge

      +

      Choose + audition to judge

      @foreach($rooms as $room) @@ -16,5 +17,20 @@ @endforeach + @foreach($bonusScoresToJudge as $bonusScore) + + {{ $bonusScore->name }} + + @foreach($bonusScore->auditions as $audition) + + + {{ $audition->name }} + + + @endforeach + + + @endforeach +
      diff --git a/tests/Feature/Pages/JudgingIndexTest.php b/tests/Feature/Pages/JudgingIndexTest.php index 082b1f7..aedc961 100644 --- a/tests/Feature/Pages/JudgingIndexTest.php +++ b/tests/Feature/Pages/JudgingIndexTest.php @@ -1,6 +1,7 @@ assertDontSee($otherRoom->name) ->assertDontSee($otherAudition->name); }); +it('shows bonus scores the user is assigned to judge', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + $judge = User::factory()->create(); + $bonusScore->judges()->attach($judge); + $this->actingAs($judge); + // Act & Assert + $this->get(route('judging.index')) + ->assertSee($bonusScore->name); +}); +it('does not show bonus scores the user is not assigned to judge', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + $otherBonusScore = BonusScoreDefinition::factory()->create(); + $judge = User::factory()->create(); + $bonusScore->judges()->attach($judge); + $this->actingAs($judge); + // Act & Assert + $this->get(route('judging.index')) + ->assertSee($bonusScore->name) + ->assertDontSee($otherBonusScore->name); +}); +it('shows auditions in a bonus score assignment', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + $audition = Audition::factory()->create(); + $bonusScore->auditions()->attach($audition); + $judge = User::factory()->create(); + $bonusScore->judges()->attach($judge); + $this->actingAs($judge); + // Act & Assert + $this->get(route('judging.index')) + ->assertSee($audition->name); +}); -- 2.39.5 From 7e908024d75e899573eeb6578c3023704e804d8a Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 22:32:47 -0500 Subject: [PATCH 11/16] Bonus Score Entry #20 Implement bonus scores Judges entry list screen correctly show scored entries. --- .../Judging/BonusScoreEntryListController.php | 32 +++++++++ .../{ => Judging}/JudgingController.php | 3 +- app/Models/BonusScore.php | 16 +++++ .../judging/bonus_score_entry_list.blade.php | 30 +++++++++ resources/views/judging/index.blade.php | 2 +- routes/judging.php | 9 ++- routes/web.php | 13 ---- .../Pages/JudgingBonusScoreEntryListTest.php | 67 +++++++++++++++++++ 8 files changed, 156 insertions(+), 16 deletions(-) create mode 100644 app/Http/Controllers/Judging/BonusScoreEntryListController.php rename app/Http/Controllers/{ => Judging}/JudgingController.php (98%) create mode 100644 resources/views/judging/bonus_score_entry_list.blade.php create mode 100644 tests/Feature/Pages/JudgingBonusScoreEntryListTest.php diff --git a/app/Http/Controllers/Judging/BonusScoreEntryListController.php b/app/Http/Controllers/Judging/BonusScoreEntryListController.php new file mode 100644 index 0000000..ae5f30c --- /dev/null +++ b/app/Http/Controllers/Judging/BonusScoreEntryListController.php @@ -0,0 +1,32 @@ +bonusScore()->first(); + if (! $bonusScore->judges->contains(auth()->id())) { + return redirect()->route('dashboard')->with('error', 'You are not assigned to judge this bonus score'); + } + $entries = $audition->entries()->orderBy('draw_number')->get(); + $entries = $entries->reject(fn ($entry) => $entry->hasFlag('no_show')); + $entries->each(fn ($entry) => $entry->audition = $audition); + + $scores = BonusScore::where('user_id', Auth::user()->id) + ->with('entry.audition') + ->with('originallyScoredEntry.audition') + ->get() + ->keyBy('entry_id'); + + return view('judging.bonus_score_entry_list', compact('audition', 'entries', 'scores')); + } +} diff --git a/app/Http/Controllers/JudgingController.php b/app/Http/Controllers/Judging/JudgingController.php similarity index 98% rename from app/Http/Controllers/JudgingController.php rename to app/Http/Controllers/Judging/JudgingController.php index 257d611..84f6fab 100644 --- a/app/Http/Controllers/JudgingController.php +++ b/app/Http/Controllers/Judging/JudgingController.php @@ -1,10 +1,11 @@ belongsTo(Entry::class); + } + + public function judge(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function originallyScoredEntry(): BelongsTo + { + return $this->belongsTo(Entry::class, 'originally_scored_entry'); + } } diff --git a/resources/views/judging/bonus_score_entry_list.blade.php b/resources/views/judging/bonus_score_entry_list.blade.php new file mode 100644 index 0000000..c5bed92 --- /dev/null +++ b/resources/views/judging/bonus_score_entry_list.blade.php @@ -0,0 +1,30 @@ +@php use Carbon\Carbon; @endphp + + Judging Dashboard - Bonus Scores + + {{ $audition->name }} - Bonus Score + + + + Entry + Score + Scored On + Score Timestamp + + + + @foreach($entries as $entry) + + {{ $audition->name }} {{ $entry->draw_number }} + @if($scores->has($entry->id)) + {{ $scores[$entry->id]->score }} + {{ $scores[$entry->id]->originallyScoredEntry->audition->name }} + {{ Carbon::create($scores[$entry->id]->created_at)->setTimezone('America/Chicago')->format('m/d/y H:i') }} + + @endif + + @endforeach + + + + diff --git a/resources/views/judging/index.blade.php b/resources/views/judging/index.blade.php index 43a655f..5d371f4 100644 --- a/resources/views/judging/index.blade.php +++ b/resources/views/judging/index.blade.php @@ -22,7 +22,7 @@ {{ $bonusScore->name }} @foreach($bonusScore->auditions as $audition) - + {{ $audition->name }} diff --git a/routes/judging.php b/routes/judging.php index 978434f..6dae2e7 100644 --- a/routes/judging.php +++ b/routes/judging.php @@ -1,6 +1,8 @@ prefix('judging Route::post('/entry/{entry}', 'saveScoreSheet')->name('judging.saveScoreSheet'); Route::patch('/entry/{entry}', 'updateScoreSheet')->name('judging.updateScoreSheet'); }); + +// Bonus score judging routes +Route::middleware(['auth', 'verified', CheckIfCanJudge::class])->prefix('judging/bonus_scores')->group(function () { + Route::get('/{audition}', BonusScoreEntryListController::class)->name('judging.bonusScore.EntryList'); +}); diff --git a/routes/web.php b/routes/web.php index c63f5b9..a0ad9ec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,19 +1,7 @@ middleware('auth', 'verified'); - Route::view('/', 'welcome')->middleware('guest')->name('home'); Route::get('/results', [App\Http\Controllers\ResultsPage::class, '__invoke'])->name('results'); diff --git a/tests/Feature/Pages/JudgingBonusScoreEntryListTest.php b/tests/Feature/Pages/JudgingBonusScoreEntryListTest.php new file mode 100644 index 0000000..9cb598d --- /dev/null +++ b/tests/Feature/Pages/JudgingBonusScoreEntryListTest.php @@ -0,0 +1,67 @@ +get(route('judging.bonusScore.EntryList', 1)); + $response->assertRedirect(route('home')); +}); +it('denies access to a user not assigned to judge this bonus score', function () { + $bonusScore = BonusScoreDefinition::factory()->create(); + $audition = Audition::factory()->create(); + $audition->bonusScore()->attach($bonusScore->id); + $room = Room::factory()->create(); + $user = User::factory()->create(); + $room->addJudge($user->id); + actingAs($user); + $this->get(route('judging.bonusScore.EntryList', $audition->id)) + ->assertRedirect(route('dashboard')) + ->assertSessionHas('error', 'You are not assigned to judge this bonus score'); +}); +it('shows all entries to an authorized judge', function () { + // Arrange + $bonusScore = BonusScoreDefinition::factory()->create(); + $audition = Audition::factory()->create(); + $audition->bonusScore()->attach($bonusScore->id); + $entries[1] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 1]); + $entries[2] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 2]); + $entries[3] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 3]); + $entries[4] = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 4]); + $entries = collect($entries); + $judge = User::factory()->create(); + $bonusScore->judges()->attach($judge->id); + actingAs($judge); + // Act & Assert + $response = $this->get(route('judging.bonusScore.EntryList', $audition->id)); + $response->assertOk(); + $entries->each(fn ($entry) => $response->assertSee($entry->audition->name.' '.$entry->draw_number)); +}); +it('shows existing scores for an entry', function () { + $bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 100]); + $audition = Audition::factory()->create(); + $audition->bonusScore()->attach($bonusScore->id); + $entry = Entry::factory()->create(['audition_id' => $audition->id, 'draw_number' => 1]); + $judge = User::factory()->create(); + $bonusScore->judges()->attach($judge->id); + BonusScore::create([ + 'entry_id' => $entry->id, + 'user_id' => $judge->id, + 'originally_scored_entry' => $entry->id, + 'score' => 42, + ]); + actingAs($judge); + // Act + $response = $this->get(route('judging.bonusScore.EntryList', $audition)); + $response->assertOk() + ->assertSeeInOrder(['', e($audition->name), $entry->draw_number, 42, ''], false); +}); -- 2.39.5 From 579a0a2fc37b2628df109b17c68725500b400c37 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Mon, 15 Jul 2024 23:32:32 -0500 Subject: [PATCH 12/16] Bonus Score Entry #20 Implement bonus scores Bonus score entry by judges is complete. --- .../Judging/BonusScoreEntryController.php | 31 +++++++++ .../Judging/BonusScoreRecordController.php | 28 ++++++++ .../views/components/form/field.blade.php | 5 +- .../judging/bonus_entry_score_sheet.blade.php | 16 +++++ .../judging/bonus_score_entry_list.blade.php | 9 ++- routes/judging.php | 6 +- .../Pages/JudgingBonusScoreEntryTest.php | 68 +++++++++++++++++++ 7 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Judging/BonusScoreEntryController.php create mode 100644 app/Http/Controllers/Judging/BonusScoreRecordController.php create mode 100644 resources/views/judging/bonus_entry_score_sheet.blade.php create mode 100644 tests/Feature/Pages/JudgingBonusScoreEntryTest.php diff --git a/app/Http/Controllers/Judging/BonusScoreEntryController.php b/app/Http/Controllers/Judging/BonusScoreEntryController.php new file mode 100644 index 0000000..447d160 --- /dev/null +++ b/app/Http/Controllers/Judging/BonusScoreEntryController.php @@ -0,0 +1,31 @@ +id)->where('user_id', Auth::user()->id)->exists()) { + return redirect()->route('judging.bonusScore.EntryList', $entry->audition)->with('error', 'You have already judged that entry'); + } + /** @var BonusScoreDefinition $bonusScore */ + $bonusScore = $entry->audition->bonusScore()->first(); + if (! $bonusScore->judges->contains(auth()->id())) { + return redirect()->route('judging.index')->with('error', 'You are not assigned to judge this entry'); + } + $maxScore = $bonusScore->max_score; + $bonusName = $bonusScore->name; + + return view('judging.bonus_entry_score_sheet', compact('entry', 'maxScore', 'bonusName')); + + } +} diff --git a/app/Http/Controllers/Judging/BonusScoreRecordController.php b/app/Http/Controllers/Judging/BonusScoreRecordController.php new file mode 100644 index 0000000..911fe3d --- /dev/null +++ b/app/Http/Controllers/Judging/BonusScoreRecordController.php @@ -0,0 +1,28 @@ +validate([ + 'score' => 'required|integer', + ]); + try { + $enterBonusScore(Auth::user(), $entry, $validData['score']); + } catch (ScoreEntryException $ex) { + return redirect()->back()->with('error', 'Score Entry Error - '.$ex->getMessage()); + } + + return redirect()->route('judging.bonusScore.EntryList', $entry->audition)->with('Score Recorded Successfully'); + } +} diff --git a/resources/views/components/form/field.blade.php b/resources/views/components/form/field.blade.php index 458bfbc..f1b2622 100644 --- a/resources/views/components/form/field.blade.php +++ b/resources/views/components/form/field.blade.php @@ -3,13 +3,14 @@ 'type' => 'text', 'label' => false, 'colspan' => '1', - 'label_text' => false + 'label_text' => false, + 'id'=>null ]) @php $label_classes = "block text-sm font-medium leading-6 text-gray-900"; $inputClasses = "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"; $inputAttributes = [ - 'id' => $name, + 'id' => $id ?? $name, 'name' => $name, 'type' => $type, 'class' => $inputClasses, diff --git a/resources/views/judging/bonus_entry_score_sheet.blade.php b/resources/views/judging/bonus_entry_score_sheet.blade.php new file mode 100644 index 0000000..b065773 --- /dev/null +++ b/resources/views/judging/bonus_entry_score_sheet.blade.php @@ -0,0 +1,16 @@ + + Enter {{ $bonusName }} Score + + {{ $entry->audition->name }} {{$entry->draw_number}} + + + Enter {{ $bonusName }} Score + + + diff --git a/resources/views/judging/bonus_score_entry_list.blade.php b/resources/views/judging/bonus_score_entry_list.blade.php index c5bed92..f70fb3f 100644 --- a/resources/views/judging/bonus_score_entry_list.blade.php +++ b/resources/views/judging/bonus_score_entry_list.blade.php @@ -15,11 +15,18 @@ @foreach($entries as $entry) - {{ $audition->name }} {{ $entry->draw_number }} @if($scores->has($entry->id)) + {{ $audition->name }} {{ $entry->draw_number }} {{ $scores[$entry->id]->score }} {{ $scores[$entry->id]->originallyScoredEntry->audition->name }} {{ Carbon::create($scores[$entry->id]->created_at)->setTimezone('America/Chicago')->format('m/d/y H:i') }} + @else + + + + {{ $audition->name }} {{ $entry->draw_number }} + + @endif diff --git a/routes/judging.php b/routes/judging.php index 6dae2e7..f5b59ab 100644 --- a/routes/judging.php +++ b/routes/judging.php @@ -1,7 +1,9 @@ prefix('judging // Bonus score judging routes Route::middleware(['auth', 'verified', CheckIfCanJudge::class])->prefix('judging/bonus_scores')->group(function () { - Route::get('/{audition}', BonusScoreEntryListController::class)->name('judging.bonusScore.EntryList'); + Route::get('/{audition}', BonusScoreEntryListController::class)->name('judging.bonusScore.EntryList'); // List of entries in an audition + Route::get('/entries/{entry}', BonusScoreEntryController::class)->name('judging.bonusScore.entry'); // Form to enter an entries score + Route::post('/entries/{entry}', BonusScoreRecordController::class)->name('judging.bonusScores.recordScore'); // Record the score }); diff --git a/tests/Feature/Pages/JudgingBonusScoreEntryTest.php b/tests/Feature/Pages/JudgingBonusScoreEntryTest.php new file mode 100644 index 0000000..410514f --- /dev/null +++ b/tests/Feature/Pages/JudgingBonusScoreEntryTest.php @@ -0,0 +1,68 @@ +get(route('judging.bonusScore.entry', 1)); + $response->assertRedirect(route('home')); +}); +it('denies access to a user not assigned to judge this bonus score', function () { + $bonusScore = BonusScoreDefinition::factory()->create(); + $audition = Audition::factory()->create(); + $audition->bonusScore()->attach($bonusScore->id); + $entry = Entry::factory()->create(['audition_id' => $audition->id]); + $room = Room::factory()->create(); + $user = User::factory()->create(); + $room->addJudge($user->id); + actingAs($user); + $this->get(route('judging.bonusScore.entry', $entry->id)) + ->assertRedirect(route('judging.index')) + ->assertSessionHas('error', 'You are not assigned to judge this entry'); +}); +it('denies access if a score already exists for the entry by the user', function () { + $entry = Entry::factory()->create(); + $judge = User::factory()->create(); + $bonusScoreDefinition = BonusScoreDefinition::factory()->create(); + $bonusScoreDefinition->judges()->attach($judge->id); + BonusScore::create([ + 'entry_id' => $entry->id, + 'user_id' => $judge->id, + 'originally_scored_entry' => $entry->id, + 'score' => 42, + ]); + actingAs($judge); + $this->get(route('judging.bonusScore.entry', $entry)) + ->assertRedirect(route('judging.bonusScore.EntryList', $entry->audition)) + ->assertSessionHas('error', 'You have already judged that entry'); +}); +it('has a proper score entry form for a valid request', function () { + // Arrange + $audition = Audition::factory()->create(); + $bonusScore = BonusScoreDefinition::factory()->create(['max_score' => 100]); + $bonusScore->auditions()->attach($audition->id); + $entry = Entry::factory()->create(['audition_id' => $audition->id]); + $judge = User::factory()->create(); + $bonusScore->judges()->attach($judge->id); + actingAs($judge); + // Act & Assert + $request = $this->get(route('judging.bonusScore.entry', $entry)); + $request->assertOk() + ->assertFormExists('#score-entry-for-'.$entry->id, function (AssertForm $form) use ($entry) { + $form->hasCSRF() + ->hasMethod('POST') + ->hasAction(route('judging.bonusScores.recordScore', $entry)) + ->containsInput(['name' => 'score']) + ->containsButton(['type' => 'submit']); + }); +}); -- 2.39.5 From c04dd02405c44fea3ab5b1756789bb51d6712088 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Tue, 16 Jul 2024 00:07:46 -0500 Subject: [PATCH 13/16] Bonus Score Use #20 Implement bonus scores Bonus score is now included in total scores for seating. --- .../Tabulation/AllowForOlympicScoring.php | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/app/Actions/Tabulation/AllowForOlympicScoring.php b/app/Actions/Tabulation/AllowForOlympicScoring.php index a5346cf..a4fda12 100644 --- a/app/Actions/Tabulation/AllowForOlympicScoring.php +++ b/app/Actions/Tabulation/AllowForOlympicScoring.php @@ -5,20 +5,27 @@ namespace App\Actions\Tabulation; use App\Exceptions\TabulationException; +use App\Models\BonusScore; use App\Models\Entry; use App\Services\AuditionService; use App\Services\EntryService; use Illuminate\Support\Facades\Cache; + use function auditionSetting; class AllowForOlympicScoring implements CalculateEntryScore { protected CalculateScoreSheetTotal $calculator; + protected AuditionService $auditionService; + protected EntryService $entryService; - public function __construct(CalculateScoreSheetTotal $calculator, AuditionService $auditionService, EntryService $entryService) - { + public function __construct( + CalculateScoreSheetTotal $calculator, + AuditionService $auditionService, + EntryService $entryService + ) { $this->calculator = $calculator; $this->auditionService = $auditionService; $this->entryService = $entryService; @@ -28,6 +35,7 @@ class AllowForOlympicScoring implements CalculateEntryScore { $cacheKey = 'entryScore-'.$entry->id.'-'.$mode; + return Cache::remember($cacheKey, 10, function () use ($mode, $entry) { $this->basicValidation($mode, $entry); $this->areAllJudgesIn($entry); @@ -38,7 +46,7 @@ class AllowForOlympicScoring implements CalculateEntryScore } - protected function getJudgeTotals($mode, Entry $entry) + protected function getJudgeTotals($mode, Entry $entry): array { $scores = []; @@ -55,7 +63,7 @@ class AllowForOlympicScoring implements CalculateEntryScore // remove the highest and lowest scores array_pop($scores); array_shift($scores); - } + } $sums = []; // Sum each subscore from the judges foreach ($scores as $score) { @@ -66,9 +74,33 @@ class AllowForOlympicScoring implements CalculateEntryScore $index++; } } + // add the bonus points for a seating mode + if ($mode === 'seating') { + $sums[0] += $this->getBonusPoints($entry); + } + return $sums; } + protected function getBonusPoints(Entry $entry) + { + + $bonusScoreDefinition = $entry->audition->bonusScore()->first(); + if (! $bonusScoreDefinition) { + return 0; + } + $bonusJudges = $bonusScoreDefinition->judges; + $bonusScoreSheets = BonusScore::where('entry_id', $entry->id)->get(); + foreach ($bonusScoreSheets as $sheet) { + if (! $bonusJudges->contains($sheet->user_id)) { + throw new TabulationException('Entry has a bonus score from unassigned judge'); + } + } + + // sum the score property of the $bonusScoreSheets + return $bonusScoreSheets->sum('score'); + } + protected function basicValidation($mode, $entry): void { if ($mode !== 'seating' && $mode !== 'advancement') { -- 2.39.5 From d1cab8262288939ca2ca72dbe5e3f7944c907e81 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Tue, 16 Jul 2024 00:09:10 -0500 Subject: [PATCH 14/16] Bonus Score Use #20 Implement bonus scores Bonus score is now included in total scores for seating. --- app/Actions/Tabulation/AllowForOlympicScoring.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Actions/Tabulation/AllowForOlympicScoring.php b/app/Actions/Tabulation/AllowForOlympicScoring.php index a4fda12..f60b3af 100644 --- a/app/Actions/Tabulation/AllowForOlympicScoring.php +++ b/app/Actions/Tabulation/AllowForOlympicScoring.php @@ -89,6 +89,7 @@ class AllowForOlympicScoring implements CalculateEntryScore if (! $bonusScoreDefinition) { return 0; } + /** @noinspection PhpPossiblePolymorphicInvocationInspection */ $bonusJudges = $bonusScoreDefinition->judges; $bonusScoreSheets = BonusScore::where('entry_id', $entry->id)->get(); foreach ($bonusScoreSheets as $sheet) { -- 2.39.5 From 83eb11e1515e5bf270f73da0fc84fda013bf8b1e Mon Sep 17 00:00:00 2001 From: Matt Young Date: Tue, 16 Jul 2024 02:15:13 -0500 Subject: [PATCH 15/16] Bonus Score Admin Entry #20 Implement bonus scores Entry form for judges designed. --- .../Tabulation/AllowForOlympicScoring.php | 3 +- app/Actions/Tabulation/EnterBonusScore.php | 4 +- .../GetBonusScoreRelatedEntries.php | 29 ++++++++ .../Tabulation/BonusScoreController.php | 54 +++++++++++++++ .../Tabulation/EntryFlagController.php | 3 +- .../Tabulation/ScoreController.php | 3 +- app/Models/Entry.php | 7 +- .../layout/navbar/menus/tabulation.blade.php | 1 + .../views/components/table/table.blade.php | 2 +- .../tabulation/bonus-score-sheet.blade.php | 69 +++++++++++++++++++ .../views/tabulation/choose_entry.blade.php | 5 +- routes/tabulation.php | 10 ++- 12 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 app/Actions/Tabulation/GetBonusScoreRelatedEntries.php create mode 100644 app/Http/Controllers/Tabulation/BonusScoreController.php create mode 100644 resources/views/tabulation/bonus-score-sheet.blade.php diff --git a/app/Actions/Tabulation/AllowForOlympicScoring.php b/app/Actions/Tabulation/AllowForOlympicScoring.php index f60b3af..82bfecc 100644 --- a/app/Actions/Tabulation/AllowForOlympicScoring.php +++ b/app/Actions/Tabulation/AllowForOlympicScoring.php @@ -75,7 +75,8 @@ class AllowForOlympicScoring implements CalculateEntryScore } } // add the bonus points for a seating mode - if ($mode === 'seating') { + if ($mode === 'seating' && $sums) { + $sums[0] += $this->getBonusPoints($entry); } diff --git a/app/Actions/Tabulation/EnterBonusScore.php b/app/Actions/Tabulation/EnterBonusScore.php index 042abe5..67f8570 100644 --- a/app/Actions/Tabulation/EnterBonusScore.php +++ b/app/Actions/Tabulation/EnterBonusScore.php @@ -9,6 +9,7 @@ use App\Models\BonusScore; use App\Models\Entry; use App\Models\User; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\App; class EnterBonusScore { @@ -18,9 +19,10 @@ class EnterBonusScore public function __invoke(User $judge, Entry $entry, int $score): void { + $getRelatedEntries = App::make(GetBonusScoreRelatedEntries::class); $this->basicValidations($judge, $entry); $this->validateJudgeValidity($judge, $entry, $score); - $entries = $this->getRelatedEntries($entry); + $entries = $getRelatedEntries($entry); // Create the score for each related entry foreach ($entries as $relatedEntry) { diff --git a/app/Actions/Tabulation/GetBonusScoreRelatedEntries.php b/app/Actions/Tabulation/GetBonusScoreRelatedEntries.php new file mode 100644 index 0000000..93fbbb1 --- /dev/null +++ b/app/Actions/Tabulation/GetBonusScoreRelatedEntries.php @@ -0,0 +1,29 @@ +getRelatedEntries($entry); + } + + public function getRelatedEntries(Entry $entry): Collection + { + $bonusScore = $entry->audition->bonusScore->first(); + $relatedAuditions = $bonusScore->auditions; + + // Get all entries that have a student_id equal to that of entry and an audition_id in the related auditions + return Entry::where('student_id', $entry->student_id) + ->whereIn('audition_id', $relatedAuditions->pluck('id')) + ->get(); + } +} diff --git a/app/Http/Controllers/Tabulation/BonusScoreController.php b/app/Http/Controllers/Tabulation/BonusScoreController.php new file mode 100644 index 0000000..c1191c6 --- /dev/null +++ b/app/Http/Controllers/Tabulation/BonusScoreController.php @@ -0,0 +1,54 @@ +validate([ + 'entry_id' => 'required|exists:entries,id', + ]); + $entry = Entry::find($validData['entry_id']); + $bonusScoreDefinition = $entry->audition->bonusScore->first(); + $assignedJudges = $bonusScoreDefinition->judges; + $relatedEntries = $getRelatedEntries($entry); + $existingScores = []; + foreach ($relatedEntries as $related) { + $existingScores[$related->id] = BonusScore::where('entry_id', $related->id) + ->with('judge') + ->with('entry') + ->with('originallyScoredEntry') + ->get(); + } + + return view('tabulation.bonus-score-sheet', + compact('entry', 'bonusScoreDefinition', 'assignedJudges', 'existingScores', 'relatedEntries')); + } + + public function saveEntryBonusScoreSheet() + { + + } + + public function destroyBonusScore() + { + + } +} diff --git a/app/Http/Controllers/Tabulation/EntryFlagController.php b/app/Http/Controllers/Tabulation/EntryFlagController.php index 74b582c..bd0aed0 100644 --- a/app/Http/Controllers/Tabulation/EntryFlagController.php +++ b/app/Http/Controllers/Tabulation/EntryFlagController.php @@ -15,8 +15,9 @@ class EntryFlagController extends Controller { $method = 'GET'; $formRoute = 'entry-flags.confirmNoShow'; + $title = 'No Show'; - return view('tabulation.choose_entry', compact('method', 'formRoute')); + return view('tabulation.choose_entry', compact('method', 'formRoute', 'title')); } public function noShowConfirm(Request $request) diff --git a/app/Http/Controllers/Tabulation/ScoreController.php b/app/Http/Controllers/Tabulation/ScoreController.php index 35b1fda..4620418 100644 --- a/app/Http/Controllers/Tabulation/ScoreController.php +++ b/app/Http/Controllers/Tabulation/ScoreController.php @@ -14,8 +14,9 @@ class ScoreController extends Controller { $method = 'GET'; $formRoute = 'scores.entryScoreSheet'; + $title = 'Enter Scores'; - return view('tabulation.choose_entry', compact('method', 'formRoute')); + return view('tabulation.choose_entry', compact('method', 'formRoute', 'title')); } public function destroyScore(ScoreSheet $score) diff --git a/app/Models/Entry.php b/app/Models/Entry.php index bd3ad71..1b6eaee 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -7,10 +7,10 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOneThrough; -use Illuminate\Support\Facades\Cache; class Entry extends Model { @@ -55,6 +55,11 @@ class Entry extends Model } + public function bonusScores(): BelongsToMany + { + return $this->belongsToMany(BonusScore::class); + } + public function advancementVotes(): HasMany { return $this->hasMany(JudgeAdvancementVote::class); diff --git a/resources/views/components/layout/navbar/menus/tabulation.blade.php b/resources/views/components/layout/navbar/menus/tabulation.blade.php index 6a68318..a2e61cd 100644 --- a/resources/views/components/layout/navbar/menus/tabulation.blade.php +++ b/resources/views/components/layout/navbar/menus/tabulation.blade.php @@ -21,6 +21,7 @@
      Enter Scores + Enter Bonus Scores Enter No-Shows Audition Status {{ auditionSetting('advanceTo') }} Status diff --git a/resources/views/components/table/table.blade.php b/resources/views/components/table/table.blade.php index b66d47e..4acc656 100644 --- a/resources/views/components/table/table.blade.php +++ b/resources/views/components/table/table.blade.php @@ -11,7 +11,7 @@
      @if($with_title_area) -
      +
      @if($title)

      attributes->merge(['class' => 'text-base font-semibold leading-6 text-gray-900']) }}>{{ $title }}

      @endif @if($subtitle)

      attributes->merge(['class' => 'mt-2 text-sm text-gray-700']) }}>{{ $subtitle }}

      @endif diff --git a/resources/views/tabulation/bonus-score-sheet.blade.php b/resources/views/tabulation/bonus-score-sheet.blade.php new file mode 100644 index 0000000..cdf63aa --- /dev/null +++ b/resources/views/tabulation/bonus-score-sheet.blade.php @@ -0,0 +1,69 @@ +@php use Illuminate\Support\Carbon; @endphp + + Enter {{ $bonusScoreDefinition->name }} Score + + + {{ $entry->student->full_name() }} + {{ $entry->student->school->name }} + + +
      + + + Existing Scores +
      + @foreach($relatedEntries as $related) + + + {{ $related->audition->name }} #{{ $related->draw_number }} + + + Entry ID: {{ $related->id }} + + + + Judge + Audition Scored + Score + Timestamp + + + + @foreach($existingScores[$related->id] as $score) + + {{ $score->judge->full_name() }} + {{ $score->originallyScoredEntry->audition->name }} + {{ $score->score }} + {{ Carbon::create($score->created_at)->setTimezone('America/Chicago')->format('m/d/y H:i') }} + + @endforeach + + + @endforeach +
      +
      + + + Enter Score +
      + NOTE: Entering score will delete any existing scores for that entry by that judge +
      + + + Judge + @foreach($assignedJudges as $judge) + + @endforeach + + + Scored Audition + @foreach($relatedEntries as $related) + + @endforeach + + + Enter Score + +
      +
      +
      diff --git a/resources/views/tabulation/choose_entry.blade.php b/resources/views/tabulation/choose_entry.blade.php index 2f4c01a..2d08677 100644 --- a/resources/views/tabulation/choose_entry.blade.php +++ b/resources/views/tabulation/choose_entry.blade.php @@ -2,13 +2,14 @@ /** * @var string $method Method for the select form * @var string $formRoute Route for the form action. Should be a route name + * @var string $title Title of the page */ @endphp - Choose Entry + {{ $title }} - Choose Entry + {{ $title }} - Choose Entry
      diff --git a/routes/tabulation.php b/routes/tabulation.php index 99f9d98..2a8fe42 100644 --- a/routes/tabulation.php +++ b/routes/tabulation.php @@ -2,13 +2,13 @@ // Tabulation Routes use App\Http\Controllers\Tabulation\AdvancementController; +use App\Http\Controllers\Tabulation\BonusScoreController; use App\Http\Controllers\Tabulation\DoublerDecisionController; use App\Http\Controllers\Tabulation\EntryFlagController; use App\Http\Controllers\Tabulation\ScoreController; use App\Http\Controllers\Tabulation\SeatAuditionFormController; use App\Http\Controllers\Tabulation\SeatingPublicationController; use App\Http\Controllers\Tabulation\SeatingStatusController; -use App\Http\Controllers\Tabulation\TabulationController; use App\Http\Middleware\CheckIfCanTab; use Illuminate\Support\Facades\Route; @@ -22,6 +22,14 @@ Route::middleware(['auth', 'verified', CheckIfCanTab::class])->group(function () Route::delete('/{score}', 'destroyScore')->name('scores.destroy'); }); + // Bonus Score Management + Route::prefix('bonus-scores/')->controller(BonusScoreController::class)->group(function () { + Route::get('/choose_entry', 'chooseEntry')->name('bonus-scores.chooseEntry'); + Route::get('/entry', 'entryBonusScoreSheet')->name('bonus-scores.entryBonusScoreSheet'); + Route::post('/entry/{entry}', 'saveEntryBonusScoreSheet')->name('bonus-scores.saveEntryBonusScoreSheet'); + Route::delete('/{bonusScore}', 'destroyBonusScore')->name('bonus-scores.destroy'); + }); + // Entry Flagging Route::prefix('entry-flags/')->controller(EntryFlagController::class)->group(function () { Route::get('/choose_no_show', 'noShowSelect')->name('entry-flags.noShowSelect'); -- 2.39.5 From f1d3ba349cc91df4a6b6ee9e35f6c4e653e171d3 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Tue, 16 Jul 2024 03:01:11 -0500 Subject: [PATCH 16/16] Bonus Score Admin Entry #20 Implement bonus scores Admin bonus score entry complete --- app/Actions/Tabulation/EnterBonusScore.php | 2 + .../Tabulation/BonusScoreController.php | 43 ++++++++++++++++++- .../tabulation/bonus-score-sheet.blade.php | 14 +++--- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/app/Actions/Tabulation/EnterBonusScore.php b/app/Actions/Tabulation/EnterBonusScore.php index 67f8570..2706f34 100644 --- a/app/Actions/Tabulation/EnterBonusScore.php +++ b/app/Actions/Tabulation/EnterBonusScore.php @@ -19,6 +19,7 @@ class EnterBonusScore public function __invoke(User $judge, Entry $entry, int $score): void { + $getRelatedEntries = App::make(GetBonusScoreRelatedEntries::class); $this->basicValidations($judge, $entry); $this->validateJudgeValidity($judge, $entry, $score); @@ -33,6 +34,7 @@ class EnterBonusScore 'score' => $score, ]); } + } protected function getRelatedEntries(Entry $entry): Collection diff --git a/app/Http/Controllers/Tabulation/BonusScoreController.php b/app/Http/Controllers/Tabulation/BonusScoreController.php index c1191c6..57cfbaa 100644 --- a/app/Http/Controllers/Tabulation/BonusScoreController.php +++ b/app/Http/Controllers/Tabulation/BonusScoreController.php @@ -2,10 +2,14 @@ namespace App\Http\Controllers\Tabulation; +use App\Actions\Tabulation\EnterBonusScore; use App\Actions\Tabulation\GetBonusScoreRelatedEntries; +use App\Exceptions\ScoreEntryException; use App\Http\Controllers\Controller; use App\Models\BonusScore; use App\Models\Entry; +use App\Models\User; +use Illuminate\Support\Facades\DB; use function request; @@ -42,9 +46,46 @@ class BonusScoreController extends Controller compact('entry', 'bonusScoreDefinition', 'assignedJudges', 'existingScores', 'relatedEntries')); } - public function saveEntryBonusScoreSheet() + public function saveEntryBonusScoreSheet(Entry $entry, GetBonusScoreRelatedEntries $getRelatedEntries, EnterBonusScore $saveBonusScore) { + $validData = request()->validate([ + 'judge_id' => 'required|exists:users,id', + 'entry_id' => 'required|exists:entries,id', + 'score' => 'nullable|numeric', + ]); + $judge = User::find($validData['judge_id']); + $entry = Entry::find($validData['entry_id']); + $relatedEntries = $getRelatedEntries($entry); + try { + DB::beginTransaction(); + + // Delete existing bonus scores for the entries by the judge + foreach ($relatedEntries as $related) { + BonusScore::where('entry_id', $related->id)->where('user_id', $judge->id)->delete(); + } + + // If no score was submitted, were going to just stop at deleting the scores + if (! $validData['score'] == null) { + // Set the new score + try { + $saveBonusScore($judge, $entry, $validData['score']); + } catch (ScoreEntryException $ex) { + DB::rollBack(); + + return redirect()->route('bonus-scores.entryBonusScoreSheet', + ['entry_id' => $entry->id])->with('error', 'Error entering score - '.$ex->getMessage()); + } + } + + DB::commit(); + } catch (\Exception) { + DB::rollBack(); + + return redirect()->route('bonus-scores.entryBonusScoreSheet', ['entry_id' => $entry->id])->with('error', 'Error entering score - '.$ex->getMessage()); + } + + return redirect()->route('bonus-scores.entryBonusScoreSheet', ['entry_id' => $entry->id])->with('success', 'New bonus score entered'); } public function destroyBonusScore() diff --git a/resources/views/tabulation/bonus-score-sheet.blade.php b/resources/views/tabulation/bonus-score-sheet.blade.php index cdf63aa..1d49969 100644 --- a/resources/views/tabulation/bonus-score-sheet.blade.php +++ b/resources/views/tabulation/bonus-score-sheet.blade.php @@ -46,23 +46,25 @@ Enter Score
      - NOTE: Entering score will delete any existing scores for that entry by that judge +

      NOTE: Entering score will delete any existing scores for that entry by that judge

      +

      Submitting the form with no score value will delete scores by that judge

      - - + + Judge + @foreach($assignedJudges as $judge) @endforeach - + Scored Audition @foreach($relatedEntries as $related) @endforeach - - Enter Score + + Enter Score
      -- 2.39.5