diff --git a/app/Http/Controllers/Judging/JudgingController.php b/app/Http/Controllers/Judging/JudgingController.php index 7318d16..2b2b506 100644 --- a/app/Http/Controllers/Judging/JudgingController.php +++ b/app/Http/Controllers/Judging/JudgingController.php @@ -3,22 +3,18 @@ namespace App\Http\Controllers\Judging; use App\Actions\Tabulation\EnterScore; -use App\Exceptions\AuditionServiceException; -use App\Exceptions\ScoreEntryException; use App\Http\Controllers\Controller; use App\Models\Audition; use App\Models\Entry; use App\Models\JudgeAdvancementVote; use App\Models\ScoreSheet; use App\Services\AuditionService; -use Exception; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; use function compact; use function redirect; -use function url; class JudgingController extends Controller { @@ -40,8 +36,9 @@ class JudgingController extends Controller public function auditionEntryList(Request $request, Audition $audition) { + // TODO: Add error message if scoring guide is not set if ($request->user()->cannot('judge', $audition)) { - return redirect()->route('judging.index')->with('error', 'You are not assigned to judge this audition'); + return redirect()->route('judging.index')->with('error', 'You are not assigned to judge that audition'); } $entries = Entry::where('audition_id', '=', $audition->id)->orderBy('draw_number')->with('audition')->get(); $subscores = $audition->scoringGuide->subscores()->orderBy('display_order')->get(); @@ -68,6 +65,13 @@ class JudgingController extends Controller return redirect()->route('judging.auditionEntryList', $entry->audition)->with('error', 'The requested entry is marked as a no-show. Scores cannot be entered.'); } + + // Turn away users if the entry is flagged as a failed-prelim + if ($entry->hasFlag('failed_prelim')) { + return redirect()->route('judging.auditionEntryList', $entry->audition)->with('error', + 'The requested entry is marked as having failed a prelim. Scores cannot be entered.'); + } + $oldSheet = ScoreSheet::where('user_id', Auth::id())->where('entry_id', $entry->id)->value('subscores') ?? null; $oldVote = JudgeAdvancementVote::where('user_id', Auth::id())->where('entry_id', $entry->id)->first(); $oldVote = $oldVote ? $oldVote->vote : 'noVote'; @@ -78,15 +82,11 @@ class JudgingController extends Controller public function saveScoreSheet(Request $request, Entry $entry, EnterScore $enterScore) { if ($request->user()->cannot('judge', $entry->audition)) { - abort(403, 'You are not assigned to judge this entry'); + return redirect()->route('judging.index')->with('error', 'You are not assigned to judge this entry'); } // Validate form data - try { - $subscores = $this->auditionService->getSubscores($entry->audition, 'all'); - } catch (AuditionServiceException $e) { - return redirect()->back()->with('error', 'Unable to get subscores - '.$e->getMessage()); - } + $subscores = $entry->audition->subscoreDefinitions; $validationChecks = []; foreach ($subscores as $subscore) { $validationChecks['score'.'.'.$subscore->id] = 'required|integer|max:'.$subscore->max_score; @@ -94,16 +94,13 @@ class JudgingController extends Controller $validatedData = $request->validate($validationChecks); // Enter the score - try { - $enterScore(Auth::user(), $entry, $validatedData['score']); - } catch (ScoreEntryException $e) { - return redirect()->back()->with('error', 'Error saving score - '.$e->getMessage()); - } + /** @noinspection PhpUnhandledExceptionInspection */ + $enterScore(Auth::user(), $entry, $validatedData['score']); // Deal with an advancement vote if needed $this->advancementVote($request, $entry); - return redirect('/judging/audition/'.$entry->audition_id)->with('success', + return redirect(route('judging.auditionEntryList', $entry->audition))->with('success', 'Entered scores for '.$entry->audition->name.' '.$entry->draw_number); } @@ -111,8 +108,10 @@ class JudgingController extends Controller public function updateScoreSheet(Request $request, Entry $entry, EnterScore $enterScore) { if ($request->user()->cannot('judge', $entry->audition)) { - abort(403, 'You are not assigned to judge this entry'); + return redirect()->route('judging.index')->with('error', 'You are not assigned to judge this entry'); } + + // We can't update a scoresheet that doesn't exist $scoreSheet = ScoreSheet::where('user_id', Auth::id())->where('entry_id', $entry->id)->first(); if (! $scoreSheet) { return redirect()->back()->with('error', 'Attempt to edit non existent score sheet'); @@ -120,11 +119,8 @@ class JudgingController extends Controller Gate::authorize('update', $scoreSheet); // Validate form data - try { - $subscores = $this->auditionService->getSubscores($entry->audition, 'all'); - } catch (AuditionServiceException $e) { - return redirect()->back()->with('error', 'Error getting subscores - '.$e->getMessage()); - } + + $subscores = $entry->audition->subscoreDefinitions; $validationChecks = []; foreach ($subscores as $subscore) { @@ -133,38 +129,29 @@ class JudgingController extends Controller $validatedData = $request->validate($validationChecks); // Enter the score - try { - $enterScore(Auth::user(), $entry, $validatedData['score'], $scoreSheet); - } catch (ScoreEntryException $e) { - return redirect()->back()->with('error', 'Error updating score - '.$e->getMessage()); - } + + $enterScore(Auth::user(), $entry, $validatedData['score'], $scoreSheet); $this->advancementVote($request, $entry); - return redirect('/judging/audition/'.$entry->audition_id)->with('success', + return redirect(route('judging.auditionEntryList', $entry->audition))->with('success', 'Updated scores for '.$entry->audition->name.' '.$entry->draw_number); } protected function advancementVote(Request $request, Entry $entry) { - if ($request->user()->cannot('judge', $entry->audition)) { - abort(403, 'You are not assigned to judge this entry'); - } - if ($entry->for_advancement and auditionSetting('advanceTo')) { $request->validate([ 'advancement-vote' => ['required', 'in:yes,no,dq'], ]); - try { - JudgeAdvancementVote::where('user_id', Auth::id())->where('entry_id', $entry->id)->delete(); - JudgeAdvancementVote::create([ - 'user_id' => Auth::user()->id, - 'entry_id' => $entry->id, - 'vote' => $request->input('advancement-vote'), - ]); - } catch (Exception) { - return redirect(url()->previous())->with('error', 'Error saving advancement vote'); - } + + JudgeAdvancementVote::where('user_id', Auth::id())->where('entry_id', $entry->id)->delete(); + JudgeAdvancementVote::create([ + 'user_id' => Auth::user()->id, + 'entry_id' => $entry->id, + 'vote' => $request->input('advancement-vote'), + ]); + } return null; diff --git a/app/Models/Audition.php b/app/Models/Audition.php index 5070235..6c86d5f 100644 --- a/app/Models/Audition.php +++ b/app/Models/Audition.php @@ -55,6 +55,29 @@ class Audition extends Model return $this->belongsTo(ScoringGuide::class); } + public function subscoreDefinitions() + { + // TODO: Consider cache. Look at how often this is called. + return $this->hasManyThrough( + SubscoreDefinition::class, // Final related model + ScoringGuide::class, // Intermediate model + 'id', // Foreign key on ScoringGuide table (primary key) + 'scoring_guide_id', // Foreign key on SubscoreDefinition table + 'scoring_guide_id', // Foreign key on Audition table (local key) + 'id' // Local key on ScoringGuide table (primary key) + ); + } + + public function getSeatingSubscores() + { + return $this->subscoreDefinitions()->where('for_seating', '1')->orderBy('display_order')->get(); + } + + public function getAdvancementSubscores() + { + return $this->subscoreDefinitions()->where('for_advance', '1')->orderBy('display_order')->get(); + } + public function bonusScore(): BelongsToMany { return $this->belongsToMany(BonusScoreDefinition::class, 'bonus_score_audition_assignment'); diff --git a/resources/views/judging/entry_score_sheet.blade.php b/resources/views/judging/entry_score_sheet.blade.php index 58fec8c..e4ef21a 100644 --- a/resources/views/judging/entry_score_sheet.blade.php +++ b/resources/views/judging/entry_score_sheet.blade.php @@ -11,7 +11,7 @@ diff --git a/tests/Feature/app/Http/Controllers/Judging/JudgingControllerTest.php b/tests/Feature/app/Http/Controllers/Judging/JudgingControllerTest.php new file mode 100644 index 0000000..38a855d --- /dev/null +++ b/tests/Feature/app/Http/Controllers/Judging/JudgingControllerTest.php @@ -0,0 +1,284 @@ +get(route('judging.index')); + $response->assertRedirect(route('dashboard')); + $response->assertSessionHas('error', 'You are not assigned to judge.'); + }); + it('shows a dashboard showing rooms and bonus scores the user is assigned to judge', function () { + $judge = User::factory()->create(); + $room = Room::factory()->create(); + $room->judges()->attach($judge); + $bonusScoreDefinition = BonusScoreDefinition::factory()->create(); + $bonusScoreDefinition->judges()->attach($judge); + $response = $this->actingAs($judge)->get(route('judging.index')); + $response->assertOk(); + $response->assertSee($room->name); + $response->assertSee($bonusScoreDefinition->name); + }); +}); + +describe('JudgingController::auditionEntryList', function () { + it('denies access to non-judges', function () { + actAsNormal(); + $audition = Audition::factory()->create(); + $response = $this->get(route('judging.auditionEntryList', $audition->id)); + $response->assertRedirect(route('dashboard')); + $response->assertSessionHas('error', 'You are not assigned to judge.'); + }); + it('denies access if were not assigned to the auditions room', function () { + $judge = User::factory()->create(); + $room = Room::factory()->create(); + $otherRoom = Room::factory()->create(); + $otherRoom->judges()->attach($judge); + $audition = Audition::factory()->create(['room_id' => $room->id]); + $response = $this->actingAs($judge)->get(route('judging.auditionEntryList', $audition->id)); + $response->assertRedirect(route('judging.index')); + $response->assertSessionHas('error', 'You are not assigned to judge that audition'); + }); + it('gives us an entry list from which we may select an entry to score', function () { + $scoringGuide = ScoringGuide::factory()->create(); + $judge = User::factory()->create(); + $room = Room::factory()->create(); + $room->judges()->attach($judge); + $audition = Audition::factory()->create(['room_id' => $room->id, 'scoring_guide_id' => $scoringGuide->id]); + $entries = Entry::factory()->count(5)->forAudition($audition)->create(); + $response = $this->actingAs($judge)->get(route('judging.auditionEntryList', $audition->id)); + $response->assertOk(); + $response->assertViewIs('judging.audition_entry_list'); + $response->assertViewHas('audition', $audition); + $response->assertViewHas('entries', $entries); + foreach ($entries as $entry) { + $response->assertSee($entry->audition->name.' '.$entry->draw_number); + $response->assertDontSee($entry->student->full_name()); + $response->assertDontSee($entry->student->full_name(true)); + } + foreach (SubscoreDefinition::all() as $subscoreDefinition) { + $response->assertSee($subscoreDefinition->name); + } + }); +}); + +describe('JudgingController::entryScoreSheet', function () { + it('denies access to non-judges', function () { + actAsNormal(); + $entry = Entry::factory()->create(); + $response = $this->get(route('judging.entryScoreSheet', $entry)); + $response->assertRedirect(route('dashboard')); + $response->assertSessionHas('error', 'You are not assigned to judge.'); + }); + + it('denies access if were not assigned to the auditions room', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $entry = Entry::factory()->create(); + $this->actingAs($judge); + $response = $this->get(route('judging.entryScoreSheet', $entry)); + $response->assertRedirect(route('judging.index')); + $response->assertSessionHas('error', 'You are not assigned to judge this entry'); + }); + + it('denies access if the audition is published', function () { + $room = Room::factory()->create(); + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $audition = Audition::factory()->create(['room_id' => $room->id]); + $entry = Entry::factory()->forAudition($audition)->create(); + $audition->addFlag('seats_published'); + $this->actingAs($judge); + $response = $this->get(route('judging.entryScoreSheet', $entry)); + $response->assertRedirect(route('judging.auditionEntryList', $audition)); + $response->assertSessionHas('error', 'Scores for entries in published auditions cannot be modified'); + }); + + it('denies access if the entry is flagged as a no-show', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $audition = Audition::factory()->create(['room_id' => $room->id]); + $entry = Entry::factory()->forAudition($audition)->create(); + $entry->addFlag('no_show'); + $this->actingAs($judge); + $response = $this->get(route('judging.entryScoreSheet', $entry)); + $response->assertRedirect(route('judging.auditionEntryList', $audition)); + $response->assertSessionHas('error', 'The requested entry is marked as a no-show. Scores cannot be entered.'); + }); + + it('denies access if the entry is flagged as a failed-prelim', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $audition = Audition::factory()->create(['room_id' => $room->id]); + $entry = Entry::factory()->forAudition($audition)->create(); + $entry->addFlag('failed_prelim'); + $this->actingAs($judge); + $response = $this->get(route('judging.entryScoreSheet', $entry)); + $response->assertRedirect(route('judging.auditionEntryList', $audition)); + $response->assertSessionHas('error', + 'The requested entry is marked as having failed a prelim. Scores cannot be entered.'); + }); + + it('gives us a form to enter a score for an entry', function () { + $scoringGuide = ScoringGuide::factory()->create(); + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $audition = Audition::factory()->create(['room_id' => $room->id, 'scoring_guide_id' => $scoringGuide->id]); + $entry = Entry::factory()->forAudition($audition)->create(); + $this->actingAs($judge); + $response = $this->get(route('judging.entryScoreSheet', $entry)); + $response->assertOk(); + $response->assertViewIs('judging.entry_score_sheet'); + $response->assertDontSee($entry->student->full_name()); + foreach (SubscoreDefinition::all() as $subscoreDefinition) { + $response->assertSee($subscoreDefinition->name); + $response->assertSee('score['.$subscoreDefinition->id.']'); + $response->assertSee('max: '.$subscoreDefinition->maximum_score); + } + }); +}); + +describe('JudgingController::saveScoreSheet', function () { + it('denies access to non-judges', function () { + actAsNormal(); + $audition = Audition::factory()->create(); + $entry = Entry::factory()->forAudition($audition)->create(); + $response = $this->post(route('judging.saveScoreSheet', $entry)); + $response->assertRedirect(route('dashboard')); + $response->assertSessionHas('error', 'You are not assigned to judge.'); + }); + + it('denies access to judges not assigned to the audition', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $audition = Audition::factory()->create(); + $entry = Entry::factory()->forAudition($audition)->create(); + $this->actingAs($judge); + $response = $this->post(route('judging.saveScoreSheet', $entry)); + $response->assertRedirect(route('judging.index')); + $response->assertSessionHas('error', 'You are not assigned to judge this entry'); + }); + + it('saves a score sheet', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $scoringGuide = ScoringGuide::factory()->create(); + $audition = Audition::factory()->create(['room_id' => $room->id, 'scoring_guide_id' => $scoringGuide->id]); + $subscoreIds = SubscoreDefinition::all()->pluck('id'); + $entry = Entry::factory()->forAudition($audition)->create(); + $submitData = [ + 'score' => [ + $subscoreIds[0] => 10, + $subscoreIds[1] => 20, + $subscoreIds[2] => 30, + $subscoreIds[3] => 40, + $subscoreIds[4] => 50, + ], + 'advancement-vote' => 'yes', + ]; + $this->actingAs($judge); + $response = $this->post(route('judging.saveScoreSheet', $entry), $submitData); + $response->assertRedirect(route('judging.auditionEntryList', $audition)); + $response->assertSessionHas('success'); + expect(ScoreSheet::where('entry_id', $entry->id)->first())->toBeInstanceOf(ScoreSheet::class) + ->and(JudgeAdvancementVote::where('entry_id', + $entry->id)->first())->toBeInstanceOf(JudgeAdvancementVote::class) + ->and(JudgeAdvancementVote::first()->vote)->toBe('yes'); + }); +}); + +describe('JudgingController::updateScoreSheet', function () { + it('denies access to non-judges', function () { + actAsNormal(); + $audition = Audition::factory()->create(); + $entry = Entry::factory()->forAudition($audition)->create(); + $response = $this->patch(route('judging.updateScoreSheet', $entry)); + $response->assertRedirect(route('dashboard')); + $response->assertSessionHas('error', 'You are not assigned to judge.'); + }); + it('denies access to judges not assigned to the audition', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $audition = Audition::factory()->create(); + $entry = Entry::factory()->forAudition($audition)->create(); + $this->actingAs($judge); + $response = $this->patch(route('judging.updateScoreSheet', $entry)); + $response->assertRedirect(route('judging.index')); + $response->assertSessionHas('error', 'You are not assigned to judge this entry'); + }); + it('will not update a non-existent score sheet', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $audition = Audition::factory()->create(['room_id' => $room->id]); + $entry = Entry::factory()->forAudition($audition)->create(); + $this->actingAs($judge); + $response = $this->patch(route('judging.updateScoreSheet', $entry)); + $response->assertRedirect()->assertSessionHas('error', 'Attempt to edit non existent score sheet'); + }); + it('will update a score sheet', function () { + $room = Room::factory()->create(); + $judge = User::factory()->create(); + $room->addJudge($judge); + $scoringGuide = ScoringGuide::factory()->create(); + $audition = Audition::factory()->create(['room_id' => $room->id, 'scoring_guide_id' => $scoringGuide->id]); + $subscoreIds = SubscoreDefinition::all()->pluck('id'); + $entry = Entry::factory()->forAudition($audition)->create(); + $submitData = [ + 'score' => [ + $subscoreIds[0] => 10, + $subscoreIds[1] => 20, + $subscoreIds[2] => 30, + $subscoreIds[3] => 40, + $subscoreIds[4] => 50, + ], + 'advancement-vote' => 'yes', + ]; + $this->actingAs($judge); + $this->post(route('judging.saveScoreSheet', $entry), $submitData); + $newSubmitData = [ + 'score' => [ + $subscoreIds[0] => 5, + $subscoreIds[1] => 15, + $subscoreIds[2] => 25, + $subscoreIds[3] => 35, + $subscoreIds[4] => 45, + ], + 'advancement-vote' => 'no', + ]; + $response = $this->patch(route('judging.updateScoreSheet', $entry), $newSubmitData); + $response->assertRedirect(route('judging.auditionEntryList', $audition)) + ->assertSessionHas('success'); + expect(ScoreSheet::count())->toEqual(1); + $ss = ScoreSheet::first(); + expect($ss->getSubscore($subscoreIds[0]))->toEqual(5); + expect($ss->getSubscore($subscoreIds[1]))->toEqual(15); + expect($ss->getSubscore($subscoreIds[2]))->toEqual(25); + expect($ss->getSubscore($subscoreIds[3]))->toEqual(35); + expect($ss->getSubscore($subscoreIds[4]))->toEqual(45); + expect(JudgeAdvancementVote::count())->toEqual(1); + $vote = JudgeAdvancementVote::first(); + expect($vote->vote)->toEqual('no'); + + }); +}); diff --git a/tests/Feature/app/Models/AuditionTest.php b/tests/Feature/app/Models/AuditionTest.php index 8b41be1..786dc1b 100644 --- a/tests/Feature/app/Models/AuditionTest.php +++ b/tests/Feature/app/Models/AuditionTest.php @@ -4,8 +4,10 @@ use App\Models\Audition; use App\Models\Ensemble; use App\Models\Entry; use App\Models\Room; +use App\Models\ScoringGuide; use App\Models\Seat; use App\Models\SeatingLimit; +use App\Models\SubscoreDefinition; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -62,6 +64,33 @@ it('can return its scoring guide if one is set', function () { expect($this->audition->scoringGuide)->toBeInstanceOf(App\Models\ScoringGuide::class); }); +it('can return the subscore definitions for its scoring guide if one is set', function () { + expect($this->audition->scoringGuide)->toBeNull(); + $guide = ScoringGuide::factory()->create(); + $subscores = SubscoreDefinition::orderBy('display_order')->get(); + $n = 1; + foreach ($subscores as $subscore) { + $subscore->update(['display_order' => $n]); + $n++; + } + $subscores[0]->update(['for_seating' => 0]); + $subscores[4]->update(['for_advance' => 0]); + $this->audition->scoringGuide()->associate($guide); + $this->audition->save(); + expect($this->audition->scoringGuide)->toBeInstanceOf(ScoringGuide::class) + ->and($this->audition->getSeatingSubscores()->contains('id', $subscores[0]->id))->toBeFalse() + ->and($this->audition->getSeatingSubscores()->contains('id', $subscores[1]->id))->toBeTrue() + ->and($this->audition->getSeatingSubscores()->contains('id', $subscores[2]->id))->toBeTrue() + ->and($this->audition->getSeatingSubscores()->contains('id', $subscores[3]->id))->toBeTrue() + ->and($this->audition->getSeatingSubscores()->contains('id', $subscores[4]->id))->toBeTrue() + ->and($this->audition->getAdvancementSubscores()->contains('id', $subscores[4]->id))->toBeFalse() + ->and($this->audition->getAdvancementSubscores()->contains('id', $subscores[3]->id))->toBeTrue() + ->and($this->audition->getAdvancementSubscores()->contains('id', $subscores[2]->id))->toBeTrue() + ->and($this->audition->getAdvancementSubscores()->contains('id', $subscores[1]->id))->toBeTrue() + ->and($this->audition->getAdvancementSubscores()->contains('id', $subscores[0]->id))->toBeTrue(); + +}); + it('can return its bonus score definition if one is set', function () { expect($this->audition->bonusScore()->count())->toBe(0); $definition = App\Models\BonusScoreDefinition::factory()->create();