Rewrite tabulation #14

Merged
okorpheus merged 43 commits from rewrite-tabulation into master 2024-07-14 05:36:29 +00:00
101 changed files with 2379 additions and 1588 deletions

2
.gitignore vendored
View File

@ -18,3 +18,5 @@ yarn-error.log
/.fleet
/.idea
/.vscode
/app/Http/Controllers/TestController.php
/resources/views/test.blade.php

View File

@ -0,0 +1,87 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException;
use App\Models\Entry;
use App\Services\AuditionService;
use App\Services\EntryService;
use Illuminate\Support\Facades\Cache;
class AllJudgesCount implements CalculateEntryScore
{
protected CalculateScoreSheetTotal $calculator;
protected AuditionService $auditionService;
protected EntryService $entryService;
public function __construct(CalculateScoreSheetTotal $calculator, AuditionService $auditionService, EntryService $entryService)
{
$this->calculator = $calculator;
$this->auditionService = $auditionService;
$this->entryService = $entryService;
}
public function calculate(string $mode, Entry $entry): array
{
$cacheKey = 'entryScore-'.$entry->id.'-'.$mode;
return Cache::remember($cacheKey, 10, function () use ($mode, $entry) {
$this->basicValidation($mode, $entry);
$this->areAllJudgesIn($entry);
$this->areAllJudgesValid($entry);
return $this->getJudgeTotals($mode, $entry);
});
}
protected function getJudgeTotals($mode, Entry $entry)
{
$scores = [];
foreach ($this->auditionService->getJudges($entry->audition) as $judge) {
$scores[] = $this->calculator->__invoke($mode, $entry, $judge);
}
$sums = [];
// Sum each subscore from the judges
foreach ($scores as $score) {
$index = 0;
foreach ($score as $value) {
$sums[$index] = $sums[$index] ?? 0;
$sums[$index] += $value;
$index++;
}
}
return $sums;
}
protected function basicValidation($mode, $entry): void
{
if ($mode !== 'seating' && $mode !== 'advancement') {
throw new TabulationException('Mode must be seating or advancement');
}
if (! $this->entryService->entryExists($entry)) {
throw new TabulationException('Invalid entry specified');
}
}
protected function areAllJudgesIn(Entry $entry): void
{
$assignedJudgeCount = $this->auditionService->getJudges($entry->audition)->count();
if ($entry->scoreSheets->count() !== $assignedJudgeCount) {
throw new TabulationException('Not all score sheets are in');
}
}
protected function areAllJudgesValid(Entry $entry): void
{
$validJudgeIds = $this->auditionService->getJudges($entry->audition)->sort()->pluck('id')->toArray();
$existingJudgeIds = $entry->scoreSheets->sort()->pluck('user_id')->toArray();
if ($validJudgeIds !== $existingJudgeIds) {
throw new TabulationException('Score exists from a judge not assigned to this audition');
}
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\Entry;
interface CalculateEntryScore
{
public function calculate(string $mode, Entry $entry): array;
}

View File

@ -0,0 +1,64 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException;
use App\Models\Entry;
use App\Models\ScoreSheet;
use App\Models\User;
use App\Services\AuditionService;
use App\Services\EntryService;
use App\Services\UserService;
class CalculateScoreSheetTotal
{
protected AuditionService $auditionService;
protected EntryService $entryService;
protected UserService $userService;
public function __construct(AuditionService $auditionService, EntryService $entryService, UserService $userService)
{
$this->auditionService = $auditionService;
$this->entryService = $entryService;
$this->userService = $userService;
}
public function __invoke(string $mode, Entry $entry, User $judge): array
{
$this->basicValidations($mode, $entry, $judge);
$scoreSheet = ScoreSheet::where('entry_id', $entry->id)->where('user_id', $judge->id)->first();
if (! $scoreSheet) {
throw new TabulationException('No score sheet by that judge for that entry');
}
$subscores = $this->auditionService->getSubscores($entry->audition, $mode);
$scoreTotal = 0;
$weightsTotal = 0;
$scoreArray = [];
foreach ($subscores as $subscore) {
$weight = $subscore['weight'];
$score = $scoreSheet->subscores[$subscore->id]['score'];
$scoreArray[] = $score;
$scoreTotal += ($score * $weight);
$weightsTotal += $weight;
}
$finalScore = $scoreTotal / $weightsTotal;
// put $final score at the beginning of the $ScoreArray
array_unshift($scoreArray, $finalScore);
return $scoreArray;
}
protected function basicValidations($mode, $entry, $judge): void
{
if ($mode !== 'seating' and $mode !== 'advancement') {
throw new TabulationException('Invalid mode requested. Mode must be seating or advancement');
}
if (! $this->entryService->entryExists($entry)) {
throw new TabulationException('Invalid entry provided');
}
if (! $this->userService->userExists($judge)) {
throw new TabulationException('Invalid judge provided');
}
}
}

View File

@ -0,0 +1,101 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
/** @noinspection PhpMissingReturnTypeInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\ScoreEntryException;
use App\Models\Entry;
use App\Models\ScoreSheet;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class EnterScore
{
// TODO implement this action every place that can save a score
public function __invoke(User $user, Entry $entry, array $scores): ScoreSheet
{
$scores = collect($scores);
$this->basicChecks($user, $entry, $scores);
$this->checkJudgeAssignment($user, $entry);
$this->checkForExistingScore($user, $entry);
$this->validateScoresSubmitted($entry, $scores);
$entry->removeFlag('no_show');
$newScoreSheet = ScoreSheet::create([
'user_id' => $user->id,
'entry_id' => $entry->id,
'subscores' => $this->subscoresForStorage($entry, $scores),
]);
return $newScoreSheet;
}
protected function subscoresForStorage(Entry $entry, Collection $scores)
{
$subscores = [];
foreach ($entry->audition->scoringGuide->subscores as $subscore) {
$subscores[$subscore->id] = [
'score' => $scores[$subscore->id],
'subscore_id' => $subscore->id,
'subscore_name' => $subscore->name,
];
}
return $subscores;
}
protected function checkForExistingScore(User $user, Entry $entry)
{
if (ScoreSheet::where('user_id', $user->id)->where('entry_id', $entry->id)->exists()) {
throw new ScoreEntryException('That judge has already entered scores for that entry');
}
}
protected function validateScoresSubmitted(Entry $entry, Collection $scores)
{
$subscoresRequired = $entry->audition->scoringGuide->subscores;
foreach ($subscoresRequired as $subscore) {
// check that there is an element in the $scores collection with the key = $subscore->id
if (! $scores->keys()->contains($subscore->id)) {
throw new ScoreEntryException('Invalid Score Submission');
}
if ($scores[$subscore->id] > $subscore->max_score) {
throw new ScoreEntryException('Supplied subscore exceeds maximum allowed');
}
}
}
protected function checkJudgeAssignment(User $user, Entry $entry)
{
$check = DB::table('room_user')
->where('room_id', $entry->audition->room_id)
->where('user_id', $user->id)->exists();
if (! $check) {
throw new ScoreEntryException('This judge is not assigned to judge this entry');
}
}
protected function basicChecks(User $user, Entry $entry, Collection $scores)
{
if (! $user->exists()) {
throw new ScoreEntryException('User does not exist');
}
if (! $entry->exists()) {
throw new ScoreEntryException('Entry does not exist');
}
if ($entry->audition->hasFlag('seats_published')) {
throw new ScoreEntryException('Cannot score an entry in an audition with published seats');
}
if ($entry->audition->hasFlag('advancement_published')) {
throw new ScoreEntryException('Cannot score an entry in an audition with published advancement');
}
$requiredScores = $entry->audition->scoringGuide->subscores()->count();
if ($scores->count() !== $requiredScores) {
throw new ScoreEntryException('Invalid number of scores');
}
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\Audition;
use App\Models\Ensemble;
use App\Models\Seat;
use function dd;
class GetAuditionSeats
{
public function __construct()
{
}
public function __invoke(Audition $audition): array
{
return $this->getSeats($audition);
}
protected function getSeats(Audition $audition)
{
$ensembles = Ensemble::where('event_id', $audition->event_id)->orderBy('rank')->get();
$seats = Seat::with('student.school')->where('audition_id', $audition->id)->orderBy('seat')->get();
$return = [];
foreach ($ensembles as $ensemble) {
$ensembleSeats = $seats->filter(fn ($seat) => $seat->ensemble_id === $ensemble->id);
foreach ($ensembleSeats as $seat) {
$return[] = [
'ensemble' => $ensemble->name,
'seat' => $seat->seat,
'student_name' => $seat->student->full_name(),
'school_name' => $seat->student->school->name,
];
}
}
return $return;
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\Audition;
use App\Models\Seat;
use Illuminate\Support\Facades\Cache;
class PublishSeats
{
public function __construct()
{
//
}
public function __invoke(Audition $audition, array $seats): void
{
// Delete from the seats table where audition_id = $audition->id
Seat::where('audition_id', $audition->id)->delete();
foreach ($seats as $seat) {
Seat::create([
'ensemble_id' => $seat['ensemble_id'],
'audition_id' => $seat['audition_id'],
'seat' => $seat['seat'],
'entry_id' => $seat['entry_id'],
]);
}
$audition->addFlag('seats_published');
Cache::forget('resultsSeatList');
}
}

View File

@ -0,0 +1,93 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException;
use App\Models\Audition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use function is_numeric;
class RankAuditionEntries
{
protected CalculateEntryScore $calculator;
public function __construct(CalculateEntryScore $calculator)
{
$this->calculator = $calculator;
}
public function rank(string $mode, Audition $audition): Collection
{
$cacheKey = 'audition'.$audition->id.$mode;
return Cache::remember($cacheKey, 30, function () use ($mode, $audition) {
return $this->calculateRank($mode, $audition);
});
}
public function calculateRank(string $mode, Audition $audition): Collection
{
$this->basicValidation($mode, $audition);
$entries = match ($mode) {
'seating' => $audition->entries()->forSeating()->with('scoreSheets')->get(),
'advancement' => $audition->entries()->forAdvancement()->with('scoreSheets')->get(),
};
foreach ($entries as $entry) {
$entry->setRelation('audition', $audition);
try {
$entry->score_totals = $this->calculator->calculate($mode, $entry);
} catch (TabulationException $ex) {
$entry->score_totals = [-1];
$entry->score_message = $ex->getMessage();
}
}
// Sort entries based on their total score, then by subscores in tiebreak order
$entries = $entries->sort(function ($a, $b) {
for ($i = 0; $i < count($a->score_totals); $i++) {
if ($a->score_totals[$i] > $b->score_totals[$i]) {
return -1;
} elseif ($a->score_totals[$i] < $b->score_totals[$i]) {
return 1;
}
}
return 0;
});
$rank = 1;
foreach ($entries as $entry) {
$entry->rank = $rank;
// We don't really get a rank for seating if we have certain flags
if ($mode === 'seating') {
if ($entry->hasFlag('declined')) {
$entry->rank = 'Declined';
} elseif ($entry->hasFlag('no_show')) {
$entry->rank = 'No Show';
} elseif ($entry->hasFlag('failed_prelim')) {
$entry->rank = 'Failed Prelim';
}
}
if (is_numeric($entry->rank)) {
$rank++;
}
}
return $entries;
}
protected function basicValidation($mode, Audition $audition): void
{
if ($mode !== 'seating' && $mode !== 'advancement') {
throw new TabulationException('Mode must be seating or advancement');
}
if (! $audition->exists()) {
throw new TabulationException('Invalid audition provided');
}
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\Audition;
use App\Models\Seat;
use Illuminate\Support\Facades\Cache;
class UnpublishSeats
{
public function __construct()
{
}
public function __invoke(Audition $audition): void
{
$audition->removeFlag('seats_published');
Cache::forget('resultsSeatList');
Seat::where('audition_id', $audition->id)->delete();
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AuditionChange
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $refreshCache;
/**
* Create a new event instance.
*/
public function __construct(bool $refreshCache = false)
{
$this->refreshCache = $refreshCache;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class EntryChange
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public int $auditionId;
/**
* Create a new event instance.
*/
public function __construct($auditionId = null)
{
$this->auditionId = $auditionId;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ScoreSheetChange
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public int $entryId;
/**
* Create a new event instance.
*/
public function __construct($entryId = null)
{
$this->entryId = $entryId;
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ScoringGuideChange
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SeatingLimitChange
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class AuditionServiceException extends Exception
{
//
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Exceptions;
use Exception;
class ScoreEntryException extends Exception
{
//
}

View File

@ -25,7 +25,7 @@ class DrawController extends Controller
public function index(Request $request)
{
$events = Event::with('auditions')->get();
$events = Event::with('auditions.flags')->get();
// $drawnAuditionsExist is true if any audition->hasFlag('drawn') is true
$drawnAuditionsExist = Audition::whereHas('flags', function ($query) {
$query->where('flag_name', 'drawn');

View File

@ -17,7 +17,7 @@ class RoomController extends Controller
if (! Auth::user()->is_admin) {
abort(403);
}
$rooms = Room::with('auditions.entries')->orderBy('name')->get();
$rooms = Room::with('auditions.entries','entries')->orderBy('name')->get();
return view('admin.rooms.index', ['rooms' => $rooms]);
}

View File

@ -23,11 +23,9 @@ class SchoolController extends Controller
public function index()
{
if (! Auth::user()->is_admin) {
abort(403);
}
$schools = School::with(['users', 'students', 'entries'])->orderBy('name')->get();
$schoolTotalFees = [];
foreach ($schools as $school) {
$schoolTotalFees[$school->id] = $this->invoiceService->getGrandTotal($school->id);
}

View File

@ -21,6 +21,7 @@ class EntryController extends Controller
});
$auditions = Audition::open()->get();
$students = Auth::user()->students;
$students->load('school');
return view('entries.index', ['entries' => $entries, 'students' => $students, 'auditions' => $auditions]);
}

View File

@ -5,11 +5,11 @@ namespace App\Http\Controllers;
use App\Models\Audition;
use App\Models\Entry;
use App\Models\JudgeAdvancementVote;
use App\Models\ScoreSheet;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use App\Models\ScoreSheet;
use function compact;
use function redirect;
@ -20,6 +20,7 @@ class JudgingController extends Controller
public function index()
{
$rooms = Auth::user()->judgingAssignments;
$rooms->load('auditions');
return view('judging.index', compact('rooms'));
}
@ -150,6 +151,7 @@ class JudgingController extends Controller
return redirect(url()->previous())->with('error', 'Error saving advancement vote');
}
}
return null;
}
}

View File

@ -2,36 +2,63 @@
namespace App\Http\Controllers\Tabulation;
use App\Actions\Tabulation\RankAuditionEntries;
use App\Http\Controllers\Controller;
use App\Models\Audition;
use App\Models\Entry;
use App\Services\TabulationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class AdvancementController extends Controller
{
protected TabulationService $tabulationService;
public function __construct(TabulationService $tabulationService)
protected RankAuditionEntries $ranker;
public function __construct(RankAuditionEntries $ranker)
{
$this->tabulationService = $tabulationService;
$this->ranker = $ranker;
}
public function status()
{
$auditions = $this->tabulationService->getAuditionsWithStatus('advancement');
$auditions = Audition::forAdvancement()
->with('flags')
->withCount([
'entries' => function ($query) {
$query->where('for_advancement', 1);
},
])
->withCount([
'unscoredEntries' => function ($query) {
$query->where('for_advancement', 1);
},
])
->get();
$auditionData = [];
$auditions->each(function ($audition) use (&$auditionData) {
$scoredPercent = ($audition->entries_count > 0) ?
round((($audition->entries_count - $audition->unscored_entries_count) / $audition->entries_count) * 100)
: 100;
$auditionData[] = [
'id' => $audition->id,
'name' => $audition->name,
'entries_count' => $audition->entries_count,
'unscored_entries_count' => $audition->unscored_entries_count,
'scored_entries_count' => $audition->entries_count - $audition->unscored_entries_count,
'scored_percentage' => $scoredPercent,
'scoring_complete' => $audition->unscored_entries_count == 0,
'published' => $audition->hasFlag('advancement_published'),
];
});
return view('tabulation.advancement.status', compact('auditions'));
return view('tabulation.advancement.status', compact('auditionData'));
}
public function ranking(Request $request, Audition $audition)
{
$entries = $this->tabulationService->auditionEntries($audition->id, 'advancement');
$entries = $this->ranker->rank('advancement', $audition);
$entries->load('advancementVotes');
$scoringComplete = $entries->every(function ($entry) {
return $entry->scoring_complete;
return $entry->score_totals[0] >= 0;
});
return view('tabulation.advancement.ranking', compact('audition', 'entries', 'scoringComplete'));
@ -46,8 +73,9 @@ class AdvancementController extends Controller
foreach ($entries as $entry) {
$entry->addFlag('will_advance');
}
return redirect()->route('advancement.ranking', ['audition' => $audition->id])->with('success', 'Passers have been set successfully');
Cache::forget('audition'.$audition->id.'advancement');
return redirect()->route('advancement.ranking', ['audition' => $audition->id])->with('success',
'Passers have been set successfully');
}
public function clearAuditionPassers(Request $request, Audition $audition)
@ -56,7 +84,8 @@ class AdvancementController extends Controller
foreach ($audition->entries as $entry) {
$entry->removeFlag('will_advance');
}
return redirect()->route('advancement.ranking', ['audition' => $audition->id])->with('success', 'Passers have been cleared successfully');
Cache::forget('audition'.$audition->id.'advancement');
return redirect()->route('advancement.ranking', ['audition' => $audition->id])->with('success',
'Passers have been cleared successfully');
}
}

View File

@ -4,13 +4,14 @@ namespace App\Http\Controllers\Tabulation;
use App\Http\Controllers\Controller;
use App\Models\Entry;
use App\Models\EntryFlag;
use App\Services\DoublerService;
use App\Services\EntryService;
use Illuminate\Support\Facades\Cache;
class DoublerDecisionController extends Controller
{
protected $doublerService;
protected $entryService;
public function __construct(DoublerService $doublerService, EntryService $entryService)
@ -21,24 +22,16 @@ class DoublerDecisionController extends Controller
public function accept(Entry $entry)
{
$doublerInfo = $this->doublerService->getDoublerInfo($entry->student_id);
foreach ($doublerInfo as $info) {
$this->entryService->clearEntryCacheForAudition($info['auditionID']);
if ($info['entryID'] != $entry->id) {
try {
EntryFlag::create([
'entry_id' => $info['entryID'],
'flag_name' => 'declined',
]);
} catch (\Exception $e) {
session()->flash('error', 'Entry ID'.$info['entryID'].' has already been declined.');
}
$doublerInfo = $this->doublerService->simpleDoubleInfo($entry);
foreach ($doublerInfo as $doublerEntry) {
/** @var Entry $doublerEntry */
if ($doublerEntry->id !== $entry->id) {
$doublerEntry->addFlag('declined');
}
}
$this->doublerService->refreshDoublerCache();
$returnMessage = $entry->student->full_name().' accepted seating in '.$entry->audition->name;
$this->clearCache($entry);
return redirect()->back()->with('success', $returnMessage);
@ -52,10 +45,17 @@ class DoublerDecisionController extends Controller
$entry->addFlag('declined');
$this->doublerService->refreshDoublerCache();
$returnMessage = $entry->student->full_name().' declined seating in '.$entry->audition->name;
$this->clearCache($entry);
return redirect()->back()->with('success', $returnMessage);
}
protected function clearCache($entry)
{
$cacheKey = 'event'.$entry->audition->event_id.'doublers-seating';
Cache::forget($cacheKey);
$cacheKey = 'event'.$entry->audition->event_id.'doublers-advancement';
Cache::forget($cacheKey);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Tabulation;
use App\Actions\Tabulation\CalculateEntryScore;
use App\Actions\Tabulation\GetAuditionSeats;
use App\Actions\Tabulation\RankAuditionEntries;
use App\Http\Controllers\Controller;
use App\Models\Audition;
use App\Services\AuditionService;
use App\Services\DoublerService;
use App\Services\EntryService;
use Illuminate\Http\Request;
class SeatAuditionFormController extends Controller
{
protected CalculateEntryScore $calc;
protected DoublerService $doublerService;
protected RankAuditionEntries $ranker;
protected EntryService $entryService;
protected AuditionService $auditionService;
public function __construct(
CalculateEntryScore $calc,
RankAuditionEntries $ranker,
DoublerService $doublerService,
EntryService $entryService,
AuditionService $auditionService,
) {
$this->calc = $calc;
$this->ranker = $ranker;
$this->doublerService = $doublerService;
$this->entryService = $entryService;
$this->auditionService = $auditionService;
}
public function __invoke(Request $request, Audition $audition)
{
// If a seating proposal was posted, deal wth it
if ($request->method() == 'POST') {
$requestedEnsembleAccepts = $request->input('ensembleAccept');
} else {
$requestedEnsembleAccepts = false;
}
$entryData = [];
$entries = $this->ranker->rank('seating', $audition);
$entries->load('student.school');
$seatable = [
'allScored' => true,
'doublersResolved' => true,
];
foreach ($entries as $entry) {
$totalScoreColumn = 'No Score';
$fullyScored = false;
if ($entry->score_totals) {
$totalScoreColumn = $entry->score_totals[0] >= 0 ? $entry->score_totals[0] : $entry->score_message;
$fullyScored = $entry->score_totals[0] >= 0;
}
$doublerData = $this->doublerService->entryDoublerData($entry);
$entryData[] = [
'rank' => $entry->rank,
'id' => $entry->id,
'studentName' => $entry->student->full_name(),
'schoolName' => $entry->student->school->name,
'drawNumber' => $entry->draw_number,
'totalScore' => $totalScoreColumn,
'fullyScored' => $fullyScored,
'doubleData' => $doublerData,
];
// If this entries double decision isn't made, block seating
if ($doublerData && $doublerData[$entry->id]['status'] == 'undecided') {
$seatable['doublersResolved'] = false;
}
// If entry is unscored, block seating
if (! $fullyScored) {
$seatable['allScored'] = false;
}
}
$rightPanel = $this->pickRightPanel($audition, $seatable);
$seatableEntries = [];
if ($seatable['doublersResolved'] && $seatable['allScored']) {
$seatableEntries = $entries->reject(function ($entry) {
if ($entry->hasFlag('declined')) {
return true;
}
if ($entry->hasFlag('no_show')) {
return true;
}
return false;
});
}
return view('tabulation.auditionSeating', compact('entryData', 'audition', 'rightPanel', 'seatableEntries', 'requestedEnsembleAccepts'));
}
protected function pickRightPanel(Audition $audition, array $seatable)
{
if ($audition->hasFlag('seats_published')) {
$resultsWindow = new GetAuditionSeats;
$rightPanel['view'] = 'tabulation.auditionSeating-show-published-seats';
$rightPanel['data'] = $resultsWindow($audition);
return $rightPanel;
}
if ($seatable['allScored'] == false || $seatable['doublersResolved'] == false) {
$rightPanel['view'] = 'tabulation.auditionSeating-unable-to-seat-card';
$rightPanel['data'] = $seatable;
return $rightPanel;
}
$rightPanel['view'] = 'tabulation.auditionSeating-right-complete-not-published';
$rightPanel['data'] = $this->auditionService->getSeatingLimits($audition);
return $rightPanel;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Tabulation;
use App\Actions\Tabulation\PublishSeats;
use App\Actions\Tabulation\UnpublishSeats;
use App\Http\Controllers\Controller;
use App\Models\Audition;
use Illuminate\Http\Request;
class SeatingPublicationController extends Controller
{
public function publishSeats(Request $request, Audition $audition)
{
$publisher = new PublishSeats;
$sessionKey = 'audition'.$audition->id.'seatingProposal';
$seats = $request->session()->get($sessionKey);
$publisher($audition, $seats);
$request->session()->forget($sessionKey);
return redirect()->route('seating.audition', ['audition' => $audition->id]);
}
public function unpublishSeats(Request $request, Audition $audition)
{
$publisher = new UnpublishSeats;
$publisher($audition);
return redirect()->route('seating.audition', ['audition' => $audition->id]);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Tabulation;
use App\Http\Controllers\Controller;
use App\Models\Audition;
use Illuminate\Http\Request;
class SeatingStatusController extends Controller
{
/**
* Handle the incoming request.
*/
public function __invoke(Request $request)
{
$auditions = Audition::forSeating()
->withCount(['entries'=> function ($query) {
$query->where('for_seating', 1);
}])
->withCount(['unscoredEntries'=>function ($query) {
$query->where('for_seating', 1);
}])
->with('flags')
->get();
$auditionData = [];
foreach ($auditions as $audition) {
$auditionData[$audition->id] = [
'id' => $audition->id,
'name' => $audition->name,
'scoredEntriesCount' => $audition->entries_count - $audition->unscored_entries_count,
'totalEntriesCount' => $audition->entries_count,
'scoredPercentage' => $audition->entries_count > 0 ? ($audition->entries_count - $audition->unscored_entries_count) / $audition->entries_count * 100 : 100,
'scoringComplete' => $audition->unscored_entries_count === 0,
'seatsPublished' => $audition->hasFlag('seats_published'),
'audition' => $audition,
];
}
$auditionData = collect($auditionData);
return view('tabulation.status', compact('auditionData'));
}
}

View File

@ -1,124 +0,0 @@
<?php
namespace App\Http\Controllers\Tabulation;
use App\Http\Controllers\Controller;
use App\Models\Audition;
use App\Models\Seat;
use App\Services\AuditionService;
use App\Services\DoublerService;
use App\Services\SeatingService;
use App\Services\TabulationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use function compact;
class TabulationController extends Controller
{
protected $tabulationService;
protected $doublerService;
protected $seatingService;
protected $auditionService;
public function __construct(TabulationService $tabulationService,
DoublerService $doublerService,
SeatingService $seatingService,
AuditionService $auditionService)
{
$this->tabulationService = $tabulationService;
$this->doublerService = $doublerService;
$this->seatingService = $seatingService;
$this->auditionService = $auditionService;
}
public function status()
{
$auditions = $this->tabulationService->getAuditionsWithStatus('seating');
return view('tabulation.status', compact('auditions'));
}
public function auditionSeating(Request $request, Audition $audition)
{
if ($request->method() == 'POST') {
$requestedEnsembleAccepts = $request->input('ensembleAccept');
} else {
$requestedEnsembleAccepts = false;
}
$entries = $this->tabulationService->auditionEntries($audition->id);
$entries = $entries->filter(function ($entry) {
return $entry->for_seating;
});
$doublerComplete = true;
foreach ($entries as $entry) {
if ($this->doublerService->studentIsDoubler($entry->student_id)) { // If this entry is a doubler
if ($this->doublerService->getDoublerInfo($entry->student_id)[$entry->id]['status'] === 'undecided') { // If there is no decision for this entry
$doublerComplete = false;
}
}
}
$scoringComplete = $entries->every(function ($entry) {
return $entry->scoring_complete;
});
$ensembleLimits = $this->seatingService->getLimitForAudition($audition->id);
$auditionComplete = $scoringComplete && $doublerComplete;
$seatableEntries = $this->seatingService->getSeatableEntries($audition->id);
$seatableEntries = $seatableEntries->filter(function ($entry) {
return $entry->for_seating;
});
return view('tabulation.auditionSeating', compact('audition',
'entries',
'scoringComplete',
'doublerComplete',
'auditionComplete',
'ensembleLimits',
'seatableEntries',
'requestedEnsembleAccepts'));
}
public function publishSeats(Request $request, Audition $audition)
{
// TODO move this to SeatingService
$sessionKey = 'audition'.$audition->id.'seatingProposal';
$seats = $request->session()->get($sessionKey);
foreach ($seats as $seat) {
Seat::create([
'ensemble_id' => $seat['ensemble_id'],
'audition_id' => $seat['audition_id'],
'seat' => $seat['seat'],
'entry_id' => $seat['entry_id'],
]);
}
$audition->addFlag('seats_published');
$request->session()->forget($sessionKey);
Cache::forget('resultsSeatList');
Cache::forget('publishedAuditions');
Cache::forget('audition'.$audition->id.'seats');
// TODO move the previous Cache functions here and in unplublish to the services, need to add an event for publishing an audition as well
return redirect()->route('tabulation.audition.seat', ['audition' => $audition->id]);
}
public function unpublishSeats(Request $request, Audition $audition)
{
// TODO move this to SeatingService
$audition->removeFlag('seats_published');
Cache::forget('resultsSeatList');
Cache::forget('publishedAuditions');
Cache::forget('audition'.$audition->id.'seats');
$this->seatingService->forgetSeatsForAudition($audition->id);
Seat::where('audition_id', $audition->id)->delete();
return redirect()->route('tabulation.audition.seat', ['audition' => $audition->id]);
}
}

View File

@ -1,33 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\AuditionService;
use App\Services\Invoice\InvoiceDataService;
use App\Services\TabulationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
class TestController extends Controller
{
protected $scoringGuideCacheService;
protected $tabulationService;
protected $invoiceService;
public function __construct(
AuditionService $scoringGuideCacheService,
TabulationService $tabulationService,
InvoiceDataService $invoiceService
) {
$this->scoringGuideCacheService = $scoringGuideCacheService;
$this->tabulationService = $tabulationService;
$this->invoiceService = $invoiceService;
}
public function flashTest(Request $request)
{
$lines = $this->invoiceService->getLines(12);
$totalFees = $this->invoiceService->getGrandTotal(12);
return view('test', compact('lines','totalFees'));
}
}

View File

@ -83,4 +83,3 @@ class UserController extends Controller
}
//TODO allow users to modify their profile information. RoomJudgeChange::dispatch(); when they do

View File

@ -1,31 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\AuditionChange;
use App\Services\AuditionService;
class RefreshAuditionCache
{
protected $auditionService;
/**
* Create the event listener.
*/
public function __construct(AuditionService $cacheService)
{
$this->auditionService = $cacheService;
}
/**
* Handle the event.
*/
public function handle(AuditionChange $event): void
{
if ($event->refreshCache) {
$this->auditionService->refreshCache();
} else {
$this->auditionService->clearCache();
}
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\AuditionChange;
use App\Events\EntryChange;
use App\Services\AuditionService;
use App\Services\EntryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class RefreshEntryCache
{
protected $entryService;
/**
* Create the event listener.
*/
public function __construct(EntryService $cacheService)
{
$this->entryService = $cacheService;
}
/**
* Handle the event.
*/
public function handle(EntryChange $event): void
{
if ($event->auditionId) {
$this->entryService->clearEntryCacheForAudition($event->auditionId);
} else {
$this->entryService->clearEntryCaches();
}
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\ScoreSheetChange;
use App\Services\ScoreService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class RefreshScoreSheetCache
{
protected $scoreService;
/**
* Create the event listener.
*/
public function __construct(ScoreService $scoreService)
{
$this->scoreService = $scoreService;
}
/**
* Handle the event.
*/
public function handle(ScoreSheetChange $event): void
{
$this->scoreService->clearScoreSheetCountCache();
if ($event->entryId) {
$this->scoreService->clearEntryTotalScoresCache($event->entryId);
}
// If we are in local environment, send a success flash message
if (config('app.env') === 'local') {
session()->flash('success','Cleared cache for entry ID ' . $event->entryId);
}
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\ScoringGuideChange;
use App\Services\ScoreService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class RefreshScoringGuideCache
{
protected $scoreService;
/**
* Create the event listener.
*/
public function __construct(ScoreService $scoreService)
{
$this->scoreService = $scoreService;
}
/**
* Handle the event.
*/
public function handle(ScoringGuideChange $event): void
{
$this->scoreService->clearScoringGuideCache();
$this->scoreService->clearAllCachedTotalScores();
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Listeners;
use App\Events\SeatingLimitChange;
use App\Services\SeatingService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class RefreshSeatingLimitCache
{
protected $seatingService;
/**
* Create the event listener.
*/
public function __construct(SeatingService $seatingService)
{
$this->seatingService = $seatingService;
}
/**
* Handle the event.
*/
public function handle(SeatingLimitChange $event): void
{
$this->seatingService->refreshLimits();
}
}

View File

@ -17,6 +17,9 @@ class Audition extends Model
{
use HasFactory;
/**
* @var int|mixed
*/
protected $guarded = [];
public function event(): BelongsTo
@ -29,6 +32,12 @@ class Audition extends Model
return $this->hasMany(Entry::class);
}
public function unscoredEntries(): HasMany
{
return $this->hasMany(Entry::class)
->whereDoesntHave('scoreSheets');
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
@ -114,12 +123,12 @@ class Audition extends Model
public function scopeForSeating(Builder $query): void
{
$query->where('for_seating', 1);
$query->where('for_seating', 1)->orderBy('score_order');
}
public function scopeForAdvancement(Builder $query): void
{
$query->where('for_advancement', 1);
$query->where('for_advancement', 1)->orderBy('score_order');
}
public function scopeSeatsPublished(Builder $query): Builder

View File

@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use App\Models\ScoreSheet;
use Illuminate\Support\Facades\Cache;
class Entry extends Model
{

View File

@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
class Event extends Model
{
@ -21,4 +22,9 @@ class Event extends Model
return $this->hasMany(Ensemble::class)
->orderBy('rank');
}
public function entries(): HasManyThrough
{
return $this->hasManyThrough(Entry::class, Audition::class);
}
}

View File

@ -46,13 +46,11 @@ class Room extends Model
{
$this->judges()->attach($userId);
$this->load('judges');
AuditionChange::dispatch();
}
public function removeJudge($userId): void
{
$this->judges()->detach($userId);
$this->load('judges');
AuditionChange::dispatch();
}
}

View File

@ -125,7 +125,7 @@ class User extends Authenticatable implements MustVerifyEmail
public function isJudge(): bool
{
return $this->judgingAssignments->count() > 0;
return $this->judgingAssignments()->count() > 0;
}
/**

View File

@ -13,8 +13,7 @@ class AuditionObserver
*/
public function created(Audition $audition): void
{
AuditionChange::dispatch();
EntryChange::dispatch($audition->id);
//
}
/**
@ -22,8 +21,7 @@ class AuditionObserver
*/
public function updated(Audition $audition): void
{
AuditionChange::dispatch();
EntryChange::dispatch($audition->id);
//
}
/**
@ -31,8 +29,7 @@ class AuditionObserver
*/
public function deleted(Audition $audition): void
{
AuditionChange::dispatch();
EntryChange::dispatch($audition->id);
//
}
/**
@ -40,8 +37,7 @@ class AuditionObserver
*/
public function restored(Audition $audition): void
{
AuditionChange::dispatch();
EntryChange::dispatch($audition->id);
//
}
/**
@ -49,7 +45,6 @@ class AuditionObserver
*/
public function forceDeleted(Audition $audition): void
{
AuditionChange::dispatch();
EntryChange::dispatch($audition->id);
//
}
}

View File

@ -20,7 +20,7 @@ class RoomObserver
*/
public function updated(Room $room): void
{
AuditionChange::dispatch();
//
}
/**
@ -28,7 +28,7 @@ class RoomObserver
*/
public function deleted(Room $room): void
{
AuditionChange::dispatch();
//
}
/**
@ -36,7 +36,7 @@ class RoomObserver
*/
public function restored(Room $room): void
{
AuditionChange::dispatch();
//
}
/**
@ -44,6 +44,6 @@ class RoomObserver
*/
public function forceDeleted(Room $room): void
{
AuditionChange::dispatch();
//
}
}

View File

@ -12,7 +12,7 @@ class RoomUserObserver
*/
public function created(RoomUser $roomUser): void
{
AuditionChange::dispatch();
//
}
/**
@ -20,7 +20,7 @@ class RoomUserObserver
*/
public function updated(RoomUser $roomUser): void
{
AuditionChange::dispatch();
//
}
/**
@ -28,7 +28,7 @@ class RoomUserObserver
*/
public function deleted(RoomUser $roomUser): void
{
AuditionChange::dispatch();
//
}
/**
@ -36,7 +36,7 @@ class RoomUserObserver
*/
public function restored(RoomUser $roomUser): void
{
AuditionChange::dispatch();
//
}
/**
@ -44,6 +44,6 @@ class RoomUserObserver
*/
public function forceDeleted(RoomUser $roomUser): void
{
AuditionChange::dispatch();
//
}
}

View File

@ -12,7 +12,7 @@ class ScoreSheetObserver
*/
public function created(ScoreSheet $scoreSheet): void
{
ScoreSheetChange::dispatch($scoreSheet->entry_id);
//
}
/**
@ -20,7 +20,7 @@ class ScoreSheetObserver
*/
public function updated(ScoreSheet $scoreSheet): void
{
ScoreSheetChange::dispatch($scoreSheet->entry_id);
//
}
/**
@ -28,7 +28,7 @@ class ScoreSheetObserver
*/
public function deleted(ScoreSheet $scoreSheet): void
{
ScoreSheetChange::dispatch($scoreSheet->entry_id);
//
}
/**
@ -36,7 +36,7 @@ class ScoreSheetObserver
*/
public function restored(ScoreSheet $scoreSheet): void
{
ScoreSheetChange::dispatch($scoreSheet->entry_id);
//
}
/**
@ -44,6 +44,6 @@ class ScoreSheetObserver
*/
public function forceDeleted(ScoreSheet $scoreSheet): void
{
ScoreSheetChange::dispatch($scoreSheet->entry_id);
//
}
}

View File

@ -13,7 +13,7 @@ class ScoringGuideObserver
*/
public function created(ScoringGuide $scoringGuide): void
{
ScoringGuideChange::dispatch();
//
}
/**
@ -21,8 +21,7 @@ class ScoringGuideObserver
*/
public function updated(ScoringGuide $scoringGuide): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
/**
@ -30,8 +29,7 @@ class ScoringGuideObserver
*/
public function deleted(ScoringGuide $scoringGuide): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
/**
@ -39,8 +37,7 @@ class ScoringGuideObserver
*/
public function restored(ScoringGuide $scoringGuide): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
/**
@ -48,7 +45,6 @@ class ScoringGuideObserver
*/
public function forceDeleted(ScoringGuide $scoringGuide): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
}

View File

@ -12,7 +12,7 @@ class SeatingLimitObserver
*/
public function created(SeatingLimit $seatingLimit): void
{
ScoringGuideChange::dispatch();
//
}
/**
@ -20,7 +20,7 @@ class SeatingLimitObserver
*/
public function updated(SeatingLimit $seatingLimit): void
{
ScoringGuideChange::dispatch();
//
}
/**
@ -28,7 +28,7 @@ class SeatingLimitObserver
*/
public function deleted(SeatingLimit $seatingLimit): void
{
ScoringGuideChange::dispatch();
//
}
/**
@ -36,7 +36,7 @@ class SeatingLimitObserver
*/
public function restored(SeatingLimit $seatingLimit): void
{
ScoringGuideChange::dispatch();
//
}
/**
@ -44,6 +44,6 @@ class SeatingLimitObserver
*/
public function forceDeleted(SeatingLimit $seatingLimit): void
{
ScoringGuideChange::dispatch();
//
}
}

View File

@ -13,8 +13,7 @@ class SubscoreDefinitionObserver
*/
public function created(SubscoreDefinition $subscoreDefinition): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
/**
@ -22,8 +21,7 @@ class SubscoreDefinitionObserver
*/
public function updated(SubscoreDefinition $subscoreDefinition): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
/**
@ -31,8 +29,7 @@ class SubscoreDefinitionObserver
*/
public function deleted(SubscoreDefinition $subscoreDefinition): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
/**
@ -40,8 +37,7 @@ class SubscoreDefinitionObserver
*/
public function restored(SubscoreDefinition $subscoreDefinition): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
/**
@ -49,7 +45,6 @@ class SubscoreDefinitionObserver
*/
public function forceDeleted(SubscoreDefinition $subscoreDefinition): void
{
AuditionChange::dispatch();
ScoringGuideChange::dispatch();
//
}
}

View File

@ -20,7 +20,7 @@ class UserObserver
*/
public function updated(User $user): void
{
AuditionChange::dispatch();
//
}
/**
@ -28,7 +28,7 @@ class UserObserver
*/
public function deleted(User $user): void
{
AuditionChange::dispatch();
//
}
/**
@ -44,6 +44,6 @@ class UserObserver
*/
public function forceDeleted(User $user): void
{
AuditionChange::dispatch();
//
}
}

View File

@ -2,21 +2,15 @@
namespace App\Providers;
use App\Events\AuditionChange;
use App\Events\EntryChange;
use App\Events\ScoreSheetChange;
use App\Events\ScoringGuideChange;
use App\Events\SeatingLimitChange;
use App\Listeners\RefreshAuditionCache;
use App\Listeners\RefreshEntryCache;
use App\Listeners\RefreshScoreSheetCache;
use App\Listeners\RefreshScoringGuideCache;
use App\Listeners\RefreshSeatingLimitCache;
use App\Actions\Tabulation\AllJudgesCount;
use App\Actions\Tabulation\CalculateEntryScore;
use App\Actions\Tabulation\CalculateScoreSheetTotal;
use App\Models\Audition;
use App\Models\Entry;
use App\Models\Room;
use App\Models\RoomUser;
use App\Models\School;
use App\Models\ScoreSheet;
use App\Models\ScoringGuide;
use App\Models\SeatingLimit;
use App\Models\Student;
@ -38,12 +32,10 @@ use App\Services\DoublerService;
use App\Services\DrawService;
use App\Services\EntryService;
use App\Services\ScoreService;
use App\Services\SeatingService;
use App\Services\TabulationService;
use Illuminate\Support\Facades\Event;
use App\Services\StudentService;
use App\Services\UserService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
use App\Models\ScoreSheet;
class AppServiceProvider extends ServiceProvider
{
@ -52,36 +44,14 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->singleton(DrawService::class, function () {
return new DrawService();
});
$this->app->singleton(AuditionService::class, function () {
return new AuditionService();
});
$this->app->singleton(SeatingService::class, function ($app) {
return new SeatingService($app->make(TabulationService::class));
});
$this->app->singleton(EntryService::class, function ($app) {
return new EntryService($app->make(AuditionService::class));
});
$this->app->singleton(ScoreService::class, function ($app) {
return new ScoreService($app->make(AuditionService::class), $app->make(EntryService::class));
});
$this->app->singleton(TabulationService::class, function ($app) {
return new TabulationService(
$app->make(AuditionService::class),
$app->make(ScoreService::class),
$app->make(EntryService::class));
});
$this->app->singleton(DoublerService::class, function ($app) {
return new DoublerService($app->make(AuditionService::class), $app->make(TabulationService::class), $app->make(SeatingService::class));
});
$this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotal::class);
$this->app->singleton(CalculateEntryScore::class, AllJudgesCount::class);
$this->app->singleton(DrawService::class, DrawService::class);
$this->app->singleton(AuditionService::class, AuditionService::class);
$this->app->singleton(EntryService::class, EntryService::class);
$this->app->singleton(ScoreService::class, ScoreService::class);
$this->app->singleton(UserService::class, UserService::class);
$this->app->singleton(DoublerService::class, DoublerService::class);
}
/**
@ -102,29 +72,6 @@ class AppServiceProvider extends ServiceProvider
User::observe(UserObserver::class);
SeatingLimit::observe(SeatingLimitObserver::class);
Event::listen(
AuditionChange::class,
RefreshAuditionCache::class
);
Event::listen(
EntryChange::class,
RefreshEntryCache::class
);
Event::listen(
ScoringGuideChange::class,
RefreshScoringGuideCache::class
);
Event::listen(
ScoreSheetChange::class,
RefreshScoreSheetCache::class
);
Event::listen(
SeatingLimitChange::class,
RefreshSeatingLimitCache::class
);
//Model::preventLazyLoading(! app()->isProduction());
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Providers;
use App\Actions\Tabulation\AllJudgesCount;
use App\Actions\Tabulation\CalculateEntryScore;
use App\Actions\Tabulation\CalculateScoreSheetTotal;
use Illuminate\Support\ServiceProvider;
class CalculateEntryScoreProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotal::class);
$this->app->singleton(CalculateEntryScore::class, AllJudgesCount::class);
}
/**
* Bootstrap services.
*/
public function boot(): void
{
//
}
}

View File

@ -2,102 +2,131 @@
namespace App\Services;
use App\Exceptions\AuditionServiceException;
use App\Models\Audition;
use App\Models\ScoringGuide;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\App;
use App\Models\Ensemble;
use App\Models\SeatingLimit;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;
class AuditionService
{
protected $cacheKey = 'auditions';
/**
* Create a new class instance.
*/
public static Collection $allAuditionIds;
public function __construct()
{
//
$cacheKey = 'allAuditionIds';
self::$allAuditionIds = Cache::remember($cacheKey, 60, function () {
return Audition::pluck('id');
});
}
/**
* Return or fill cache of auditions including the audition,
* scoringGuide.subscores, judges, judges_count, and entries_count
* @throws AuditionServiceException
*/
public function getAuditions($mode = 'seating'): \Illuminate\Database\Eloquent\Collection
public function getSubscores(Audition $audition, $mode = 'seating', $sort = 'tiebreak')
{
$auditions = Cache::remember($this->cacheKey, 3600, function () {
if (App::environment('local')) {
Session::flash('success', 'Audition Cache Updated');
}
$cacheKey = 'auditionSubscores-'.$audition->id.'-'.$mode.'-'.$sort;
return Audition::with(['scoringGuide.subscores', 'judges'])
->withCount('judges')
->withCount('entries')
->withCount(['entries as seating_entries_count' => function (Builder $query) {
$query->where('for_seating', true);
}])
->withCount(['entries as advancement_entries_count' => function (Builder $query) {
$query->where('for_advancement', true);
}])
->orderBy('score_order')
->get()
->keyBy('id');
});
return Cache::remember($cacheKey, 10, function () use ($audition, $mode, $sort) {
$this->validateAudition($audition);
$this->validateMode($mode);
$this->validateSort($sort);
switch ($mode) {
case 'seating':
return $auditions->filter(fn ($audition) => $audition->for_seating);
case 'advancement':
return $auditions->filter(fn ($audition) => $audition->for_advancement);
default:
return $auditions;
}
}
$sortColumn = match ($sort) {
'tiebreak' => 'tiebreak_order',
'display' => 'display_order',
};
$modeColumn = match ($mode) {
'seating' => 'for_seating',
'advancement' => 'for_advance',
};
$audition->load('scoringGuide.subscores');
public function getAudition($id): Audition
{
return $this->getAuditions()->firstWhere('id', $id);
}
public function refreshCache(): void
{
Cache::forget($this->cacheKey);
$this->getAuditions();
}
public function clearCache(): void
{
Cache::forget($this->cacheKey);
}
public function getPublishedAuditions()
{
$cacheKey = 'publishedAuditions';
return Cache::remember(
$cacheKey,
now()->addHour(),
function () {
return Audition::with('flags')->orderBy('score_order')->get()->filter(fn ($audition) => $audition->hasFlag('seats_published'));
return $audition->scoringGuide->subscores->where($modeColumn, true)->sortBy($sortColumn);
});
}
public function getPublishedAdvancementAuditions()
public function getJudgesOLD(Audition $audition)
{
$cacheKey = 'publishedAdvancementAuditions';
return Cache::remember(
$cacheKey,
now()->addHour(),
function () {
return Audition::with('flags')->orderBy('score_order')->get()->filter(fn ($audition) => $audition->hasFlag('advancement_published'));
});
$cacheKey = 'auditionJudges-'.$audition->id;
return Cache::remember($cacheKey, 10, function () use ($audition) {
$this->validateAudition($audition);
return $audition->judges;
});
}
public function clearPublishedAuditionsCache(): void
public function getJudges(Audition $audition)
{
Cache::forget('publishedAuditions');
$cacheKey = 'auditionJudgeAssignments';
$assignments = Cache::remember($cacheKey, 60, function () {
$allAuditions = Audition::with('judges')->get();
$return = [];
foreach ($allAuditions as $audition) {
$return[$audition->id] = $audition->judges;
}
return $return;
});
return $assignments[$audition->id];
}
public function getSeatingLimits(Audition $audition)
{
$cacheKey = 'auditionSeatingLimits';
$allLimits = Cache::remember($cacheKey, 60, function () {
$lims = [];
$auditions = Audition::all();
$ensembles = Ensemble::orderBy('rank')->get();
foreach ($auditions as $audition) {
foreach ($ensembles as $ensemble) {
if ($ensemble->event_id !== $audition->event_id) {
continue;
}
$lims[$audition->id][$ensemble->id] = [
'ensemble' => $ensemble,
'limit' => 0,
];
}
}
$limits = SeatingLimit::all();
foreach ($limits as $limit) {
$lims[$limit->audition_id][$limit->ensemble_id] = [
'ensemble' => $ensembles->find($limit->ensemble_id),
'limit' =>$limit->maximum_accepted,
];
}
return $lims;
});
return $allLimits[$audition->id] ?? [];
}
protected function validateAudition($audition)
{
if (! $audition->exists()) {
throw new AuditionServiceException('Invalid audition provided');
}
}
protected function validateMode($mode)
{
if ($mode !== 'seating' && $mode !== 'advancement') {
throw new AuditionServiceException('Invalid mode requested. Mode must be seating or advancement');
}
}
protected function validateSort($sort)
{
if ($sort !== 'tiebreak' && $sort !== 'display') {
throw new AuditionServiceException('Invalid sort requested. Sort must be tiebreak or weight');
}
}
}

View File

@ -2,124 +2,134 @@
namespace App\Services;
use App\Exceptions\TabulationException;
use App\Models\Entry;
use App\Models\Event;
use App\Models\Student;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
class DoublerService
{
protected $doublersCacheKey = 'doublers';
protected EntryService $entryService;
protected $auditionService;
protected AuditionService $auditionService;
protected $tabulationService;
protected $seatingService;
/**
* Create a new class instance.
*/
public function __construct(AuditionService $auditionService, TabulationService $tabulationService, SeatingService $seatingService)
public function __construct(EntryService $entryService, AuditionService $auditionService)
{
$this->entryService = $entryService;
$this->auditionService = $auditionService;
$this->tabulationService = $tabulationService;
$this->seatingService = $seatingService;
}
/**
* Returns a collection of students that have more than one entry
* returns a collection of doublers for the event in the form of
* [studentId => [student=>student, entries=>[entries]]
*/
public function getDoublers(): \Illuminate\Database\Eloquent\Collection
public function doublersForEvent(Event $event, string $mode = 'seating')
{
// TODO creating or destroying an entry should refresh the doubler cache
// TODO needs to split by event so that a doubler may enter jazz and concert events for example
$doublers = Cache::remember($this->doublersCacheKey, 60, function () {
return Student::withCount(['entries' => function (Builder $query) {
$query->where('for_seating', true);
}])
->with(['entries' => function (Builder $query) {
$query->where('for_seating', true);
}])
->havingRaw('entries_count > ?', [1])
->get();
$cacheKey = 'event'.$event->id.'doublers-'.$mode;
return Cache::remember($cacheKey, 60, function () use ($event, $mode) {
return $this->findDoublersForEvent($event, $mode);
});
return $doublers;
}
public function refreshDoublerCache()
{
Cache::forget($this->doublersCacheKey);
$this->getDoublers();
}
/**
* Returns an array of information about each entry for a specific doubler. Info for each entry includes
* entryID
* auditionID
* auditionName
* rank => This student's rank in the given audition
* unscored => How many entries remain to be scored in this audition
* limits => acceptance limits for this audition
* status => accepted, declined, or undecided
*
* @param int $studentId The ID of the doubler
* @throws TabulationException
*/
public function getDoublerInfo($studentId): array
protected function findDoublersForEvent(Event $event, string $mode = 'seating'): array
{
$doubler = $this->getDoublers()->firstWhere('id', $studentId);
// TODO add scoped entry queries to the event model
$this->validateEvent($event);
$entries = $event->entries()->with('audition')->with('student')->get();
$entries = match ($mode) {
'seating' => $entries->filter(fn ($entry) => $entry->for_seating === 1),
'advancement' => $entries->filter(fn ($entry) => $entry->for_advance === 1),
};
// Split $doubler->entries into two arrays based on the result of hasFlag('declined')
$undecidedEntries = $doubler->entries->filter(function ($entry) {
return ! $entry->hasFlag('declined');
});
$acceptedEntry = null;
if ($undecidedEntries->count() == 1) {
$acceptedEntry = $undecidedEntries->first();
$grouped = $entries->groupBy('student_id');
// Filter out student groups with only one entry in the event
$grouped = $grouped->filter(fn ($s) => $s->count() > 1);
$doubler_array = [];
foreach ($grouped as $student_id => $entries) {
$doubler_array[$student_id] = [
'student_id' => $student_id,
'entries' => $entries,
];
}
// TODO can I rewrite this?
// When getting a doubler we need to know
// 1) What their entries are
// 2) For each audition they're entered in, what is their rank
// 3) For each audition they're entered in, how many entries are unscored
// 4) How many are accepted on that instrument
// 5) Status - accepted, declined or undecided
return $doubler_array;
}
$info = [];
public function simpleDoubleInfo(Entry $primaryEntry)
{
if (! isset($this->findDoublersForEvent($primaryEntry->audition->event)[$primaryEntry->student_id])) {
return false;
}
foreach ($doubler->entries as $entry) {
return $this->findDoublersForEvent($primaryEntry->audition->event)[$primaryEntry->student_id]['entries'];
}
public function entryDoublerData(Entry $primaryEntry)
{
if (! isset($this->doublersForEvent($primaryEntry->audition->event)[$primaryEntry->student_id])) {
return false;
}
$entries = $this->doublersForEvent($primaryEntry->audition->event)[$primaryEntry->student_id]['entries'];
$entryData = collect([]);
/** @var Collection $entries */
foreach ($entries as $entry) {
$status = 'undecided';
if ($entry->hasFlag('declined')) {
$status = 'declined';
} elseif ($entry === $acceptedEntry) {
$status = 'accepted';
} else {
$status = 'undecided';
}
$info[$entry->id] = [
'entryID' => $entry->id,
'auditionID' => $entry->audition_id,
'auditionName' => $this->auditionService->getAudition($entry->audition_id)->name,
'rank' => $this->tabulationService->entryRank($entry),
'unscored' => $this->tabulationService->remainingEntriesForAudition($entry->audition_id),
'limits' => $this->seatingService->getLimitForAudition($entry->audition_id),
'status' => $status,
];
$entry->audition = $this->auditionService->getAudition($entry->audition_id);
if ($entry->hasFlag('no_show')) {
$status = 'no_show';
}
return $info;
$lims = $this->auditionService->getSeatingLimits($entry->audition);
$limits = [];
foreach ($lims as $lim) {
$limits[] = [
'ensemble_name' => $lim['ensemble']->name,
'accepts' => $lim['limit'],
'ensemble' => $lim['ensemble'],
];
}
$entryData[$entry->id] = [
'entry' => $entry,
'audition' => $entry->audition,
'auditionName' => $entry->audition->name,
'status' => $status,
'rank' => $this->entryService->rankOfEntry('seating', $entry),
'unscored_entries' => $entry->audition->unscored_entries_count,
'seating_limits' => $limits,
];
}
// find out how many items in the collection $entryData have a status of 'undecided'
$undecided_count = $entryData->filter(fn ($entry) => $entry['status'] === 'undecided')->count();
// if $undecided_count is 1 set the item where status is 'undecided' to 'accepted'
if ($undecided_count === 1) {
$entryData->transform(function ($entry) {
if ($entry['status'] === 'undecided') {
$entry['status'] = 'accepted';
}
return $entry;
});
}
return $entryData;
}
/**
* Checks if a student is a doubler based on the given student ID
*
* @param int $studentId The ID of the student to check
* @return bool Returns true if the student is a doubler, false otherwise
* @throws TabulationException
*/
public function studentIsDoubler($studentId): bool
protected function validateEvent(Event $event)
{
return $this->getDoublers()->contains('id', $studentId);
if (! $event->exists) {
throw new TabulationException('Invalid event provided');
}
}
}

View File

@ -2,95 +2,22 @@
namespace App\Services;
use App\Actions\Tabulation\RankAuditionEntries;
use App\Models\Entry;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
class EntryService
{
protected $auditionCache;
/**
* Create a new class instance.
*/
public function __construct(AuditionService $auditionCache)
public function __construct()
{
$this->auditionCache = $auditionCache;
}
/**
* Return a collection of all entries for the provided auditionId along with the
* student.school for each entry.
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getEntriesForAudition($auditionId, $mode = 'seating')
{
// TODO this invokes a lot of lazy loading. Perhaps cache the data for all entries then draw from that for each audition
$cacheKey = 'audition'.$auditionId.'entries';
$entries = Cache::remember($cacheKey, 3600, function () use ($auditionId) {
return Entry::where('audition_id', $auditionId)
->with('student.school')
->get()
->keyBy('id');
});
switch ($mode) {
case 'seating':
return $entries->filter(function ($entry) {
return $entry->for_seating;
});
case 'advancement':
return $entries->filter(function ($entry) {
return $entry->for_advancement;
});
default:
return $entries;
}
}
/**
* Returns a collection of collections of entries, one collection for each audition.
* The outer collection is keyed by the audition ID. The included entries are
* with their student.school.
*/
public function getAllEntriesByAudition(): Collection
{
$auditions = $this->auditionCache->getAuditions();
$allEntries = [];
foreach ($auditions as $audition) {
$allEntries[$audition->id] = $this->getEntriesForAudition($audition->id);
}
return collect($allEntries);
}
public function getAllEntries()
{
$cacheKey = 'allEntries';
return Cache::remember($cacheKey, 5, function () {
return Entry::all();
});
}
public function clearEntryCacheForAudition($auditionId): void
{
$cacheKey = 'audition'.$auditionId.'entries';
Cache::forget($cacheKey);
Cache::forget('allEntries');
}
public function clearEntryCaches(): void
{
$auditions = $this->auditionCache->getAuditions();
foreach ($auditions as $audition) {
$this->clearEntryCacheForAudition($audition->id);
}
}
public function entryIsLate(Entry $entry): bool
public function isEntryLate(Entry $entry): bool
{
if ($entry->hasFlag('wave_late_fee')) {
return false;
@ -98,4 +25,25 @@ class EntryService
return $entry->created_at > $entry->audition->entry_deadline;
}
public function entryExists(Entry $entry): bool
{
$cacheKey = 'allEntryIds';
$allEntryIds = Cache::remember($cacheKey, 60, function () {
return Entry::pluck('id');
});
return $allEntryIds->contains($entry->id);
}
public function rankOfEntry(string $mode, Entry $entry)
{
$ranker = App::make(RankAuditionEntries::class);
$rankings = $ranker->rank($mode, $entry->audition);
$rankedEntry = $rankings->find($entry->id);
if (isset($rankedEntry->score_message)) {
return $rankedEntry->score_message;
}
return $rankings->find($entry->id)->rank ?? 'No Rank';
}
}

View File

@ -39,7 +39,7 @@ class InvoiceOneFeePerEntry implements InvoiceDataService
foreach ($school->students as $student) {
foreach ($entries[$student->id] ?? [] as $entry) {
$entryFee = $entry->audition->entry_fee / 100;
$lateFee = $this->entryService->entryIsLate($entry) ? auditionSetting('late_fee') / 100 : 0;
$lateFee = $this->entryService->isEntryLate($entry) ? auditionSetting('late_fee') / 100 : 0;
$invoiceData['lines'][] = [
'student_name' => $student->full_name(true),

View File

@ -3,216 +3,22 @@
namespace App\Services;
use App\Models\Entry;
use App\Models\ScoringGuide;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use App\Models\ScoreSheet;
use function array_unshift;
use App\Models\User;
class ScoreService
{
protected $auditionCache;
protected $entryCache;
/**
* Create a new class instance.
*/
public function __construct(AuditionService $auditionCache, EntryService $entryCache)
{
$this->auditionCache = $auditionCache;
$this->entryCache = $entryCache;
}
/**
* Cache all scoring guides
*/
public function getScoringGuides(): \Illuminate\Database\Eloquent\Collection
{
$cacheKey = 'scoringGuides';
return Cache::remember($cacheKey, 3600, fn () => ScoringGuide::with('subscores')->withCount('subscores')->get());
}
/**
* Retrieve a single scoring guide from the cache
*/
public function getScoringGuide($id): ScoringGuide
{
return $this->getScoringGuides()->find($id);
}
/**
* Clear the scoring guide cache
*/
public function clearScoringGuideCache(): void
{
Cache::forget('scoringGuides');
}
/**
* Returns an array where each key is an entry id and the value is the number
* of score sheets assigned to that entry.
*
* @return Collection
*/
public function entryScoreSheetCounts()
{
$cacheKey = 'entryScoreSheetCounts';
return Cache::remember($cacheKey, 10, function () {
// For each Entry get the number of ScoreSheets associated with it
$scoreSheetCountsByEntry = ScoreSheet::select('entry_id', DB::raw('count(*) as count'))
->groupBy('entry_id')
->get()
->pluck('count', 'entry_id');
$entryScoreSheetCounts = [];
$entries = $this->entryCache->getAllEntries();
foreach ($entries as $entry) {
$entryScoreSheetCounts[$entry->id] = $scoreSheetCountsByEntry[$entry->id] ?? 0;
}
return $entryScoreSheetCounts;
});
}
/**
* Get final scores array for the requested entry. The first element is the total score. The following elements are sums
* of each subscore in tiebreaker order
*
* @return array
*/
public function entryTotalScores(Entry $entry)
{
$cacheKey = 'entry'.$entry->id.'totalScores';
return Cache::remember($cacheKey, 3600, function () use ($entry) {
return $this->calculateFinalScoreArray($entry->audition->scoring_guide_id, $entry->scoreSheets);
});
}
/**
* Calculate and cache scores for all entries for the provided audition ID
*
* @return void
*/
public function calculateScoresForAudition($auditionId, $mode= 'seating')
{
static $alreadyChecked = [];
// if $auditionId is in the array $alreadyChecked return
if (in_array($auditionId, $alreadyChecked)) {
return;
}
$alreadyChecked[] = $auditionId;
$audition = $this->auditionCache->getAudition($auditionId);
$scoringGuideId = $audition->scoring_guide_id;
$entries = $this->entryCache->getEntriesForAudition($auditionId, $mode);
$entries->load('scoreSheets'); // TODO Cache this somehow, it's expensive and repetitive on the seating page
foreach ($entries as $entry) {
$cacheKey = 'entry'.$entry->id.'totalScores';
if (Cache::has($cacheKey)) {
continue;
}
$thisTotalScore = $this->calculateFinalScoreArray($scoringGuideId, $entry->scoreSheets);
Cache::put($cacheKey, $thisTotalScore, 3600);
}
}
public function clearScoreSheetCountCache()
{
$cacheKey = 'entryScoreSheetCounts';
Cache::forget($cacheKey);
}
public function clearEntryTotalScoresCache($entryId)
{
$cacheKey = 'entry'.$entryId.'totalScores';
Cache::forget($cacheKey);
}
public function clearAllCachedTotalScores()
{
foreach ($this->entryCache->getAllEntries() as $entry) {
$cacheKey = 'entry'.$entry->id.'totalScores';
Cache::forget($cacheKey);
}
}
/**
* Calculate final score using the provided scoring guide and score sheets. Returns an array of scores
* The first element is the total score. The following elements are the sum of each subscore
* in tiebreaker order.
*/
public function calculateFinalScoreArray($scoringGuideId, array|Collection $scoreSheets): array
public function __construct()
{
$sg = $this->getScoringGuide($scoringGuideId);
// TODO cache the scoring guides with their subscores
$subscores = $sg->subscores->sortBy('tiebreak_order');
$ignoredSubscores = []; // This will be subscores not used for seating
// Init final scores array
$finalScoresArray = [];
foreach ($subscores as $subscore) {
if (! $subscore->for_seating) { // Ignore scores that are not for seating
$ignoredSubscores[] = $subscore->id;
continue;
}
$finalScoresArray[$subscore->id] = 0;
}
foreach ($scoreSheets as $sheet) {
foreach ($sheet->subscores as $ss) {
if (in_array($ss['subscore_id'], $ignoredSubscores)) { // Ignore scores that are not for seating
continue;
}
$finalScoresArray[$ss['subscore_id']] += $ss['score'];
}
}
// calculate weighted final score
$totalScore = 0;
$totalWeight = 0;
foreach ($subscores as $subscore) {
if (in_array($subscore->id, $ignoredSubscores)) { // Ignore scores that are not for seating
continue;
}
$totalScore += ($finalScoresArray[$subscore->id] * $subscore->weight);
$totalWeight += $subscore->weight;
}
$totalScore = ($totalScore / $totalWeight);
array_unshift($finalScoresArray, $totalScore);
return $finalScoresArray;
}
/**
* Validate that the judge on the score sheet is actually assigned to judge
* then entry
*
* @return bool
*/
public function validateScoreSheet(ScoreSheet $sheet)
public function isEntryFullyScored(Entry $entry): bool
{
// TODO use this when calculating scores
$entry = $this->entryCache->getAllEntries()->find($sheet->entry_id);
$audition = $this->auditionCache->getAudition($entry->audition_id);
$validJudges = $audition->judges;
// send a laravel flash message with an error if the $sheet->user_id is not in the collection $validJudges
if (! $validJudges->contains('id', $sheet->user_id)) {
session()->flash('error', 'Entry ID '.$sheet->entry_id.' has an invalid score entered by '.$sheet->judge->full_name());
}
// check if $sheet->user_id is in the collection $validJudges, return false if not, true if it is
return $validJudges->contains('id', $sheet->user_id);
$requiredJudges = $entry->audition->judges()->count();
$scoreSheets = $entry->scoreSheets()->count();
return $requiredJudges === $scoreSheets;
}
}

View File

@ -1,90 +0,0 @@
<?php
namespace App\Services;
use App\Models\Event;
use App\Models\Seat;
use App\Models\SeatingLimit;
use Illuminate\Support\Facades\Cache;
class SeatingService
{
protected $limitsCacheKey = 'acceptanceLimits';
protected $tabulationService;
/**
* Create a new class instance.
*/
public function __construct(TabulationService $tabulationService)
{
$this->tabulationService = $tabulationService;
}
public function getAcceptanceLimits()
{
return Cache::remember($this->limitsCacheKey, now()->addDay(), function () {
$limits = SeatingLimit::with('ensemble')->get();
// Sort limits by ensemble->rank
$limits = $limits->sortBy(function ($limit) {
return $limit->ensemble->rank;
});
return $limits->groupBy('audition_id');
});
}
public function getLimitForAudition($auditionId)
{
if (! $this->getAcceptanceLimits()->has($auditionId)) {
return new \Illuminate\Database\Eloquent\Collection();
}
return $this->getAcceptanceLimits()[$auditionId];
}
public function refreshLimits(): void
{
Cache::forget($this->limitsCacheKey);
}
public function getSeatableEntries($auditionId)
{
$entries = $this->tabulationService->auditionEntries($auditionId);
return $entries->reject(function ($entry) {
return $entry->hasFlag('declined');
});
}
public function getSeatsForAudition($auditionId)
{
$cacheKey = 'audition'.$auditionId.'seats';
// TODO rework to pull entry info from cache
return Cache::remember($cacheKey, now()->addHour(), function () use ($auditionId) {
return Seat::with('entry.student.school')
->where('audition_id', $auditionId)
->orderBy('seat')
->get()
->groupBy('ensemble_id');
});
}
public function forgetSeatsForAudition($auditionId)
{
$cacheKey = 'audition'.$auditionId.'seats';
Cache::forget($cacheKey);
}
public function getEnsemblesForEvent($eventId)
{
static $eventEnsembles = [];
if (array_key_exists($eventId, $eventEnsembles)) {
return $eventEnsembles[$eventId];
}
$event = Event::find($eventId);
$eventEnsembles[$eventId] = $event->ensembles;
return $eventEnsembles[$eventId];
}
}

View File

@ -1,176 +0,0 @@
<?php
namespace App\Services;
use App\Models\Entry;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Session;
class TabulationService
{
protected AuditionService $auditionService;
protected EntryService $entryService;
protected ScoreService $scoreService;
/**
* Create a new class instance.
*/
public function __construct(
AuditionService $auditionService,
ScoreService $scoreService,
EntryService $entryService)
{
$this->auditionService = $auditionService;
$this->scoreService = $scoreService;
$this->entryService = $entryService;
}
/**
* Returns the rank of the entry in its audition
*
* @return mixed
*/
public function entryRank(Entry $entry)
{
return $this->auditionEntries($entry->audition_id)[$entry->id]->rank;
}
/**
* Returns a collection of entries including their calculated final_score_array and ranked
* based upon their scores.
*
* @return \Illuminate\Support\Collection|mixed
*/
public function auditionEntries(int $auditionId, $mode = 'seating')
{
static $cache = [];
if (isset($cache[$auditionId])) {
return $cache[$auditionId];
}
$audition = $this->auditionService->getAudition($auditionId);
$entries = $this->entryService->getEntriesForAudition($auditionId, $mode);
$this->scoreService->calculateScoresForAudition($auditionId);
// TODO will need to pass a mode to the above function to only use subscores for hte appropriate mode
foreach ($entries as $entry) {
$entry->final_score_array = $this->scoreService->entryTotalScores($entry);
$entry->scoring_complete = ($this->scoreService->entryScoreSheetCounts()[$entry->id] == $audition->judges_count);
}
// Sort the array $entries by the first element in the final_score_array on each entry, then by the second element in that array continuing through each element in the final_score_array for each entry
$entries = $entries->sort(function ($a, $b) {
for ($i = 0; $i < count($a->final_score_array); $i++) {
if ($a->final_score_array[$i] != $b->final_score_array[$i]) {
return $b->final_score_array[$i] > $a->final_score_array[$i] ? 1 : -1;
}
}
return 0;
});
//TODO verify this actually sorts by subscores correctly
// Assign a rank to each entry. In the case of a declined seat by a doubler, indicate as so and do not increment rank
$n = 1;
/** @var Entry $entry */
foreach ($entries as $entry) {
if (! $entry->hasFlag('declined') or $mode != 'seating') {
$entry->rank = $n;
$n++;
} else {
$entry->rank = $n.' - declined';
}
}
$cache[$auditionId] = $entries->keyBy('id');
return $entries->keyBy('id');
}
public function entryScoreSheetsAreValid(Entry $entry): bool
{
//TODO consider making this move the invalid score to another database for further investigation
$validJudges = $this->auditionService->getAudition($entry->audition_id)->judges;
foreach ($entry->scoreSheets as $sheet) {
if (! $validJudges->contains($sheet->user_id)) {
$invalidJudge = User::find($sheet->user_id);
Session::flash('error', 'Invalid scores for entry '.$entry->id.' exist from '.$invalidJudge->full_name());
return false;
}
}
return true;
}
/**
* Returns the number of un-scored entries for the audition with the given ID.
*
* @return mixed
*/
public function remainingEntriesForAudition($auditionId, $mode = 'seating')
{
$audition = $this->getAuditionsWithStatus($mode)[$auditionId];
switch ($mode) {
case 'seating':
return $audition->seating_entries_count - $audition->scored_entries_count;
case 'advancement':
return $audition->advancement_entries_count - $audition->scored_entries_count;
}
return $audition->entries_count - $audition->scored_entries_count;
}
/**
* Get the array of all auditions from the cache. For each one, set a property
* scored_entries_count that indicates the number of entries for that audition that
* have a number of score sheets equal to the number of judges for that audition.
*
* @return mixed
*/
public function getAuditionsWithStatus($mode = 'seating')
{
return Cache::remember('auditionsWithStatus', 30, function () use ($mode) {
// Retrieve auditions from the cache and load entry IDs
$auditions = $this->auditionService->getAuditions($mode);
// Iterate over the auditions and calculate the scored_entries_count
foreach ($auditions as $audition) {
$scored_entries_count = 0;
$entries_to_check = $this->entryService->getEntriesForAudition($audition->id);
switch ($mode) {
case 'seating':
$entries_to_check = $entries_to_check->filter(function ($entry) {
return $entry->for_seating;
});
$auditions = $auditions->filter(function ($audition) {
return $audition->for_seating;
});
break;
case 'advancement':
$entries_to_check = $entries_to_check->filter(function ($entry) {
return $entry->for_advancement;
});
$auditions = $auditions->filter(function ($audition) {
return $audition->for_advancement;
});
break;
}
foreach ($entries_to_check as $entry) {
if ($this->scoreService->entryScoreSheetCounts()[$entry->id] - $audition->judges_count == 0) {
$scored_entries_count++;
}
}
$audition->scored_entries_count = $scored_entries_count;
}
return $auditions;
});
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
class UserService
{
public function __construct()
{
}
public function userExists(User $user): bool
{
$cacheKey = 'allUserIds';
$allUserIds = Cache::remember($cacheKey, 60, function () {
return User::pluck('id');
});
return $allUserIds->contains($user->id);
}
}

View File

@ -25,6 +25,7 @@ class Settings
public static function get($key, $default = null)
{
$settings = Cache::get(self::$cacheKey, []);
return $settings[$key] ?? $default;
}

View File

@ -1,6 +1,11 @@
<?php
use App\Actions\Tabulation\EnterScore;
use App\Exceptions\ScoreEntryException;
use App\Models\Entry;
use App\Models\User;
use App\Settings;
use Illuminate\Support\Facades\App;
function tw_max_width_class_array(): array
{
@ -25,7 +30,16 @@ function tw_max_width_class_array(): array
return $return;
}
function auditionSetting($key) {
function auditionSetting($key)
{
return Settings::get($key);
}
/**
* @throws ScoreEntryException
*/
function enterScore(User $user, Entry $entry, array $scores): \App\Models\ScoreSheet
{
$scoreEntry = App::make(EnterScore::class);
return $scoreEntry($user, $entry, $scores);
}

View File

@ -2,6 +2,7 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\CalculateEntryScoreProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\InvoiceDataServiceProvider::class,
];

View File

@ -12,6 +12,7 @@
"laravel/pail": "^1.1",
"laravel/tinker": "^2.9",
"predis/predis": "^2.2",
"staudenmeir/belongs-to-through": "^2.5",
"symfony/http-client": "^7.1",
"symfony/mailgun-mailer": "^7.1"
},

67
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7aab57ef52f0152526434decd76ef1e1",
"content-hash": "cd8959ab9db27e12c6fce8cf87c52d90",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -3601,6 +3601,71 @@
],
"time": "2024-04-27T21:32:50+00:00"
},
{
"name": "staudenmeir/belongs-to-through",
"version": "v2.16",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/belongs-to-through.git",
"reference": "79667db6660fa0065b24415bab29a5f85a0128c7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/belongs-to-through/zipball/79667db6660fa0065b24415bab29a5f85a0128c7",
"reference": "79667db6660fa0065b24415bab29a5f85a0128c7",
"shasum": ""
},
"require": {
"illuminate/database": "^11.0",
"php": "^8.2"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.0",
"orchestra/testbench": "^9.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Staudenmeir\\BelongsToThrough\\IdeHelperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Znck\\Eloquent\\": "src/",
"Staudenmeir\\BelongsToThrough\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Rahul Kadyan",
"email": "hi@znck.me"
},
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Laravel Eloquent BelongsToThrough relationships",
"support": {
"issues": "https://github.com/staudenmeir/belongs-to-through/issues",
"source": "https://github.com/staudenmeir/belongs-to-through/tree/v2.16"
},
"funding": [
{
"url": "https://paypal.me/JonasStaudenmeir",
"type": "custom"
}
],
"time": "2024-03-09T09:53:11+00:00"
},
{
"name": "symfony/clock",
"version": "v7.0.7",

View File

@ -74,6 +74,7 @@ return [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
'prefix' => env('REDIS_CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
],
'dynamodb' => [

View File

@ -0,0 +1,84 @@
<?php
namespace Database\Seeders;
use App\Models\Audition;
use App\Models\Room;
use App\Models\ScoringGuide;
use App\Models\SubscoreDefinition;
use Illuminate\Database\Seeder;
class AuditionWithScoringGuideAndRoom extends Seeder
{
/**
* Run the database seeds.
*/
// TiebreakOrder: Tone, Sightreading, Etude 1, Etude 2, Scale
// Scale is only for seating, Tone is only for advancement
public function run(): void
{
$room = Room::factory()->create(['id' => 1000]);
$sg = ScoringGuide::factory()->create(['id' => 1000]);
SubscoreDefinition::create([
'id' => 1001,
'scoring_guide_id' => $sg->id,
'name' => 'Scale',
'max_score' => 100,
'weight' => 1,
'display_order' => 1,
'tiebreak_order' => 5,
'for_seating' => 1,
'for_advance' => 0,
]);
SubscoreDefinition::create([
'id' => 1002,
'scoring_guide_id' => $sg->id,
'name' => 'Etude 1',
'max_score' => 100,
'weight' => 2,
'display_order' => 2,
'tiebreak_order' => 3,
'for_seating' => 1,
'for_advance' => 1,
]);
SubscoreDefinition::create([
'id' => 1003,
'scoring_guide_id' => $sg->id,
'name' => 'Etude 2',
'max_score' => 100,
'weight' => 2,
'display_order' => 3,
'tiebreak_order' => 4,
'for_seating' => 1,
'for_advance' => 1,
]);
SubscoreDefinition::create([
'id' => 1004,
'scoring_guide_id' => $sg->id,
'name' => 'Sight Reading',
'max_score' => 100,
'weight' => 3,
'display_order' => 4,
'tiebreak_order' => 2,
'for_seating' => 1,
'for_advance' => 1,
]);
SubscoreDefinition::create([
'id' => 1005,
'scoring_guide_id' => $sg->id,
'name' => 'Tone',
'max_score' => 100,
'weight' => 1,
'display_order' => 5,
'tiebreak_order' => 1,
'for_seating' => 0,
'for_advance' => 1,
]);
Audition::factory()->create([
'id' => 1000,
'room_id' => $room->id,
'scoring_guide_id' => $sg->id,
'name' => 'Test Audition',
]);
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class RoomSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SchoolSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ScoreSheetSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ScoringGuideSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class StudentSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SubscoreDefinitionSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@ -22,7 +22,7 @@
<div class="w-56 shrink rounded-xl bg-white p-4 text-sm font-semibold leading-6 text-gray-900 shadow-lg ring-1 ring-gray-900/5">
<a href="{{ route('scores.chooseEntry') }}" class="block p-2 hover:text-indigo-600">Enter Scores</a>
<a href="{{ route('entry-flags.noShowSelect') }}" class="block p-2 hover:text-indigo-600">Enter No-Shows</a>
<a href="{{ route('tabulation.status') }}" class="block p-2 hover:text-indigo-600">Audition Status</a>
<a href="{{ route('seating.status') }}" class="block p-2 hover:text-indigo-600">Audition Status</a>
<a href="{{ route('advancement.status') }}" class="block p-2 hover:text-indigo-600">{{ auditionSetting('advanceTo') }} Status</a>
</div>

View File

@ -7,7 +7,6 @@
<x-table.th>Draw #</x-table.th>
<x-table.th>Student Name</x-table.th>
<x-table.th>Total Score</x-table.th>
<x-table.th>All Scores?</x-table.th>
<x-table.th>Votes</x-table.th>
@if($scoringComplete)
<x-table.th>Pass?</x-table.th>
@ -17,6 +16,13 @@
<x-table.body>
@foreach($entries as $entry)
@php
if ($entry->score_totals[0] < 0) {
$score = $entry->score_message;
} else {
$score = number_format($entry->score_totals[0] ?? 0,4);
}
@endphp
<tr>
<x-table.td>{{ $entry->rank }}</x-table.td>
<x-table.td>{{ $entry->id }}</x-table.td>
@ -25,12 +31,8 @@
<span>{{ $entry->student->full_name() }}</span>
<span class="text-xs text-gray-400">{{ $entry->student->school->name }}</span>
</x-table.td>
<x-table.td>{{ number_format($entry->final_score_array[0] ?? 0,4) }}</x-table.td>
<x-table.td>
@if($entry->scoring_complete)
<x-icons.checkmark color="green"/>
@endif
</x-table.td>
<x-table.td>{{ $score }}</x-table.td>
<x-table.td class="flex space-x-1">
@foreach($entry->advancementVotes as $vote)
<div x-data="{ showJudgeName: false, timeout: null }"

View File

@ -13,33 +13,27 @@
</tr>
</thead>
<x-table.body>
@foreach($auditions as $audition)
@php
$percent = 100;
if($audition->advancement_entries_count > 0) {
$percent = round(($audition->scored_entries_count / $audition->advancement_entries_count) * 100);
}
@endphp
@foreach($auditionData as $audition)
<tr class="hover:bg-gray-50">
<x-table.td class="">
<a href="{{ route('advancement.ranking', ['audition' => $audition->id]) }}">
<a href="{{ route('advancement.ranking', ['audition' => $audition['id']]) }}">
<div class="flex justify-between mb-1">
<span class="text-base font-medium text-indigo-700 dark:text-white">{{ $audition->name }}</span>
<span class="text-sm font-medium text-indigo-700 dark:text-white">{{ $audition->scored_entries_count }} / {{ $audition->advancement_entries_count }} Scored</span>
<span class="text-base font-medium text-indigo-700 dark:text-white">{{ $audition['name'] }}</span>
<span class="text-sm font-medium text-indigo-700 dark:text-white">{{ $audition['scored_entries_count'] }} / {{ $audition['entries_count'] }} Scored</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div class="bg-indigo-600 h-2.5 rounded-full" style="width: {{ $percent }}%"></div>
<div class="bg-indigo-600 h-2.5 rounded-full" style="width: {{ $audition['scored_percentage'] }}%"></div>
</div>
</a>
</x-table.td>
<td class="px-8">
@if( $audition->scored_entries_count == $audition->advancement_entries_count)
@if( $audition['scoring_complete'])
<x-icons.checkmark color="green"/>
@endif
</td>
<td class="px-8">
@if( $audition->hasFlag('advancement_published'))
@if( $audition['published'])
<x-icons.checkmark color="green"/>
@endif
</td>

View File

@ -1,14 +1,13 @@
@php($doublerEntryInfo = $doublerService->getDoublerInfo($entry->student_id))
@php($doublerButtonClasses = 'hidden rounded-md bg-white px-2.5 py-1.5 text-xs text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:block')
<ul role="list" class="">
@foreach($doublerEntryInfo as $info)
@php($isopen = $info['status'] == 'undecided')
@foreach($entry['doubleData'] as $double)
@php($isopen = $double['status'] == 'undecided')
<li class="pb-2 pt-0 px-0 my-2 rounded-xl border border-gray-200 max-w-xs" x-data="{ open: {{ $isopen ? 'true':'false' }} }">
<div class="flex items-start gap-x-3 bg-gray-100 px-3 py-2 rounded-t-xl" >
<p class="text-sm font-semibold leading-6 text-gray-900">
<a href="/tabulation/auditions/{{ $info['auditionID'] }}">
{{ $info['auditionName'] }} - {{ $info['status'] }}
<a href="{{ route('seating.audition', $double['audition']) }}">
{{ $double['auditionName'] }} - {{ $double['status'] }}
</a>
</p>
<div class="w-full flex justify-end" >
@ -21,26 +20,23 @@
<div class="grid grid-cols-4" x-show="open">
<div class="mt-1 px-3 text-xs leading-5 text-gray-500 col-span-3">
<ul>
<li class="flex items-center gap-x-2">
<p class="whitespace-nowrap">Ranked {{ $info['rank'] }}</p>
<svg viewBox="0 0 2 2" class="h-0.5 w-0.5 fill-current">
<circle cx="1" cy="1" r="1" />
</svg>
<p class="truncate">{{ $info['unscored'] }} Unscored</p>
<li class="">
<p class="whitespace-nowrap">Ranked {{ $double['rank'] }}</p>
<p class="truncate">{{ $double['unscored_entries'] }} Unscored</p>
</li>
@foreach($info['limits'] as $limit)
<li>{{ $limit->ensemble->name }} accepts {{ $limit->maximum_accepted }}</li>
@foreach($double['seating_limits'] as $limit)
<li>{{$limit['ensemble_name']}} accepts {{ $limit['accepts'] }}</li>
@endforeach
</ul>
</div>
<div class="flex flex-col justify-end gap-y-1 pt-1">
@if ($info['status'] === 'undecided')
<form method="POST" action="{{ route('doubler.accept',['entry'=>$info['entryID']]) }}">
@if ($double['status'] == 'undecided')
<form method="POST" action="{{ route('doubler.accept',['entry'=>$double['entry']]) }}">
@csrf
<button class="{{ $doublerButtonClasses }}">Accept</button>
</form>
<form method="POST" action="{{ route('doubler.decline',['entry'=>$info['entryID']]) }}">
<form method="POST" action="{{ route('doubler.decline',['entry'=>$double['entry']]) }}">
@csrf
<button class="{{ $doublerButtonClasses }}">Decline</button>
</form>

View File

@ -1,17 +1,20 @@
<x-card.card class="mb-3">
@php
@endphp
<x-card.heading>Seating</x-card.heading>
<div class="py-3 px-1">
<x-form.form method="POST" action="{{ route('tabulation.audition.seat',['audition' => $audition]) }}">
<x-form.form method="POST" action="{{ route('seating.audition',['audition' => $audition]) }}">
@csrf
@foreach($ensembleLimits as $ensembleLimit)
@foreach($rightPanel['data'] as $ensembleLimit)
@php
$value = $requestedEnsembleAccepts[$ensembleLimit->ensemble->id] ?? $ensembleLimit->maximum_accepted;
$value = $requestedEnsembleAccepts[$ensembleLimit['ensemble']->id] ?? $ensembleLimit['limit'];
// $value = $ensembleLimit['limit'];
@endphp
<x-form.field name="ensembleAccept[{{ $ensembleLimit->ensemble->id }}]"
label_text="{{ $ensembleLimit->ensemble->name }} - Max: {{ $ensembleLimit->maximum_accepted }}"
<x-form.field name="ensembleAccept[{{ $ensembleLimit['ensemble']->id }}]"
label_text="{{ $ensembleLimit['ensemble']->name }} - Max: {{ $ensembleLimit['limit'] }}"
type="number"
max="{{ $ensembleLimit->maximum_accepted }}"
max="{{ $ensembleLimit['limit'] }}"
value="{{ $value }}"
class="mb-3"/>
@endforeach

View File

@ -16,23 +16,35 @@
</thead>
<x-table.body>
@foreach($entries as $entry)
@foreach($entryData as $entry)
<tr>
<x-table.td>{{ $entry->rank }}</x-table.td>
<x-table.td>{{ $entry->id }}</x-table.td>
<x-table.td>{{ $entry->draw_number }}</x-table.td>
<x-table.td>{{ $entry['rank'] }}</x-table.td>
<x-table.td>{{ $entry['id'] }}</x-table.td>
<x-table.td>{{ $entry['drawNumber'] }}</x-table.td>
<x-table.td class="flex flex-col">
<span>{{ $entry->student->full_name() }}</span>
<span class="text-xs text-gray-400">{{ $entry->student->school->name }}</span>
<span>{{ $entry['studentName'] }}</span>
<span class="text-xs text-gray-400">{{ $entry['schoolName'] }}</span>
</x-table.td>
<x-table.td class="!py-0">
@if($doublerService->studentIsDoubler($entry->student_id))
@if($entry['doubleData'])
@include('tabulation.auditionSeating-doubler-block')
{{-- DOUBLER<br>--}}
{{-- @foreach($entry['doubleData'] as $double)--}}
{{-- ID: {{ $double['entryId'] }} - {{ $double['name'] }} - {{ $double['rank'] }}<br>--}}
{{-- Unscored Entries: {{ $double['unscored_in_audition'] }}<br>--}}
{{-- @foreach($double['limits'] as $limit)--}}
{{-- {{$limit['ensemble']->name}}: {{ $limit['limit'] }}<br>--}}
{{-- @endforeach--}}
{{-- <hr>--}}
{{-- @endforeach--}}
@endif
{{-- @if($doublerService->studentIsDoubler($entry->student_id))--}}
{{-- @include('tabulation.auditionSeating-doubler-block')--}}
{{-- @endif--}}
</x-table.td>
<x-table.td>{{ number_format($entry->final_score_array[0] ?? 0,4) }}</x-table.td>
<x-table.td>{{ $entry['totalScore'] }}</x-table.td>
<x-table.td>
@if($entry->scoring_complete)
@if($entry['fullyScored'])
<x-icons.checkmark color="green"/>
@endif
</x-table.td>

View File

@ -0,0 +1,2 @@
@include('tabulation.auditionSeating-fill-seats-form')
@include('tabulation.auditionSeating-show-proposed-seats')

View File

@ -2,19 +2,20 @@
$seatingProposal = [];
@endphp
@foreach($ensembleLimits as $ensembleLimit)
@foreach($rightPanel['data'] as $ensembleLimit)
<x-card.card class="mb-3">
<x-card.heading>{{ $ensembleLimit->ensemble->name }} - DRAFT</x-card.heading>
<x-card.heading>{{ $ensembleLimit['ensemble']->name }} - DRAFT</x-card.heading>
<x-card.list.body>
@php
$maxAccepted = $requestedEnsembleAccepts[$ensembleLimit->ensemble->id] ?? $ensembleLimit->maximum_accepted;
$maxAccepted = $requestedEnsembleAccepts[$ensembleLimit['ensemble']->id] ?? $ensembleLimit['limit'];
// $maxAccepted = $ensembleLimit['limit'];
@endphp
@for($n=1; $n <= $maxAccepted; $n++)
@php
$entry = $seatableEntries->shift();
if (is_null($entry)) continue;
$seatingProposal[] = [
'ensemble_id' => $ensembleLimit->ensemble->id,
'ensemble_id' => $ensembleLimit['ensemble']->id,
'audition_id' => $audition->id,
'seat' => $n,
'entry_id' => $entry->id,
@ -28,7 +29,7 @@
</x-card.list.body>
</x-card.card>
@endforeach
<form method="POST" action="{{ route('tabulation.seat.publish',['audition' => $audition]) }}">
<form method="POST" action="{{ route('seating.audition.publish',['audition' => $audition]) }}">
@csrf
<x-form.button type="submit">Seat and Publish</x-form.button>
</form>

View File

@ -1,12 +1,10 @@
@inject('seatingService','App\Services\SeatingService')
<x-card.card class="mb-3">
<x-card.heading>
Seats are Published
</x-card.heading>
<x-form.form method="POST"
action="{{ route('tabulation.seat.unpublish',['audition' => $audition->id]) }}"
action="{{ route('seating.audition.unpublish',['audition' => $audition->id]) }}"
class="mx-5 my-2">
<x-form.button type="submit">
Unpublish
@ -15,17 +13,34 @@
</x-card.card>
@foreach($ensembleLimits as $ensembleLimit)
<x-card.card class="mb-3">
@php
$ensembleSeats = $seatingService->getSeatsForAudition($audition->id)[$ensembleLimit->ensemble->id] ?? array();
$previousEnsemble = null;
@endphp
<x-card.card class="mb-3">
<x-card.heading>{{ $ensembleLimit->ensemble->name }}</x-card.heading>
@foreach($ensembleSeats as $seat)
@foreach($rightPanel['data'] as $seat)
@if($seat['ensemble'] !== $previousEnsemble)
<x-card.heading>{{$seat['ensemble']}}</x-card.heading>
@endif
<x-card.list.row class="!py-2">
{{ $seat->seat }} - {{ $seat->student->full_name() }}
{{ $seat['seat'] }} - {{ $seat['student_name'] }}
</x-card.list.row>
@endforeach
@php
</x-card.card>
@endforeach
$previousEnsemble = $seat['ensemble'];
@endphp
@endforeach
</x-card.card>
{{--@foreach($ensembleLimits as $ensembleLimit)--}}
{{-- @php--}}
{{-- $ensembleSeats = $seatingService->getSeatsForAudition($audition->id)[$ensembleLimit->ensemble->id] ?? array();--}}
{{-- @endphp--}}
{{-- <x-card.card class="mb-3">--}}
{{-- <x-card.heading>{{ $ensembleLimit->ensemble->name }}</x-card.heading>--}}
{{-- @foreach($ensembleSeats as $seat)--}}
{{-- <x-card.list.row class="!py-2">--}}
{{-- {{ $seat->seat }} - {{ $seat->student->full_name() }}--}}
{{-- </x-card.list.row>--}}
{{-- @endforeach--}}
{{-- </x-card.card>--}}
{{--@endforeach--}}

View File

@ -1,10 +1,10 @@
<x-card.card>
<x-card.heading>Unable to seat this audition</x-card.heading>
@if(! $scoringComplete)
@if(! $rightPanel['data']['allScored'])
<p class="text-sm px-5 py-2">The audition cannot be seated while it has unscored entries.</p>
@endif
@if(! $doublerComplete)
@if(! $rightPanel['data']['doublersResolved'])
<p class="text-sm px-5 py-2">The audition cannot be seated while it has unresolved doublers.</p>
@endif
</x-card.card>

View File

@ -10,20 +10,21 @@
@include('tabulation.auditionSeating-results-table')
</div>
<div class="ml-4">
@if($audition->hasFlag('seats_published'))
@include('tabulation.auditionSeating-show-published-seats')
@elseif(! $auditionComplete)
@include('tabulation.auditionSeating-unable-to-seat-card')
@else
@include('tabulation.auditionSeating-fill-seats-form')
@include('tabulation.auditionSeating-show-proposed-seats')
@endif
@include($rightPanel['view'])
</div>
{{-- <div class="ml-4">--}}
{{-- @if($audition->hasFlag('seats_published'))--}}
{{-- @include('tabulation.auditionSeating-show-published-seats')--}}
{{-- @elseif(! $auditionComplete)--}}
{{-- @include('tabulation.auditionSeating-unable-to-seat-card')--}}
{{-- @else--}}
{{-- @include('tabulation.auditionSeating-fill-seats-form')--}}
{{-- @include('tabulation.auditionSeating-show-proposed-seats')--}}
{{-- @endif--}}
{{-- </div>--}}
</div>
</x-layout.app>
{{--TODO deal with unlikely scenario of a doubler is entered for seating on some auditions but not others--}}

View File

@ -13,33 +13,26 @@
</tr>
</thead>
<x-table.body>
@foreach($auditions as $audition)
@php
$percent = 100;
if($audition->seating_entries_count > 0) {
$percent = round(($audition->scored_entries_count / $audition->seating_entries_count) * 100);
}
@endphp
@foreach($auditionData as $audition)
<tr class="hover:bg-gray-50">
<x-table.td class="">
<a href="/tabulation/auditions/{{ $audition->id }}">
<a href="{{ route('seating.audition', ['audition' => $audition['audition']]) }}">
<div class="flex justify-between mb-1">
<span class="text-base font-medium text-indigo-700 dark:text-white">{{ $audition->name }}</span>
<span class="text-sm font-medium text-indigo-700 dark:text-white">{{ $audition->scored_entries_count }} / {{ $audition->seating_entries_count }} Scored</span>
<span class="text-base font-medium text-indigo-700 dark:text-white">{{ $audition['name'] }}</span>
<span class="text-sm font-medium text-indigo-700 dark:text-white">{{ $audition['scoredEntriesCount'] }} / {{ $audition['totalEntriesCount'] }} Scored</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
<div class="bg-indigo-600 h-2.5 rounded-full" style="width: {{ $percent }}%"></div>
<div class="bg-indigo-600 h-2.5 rounded-full" style="width: {{ $audition['scoredPercentage'] }}%"></div>
</div>
</a>
</x-table.td>
<td class="px-8">
@if( $audition->scored_entries_count == $audition->seating_entries_count)
@if( $audition['scoringComplete'])
<x-icons.checkmark color="green"/>
@endif
</td>
<td class="px-8">
@if( $audition->hasFlag('seats_published'))
@if( $audition['seatsPublished'])
<x-icons.checkmark color="green"/>
@endif
</td>

View File

@ -1,16 +0,0 @@
@php use App\Enums\AuditionFlags;use App\Models\Audition;use App\Models\AuditionFlag; @endphp
@php @endphp
@inject('scoreservice','App\Services\ScoreService');
@inject('auditionService','App\Services\AuditionService');
@inject('entryService','App\Services\EntryService')
@inject('seatingService','App\Services\SeatingService')
@inject('drawService', 'App\Services\DrawService')
<x-layout.app>
<x-slot:page_title>Test Page</x-slot:page_title>
@php
$audition = Audition::first();
$audition->addFlag('drawn');
@endphp
</x-layout.app>

View File

@ -1,37 +1,46 @@
<?php
// Tabulation Routes
use App\Http\Controllers\Tabulation\AdvancementController;
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;
Route::middleware(['auth', 'verified', CheckIfCanTab::class])->group(function () {
// Score Management
Route::prefix('scores/')->controller(\App\Http\Controllers\Tabulation\ScoreController::class)->group(function () {
Route::prefix('scores/')->controller(ScoreController::class)->group(function () {
Route::get('/choose_entry', 'chooseEntry')->name('scores.chooseEntry');
Route::get('/entry', 'entryScoreSheet')->name('scores.entryScoreSheet');
Route::post('/entry/{entry}', 'saveEntryScoreSheet')->name('scores.saveEntryScoreSheet');
Route::delete('/{score}', [\App\Http\Controllers\Tabulation\ScoreController::class, 'destroyScore'])->name('scores.destroy');
Route::delete('/{score}',
[ScoreController::class, 'destroyScore'])->name('scores.destroy');
});
// Entry Flagging
Route::prefix('entry-flags/')->controller(\App\Http\Controllers\Tabulation\EntryFlagController::class)->group(function () {
Route::prefix('entry-flags/')->controller(EntryFlagController::class)->group(function () {
Route::get('/choose_no_show', 'noShowSelect')->name('entry-flags.noShowSelect');
Route::get('/propose-no-show', 'noShowConfirm')->name('entry-flags.confirmNoShow');
Route::post('/no-show/{entry}', 'enterNoShow')->name('entry-flags.enterNoShow');
Route::delete('/no-show/{entry}', 'undoNoShow')->name('entry-flags.undoNoShow');
});
// Generic Tabulation Routes
Route::prefix('tabulation/')->controller(\App\Http\Controllers\Tabulation\TabulationController::class)->group(function () {
Route::get('/status', 'status')->name('tabulation.status');
Route::match(['get', 'post'], '/auditions/{audition}', 'auditionSeating')->name('tabulation.audition.seat');
Route::post('/auditions/{audition}/publish-seats', 'publishSeats')->name('tabulation.seat.publish');
Route::post('/auditions/{audition}/unpublish-seats', 'unpublishSeats')->name('tabulation.seat.unpublish');
// Seating Routes
Route::prefix('seating/')->group(function () {
Route::get('/', SeatingStatusController::class)->name('seating.status');
Route::match(['get', 'post'], '/{audition}', SeatAuditionFormController::class)->name('seating.audition');
Route::post('/{audition}/publish', [SeatingPublicationController::class, 'publishSeats'])->name('seating.audition.publish');
Route::post('/{audition}/unpublish', [SeatingPublicationController::class, 'unpublishSeats'])->name('seating.audition.unpublish');
});
// Advancement Routes
Route::prefix('advancement/')->controller(\App\Http\Controllers\Tabulation\AdvancementController::class)->group(function () {
Route::prefix('advancement/')->controller(AdvancementController::class)->group(function () {
Route::get('/status', 'status')->name('advancement.status');
Route::get('/{audition}', 'ranking')->name('advancement.ranking');
Route::post('/{audition}', 'setAuditionPassers')->name('advancement.setAuditionPassers');

View File

@ -0,0 +1,141 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use App\Actions\Tabulation\AllJudgesCount;
use App\Exceptions\TabulationException;
use App\Models\Entry;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
it('throws an exception if mode is not seating or advancement', function () {
#$calculator = new AllJudgesCount();
$calculator = App::make(AllJudgesCount::class);
$calculator->calculate('WRONG', Entry::factory()->create());
})->throws(TabulationException::class, 'Mode must be seating or advancement');
it('throws an exception if entry is not valid', function () {
// Arrange
#$calculator = new AllJudgesCount();
$calculator = App::make(AllJudgesCount::class);
// Act
$calculator->calculate('seating', Entry::factory()->make());
// Assert
})->throws(TabulationException::class, 'Invalid entry specified');
it('throws an exception if entry is missing judge scores', function () {
// Arrange
loadSampleAudition();
$judge1 = User::factory()->create();
$judge2 = User::factory()->create();
Room::find(1000)->addJudge($judge1);
Room::find(1000)->addJudge($judge2);
$entry = Entry::factory()->create(['audition_id' => 1000]);
$scores = [
1001 => 50,
1002 => 60,
1003 => 70,
1004 => 80,
1005 => 90,
];
#$calculator = new AllJudgesCount();
$calculator = App::make(AllJudgesCount::class);
enterScore($judge1, $entry, $scores);
// Act
$calculator->calculate('seating', $entry);
// Assert
})->throws(TabulationException::class, 'Not all score sheets are in');
it('throws an exception if a score exists from an invalid judge', function () {
// Arrange
loadSampleAudition();
$judge1 = User::factory()->create();
$judge2 = User::factory()->create();
$judge3 = User::factory()->create();
Room::find(1000)->addJudge($judge1);
Room::find(1000)->addJudge($judge2);
$entry = Entry::factory()->create(['audition_id' => 1000]);
$scores = [
1001 => 50,
1002 => 60,
1003 => 70,
1004 => 80,
1005 => 90,
];
#$calculator = new AllJudgesCount();
$calculator = App::make(AllJudgesCount::class);
enterScore($judge1, $entry, $scores);
$scoreSheetToSpoof = enterScore($judge2, $entry, $scores);
$scoreSheetToSpoof->update(['user_id' => $judge3->id]);
// Act
$calculator->calculate('seating', $entry);
// Assert
})->throws(TabulationException::class, 'Score exists from a judge not assigned to this audition');
it('correctly calculates scores for seating', function () {
// Arrange
loadSampleAudition();
$judge1 = User::factory()->create();
$judge2 = User::factory()->create();
Room::find(1000)->addJudge($judge1);
Room::find(1000)->addJudge($judge2);
$entry = Entry::factory()->create(['audition_id' => 1000]);
$scores = [
1001 => 50,
1002 => 60,
1003 => 70,
1004 => 80,
1005 => 90,
];
$scores2 = [
1001 => 55,
1002 => 65,
1003 => 75,
1004 => 85,
1005 => 95,
];
#$calculator = new AllJudgesCount();
$calculator = App::make(AllJudgesCount::class);
enterScore($judge1, $entry, $scores);
enterScore($judge2, $entry, $scores2);
// Act
$finalScores = $calculator->calculate('seating', $entry);
// Assert
$expectedScores = [142.5, 165, 125, 145, 105];
expect($finalScores)->toBe($expectedScores);
});
it('correctly calculates scores for advancement', function () {
// Arrange
loadSampleAudition();
$judge1 = User::factory()->create();
$judge2 = User::factory()->create();
Room::find(1000)->addJudge($judge1);
Room::find(1000)->addJudge($judge2);
$entry = Entry::factory()->create(['audition_id' => 1000]);
$scores = [
1001 => 50,
1002 => 60,
1003 => 70,
1004 => 80,
1005 => 90,
];
$scores2 = [
1001 => 55,
1002 => 65,
1003 => 75,
1004 => 85,
1005 => 95,
];
$calculator = App::make(AllJudgesCount::class);
enterScore($judge1, $entry, $scores);
enterScore($judge2, $entry, $scores2);
// Act
$finalScores = $calculator->calculate('advancement', $entry);
// Assert
$expectedScores = [152.5, 185, 165, 125, 145];
expect($finalScores)->toBe($expectedScores);
});

View File

@ -0,0 +1,77 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use App\Actions\Tabulation\CalculateScoreSheetTotal;
use App\Exceptions\TabulationException;
use App\Models\Entry;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
it('throws an exception if an invalid mode is called for', function () {
$calculator = app(CalculateScoreSheetTotal::class);
$calculator('anything', Entry::factory()->create(), User::factory()->create());
})->throws(TabulationException::class, 'Invalid mode requested. Mode must be seating or advancement');
it('throws an exception if an invalid judge is provided', function () {
$calculator = app(CalculateScoreSheetTotal::class);
$calculator('seating', Entry::factory()->create(), User::factory()->make());
})->throws(TabulationException::class, 'Invalid judge provided');
it('throws an exception if an invalid entry is provided', function () {
$calculator = app(CalculateScoreSheetTotal::class);
$calculator('advancement', Entry::factory()->make(), User::factory()->create());
})->throws(TabulationException::class, 'Invalid entry provided');
it('throws an exception if the specified judge has not scored the entry', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
Room::find(1000)->addJudge($judge);
$entry = Entry::factory()->create(['audition_id' => 1000]);
Artisan::call('cache:clear');
$calculator = app(CalculateScoreSheetTotal::class);
// Act
$calculator('seating', $entry, $judge);
//Assert
})->throws(TabulationException::class, 'No score sheet by that judge for that entry');
it('correctly calculates final score for seating', function () {
loadSampleAudition();
$judge = User::factory()->create();
Room::find(1000)->addJudge($judge);
$entry = Entry::factory()->create(['audition_id' => 1000]);
$scores = [
1001 => 50,
1002 => 60,
1003 => 70,
1004 => 80,
1005 => 90,
];
enterScore($judge, $entry, $scores);
$calculator = app(CalculateScoreSheetTotal::class);
$total = $calculator('seating', $entry, $judge);
expect($total[0])->toBe(68.75);
$expectedArray = [68.75, 80, 60, 70, 50];
expect($total)->toBe($expectedArray);
});
it('correctly calculates final score for advancement', function () {
loadSampleAudition();
$judge = User::factory()->create();
Room::find(1000)->addJudge($judge);
$entry = Entry::factory()->create(['audition_id' => 1000]);
$scores = [
1001 => 50,
1002 => 60,
1003 => 70,
1004 => 80,
1005 => 90,
];
enterScore($judge, $entry, $scores);
$calculator = app(CalculateScoreSheetTotal::class);
$total = $calculator('advancement', $entry, $judge);
expect($total[0])->toBe(73.75);
$expectedArray = [73.75, 90, 80, 60, 70];
expect($total)->toBe($expectedArray);
});

View File

@ -0,0 +1,187 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use App\Actions\Tabulation\EnterScore;
use App\Exceptions\ScoreEntryException;
use App\Models\Entry;
use App\Models\Room;
use App\Models\ScoreSheet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
beforeEach(function () {
#$this->scoreEntry = new EnterScore();
$this->scoreEntry = App::make(EnterScore::class);
});
test('throws an exception if the user does not exist', function () {
$user = User::factory()->make();
$entry = Entry::factory()->create();
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'User does not exist');
test('throws an exception if the entry does not exist', function () {
$user = User::factory()->create();
$entry = Entry::factory()->make();
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'Entry does not exist');
it('throws an exception if the seats for the entries audition are published', function () {
// Arrange
$user = User::factory()->create();
$entry = Entry::factory()->create();
$entry->audition->addFlag('seats_published');
// Act & Assert
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'Cannot score an entry in an audition with published seats');
it('throws an exception if the advancement for the entries audition is published', function () {
// Arrange
$user = User::factory()->create();
$entry = Entry::factory()->create();
$entry->audition->addFlag('advancement_published');
// Act & Assert
enterScore($user, $entry, []);
})->throws(ScoreEntryException::class, 'Cannot score an entry in an audition with published advancement');
it('throws an exception if too many scores are submitted', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
// Act & Assert
enterScore($judge, $entry, [1, 2, 5, 3, 2, 1]);
})->throws(ScoreEntryException::class, 'Invalid number of scores');
it('throws an exception if too few scores are submitted', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
// Act & Assert
enterScore($judge, $entry, [1, 2, 5, 3]);
})->throws(ScoreEntryException::class, 'Invalid number of scores');
it('throws an exception if the user is not assigned to judge the entry', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
// Act & Assert
enterScore($judge, $entry, [1, 2, 5, 3, 7]);
})->throws(ScoreEntryException::class, 'This judge is not assigned to judge this entry');
it('throws an exception if an invalid subscore is provided', function () {
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1006 => 88,
];
enterScore($judge, $entry, $scores);
})->throws(ScoreEntryException::class, 'Invalid Score Submission');
it('throws an exception if a submitted subscore exceeds the maximum allowed', function () {
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 101,
];
enterScore($judge, $entry, $scores);
})->throws(ScoreEntryException::class, 'Supplied subscore exceeds maximum allowed');
it('removes an existing no_show flag from the entry if one exists', function () {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
$entry->addFlag('no_show');
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 98,
];
// Act
enterScore($judge, $entry, $scores);
// Assert
expect($entry->hasFlag('no_show'))->toBeFalse();
});
it('saves the score with a properly formatted subscore object', function () {
// Arrange
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
$entry->addFlag('no_show');
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 98,
];
$desiredReturn = [
1001 => [
'score' => 98,
'subscore_id' => 1001,
'subscore_name' => 'Scale',
],
1002 => [
'score' => 90,
'subscore_id' => 1002,
'subscore_name' => 'Etude 1',
],
1003 => [
'score' => 87,
'subscore_id' => 1003,
'subscore_name' => 'Etude 2',
],
1004 => [
'score' => 78,
'subscore_id' => 1004,
'subscore_name' => 'Sight Reading',
],
1005 => [
'score' => 98,
'subscore_id' => 1005,
'subscore_name' => 'Tone',
],
];
// Act
$newScore = enterScore($judge, $entry, $scores);
// Assert
$checkScoreSheet = ScoreSheet::find($newScore->id);
expect($checkScoreSheet->exists())->toBeTrue()
->and($checkScoreSheet->subscores)->toBe($desiredReturn);
});
it('throws an exception of the entry already has a score by the judge', function() {
// Arrange
loadSampleAudition();
$judge = User::factory()->create();
$entry = Entry::factory()->create(['audition_id' => 1000]);
$entry->addFlag('no_show');
Room::find(1000)->addJudge($judge);
$scores = [
1001 => 98,
1002 => 90,
1003 => 87,
1004 => 78,
1005 => 98,
];
// Act
enterScore($judge, $entry, $scores);
// Assert
enterScore($judge, $entry, $scores);
})->throws(ScoreEntryException::class, 'That judge has already entered scores for that entry');

View File

@ -0,0 +1,68 @@
<?php
use App\Actions\Tabulation\AllJudgesCount;
use App\Actions\Tabulation\RankAuditionEntries;
use App\Exceptions\TabulationException;
use App\Models\Audition;
use App\Models\Entry;
use App\Models\Room;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
it('throws an exception if an invalid mode is specified', function () {
#$ranker = new RankAuditionEntries(new AllJudgesCount());
$ranker = App::make(RankAuditionEntries::class);
$ranker->rank('wrong', Audition::factory()->create());
})->throws(TabulationException::class, 'Mode must be seating or advancement');
it('throws an exception if an invalid audition is provided', function () {
// Arrange
#$ranker = new RankAuditionEntries(new AllJudgesCount());
$ranker = App::make(RankAuditionEntries::class);
// Act & Assert
$ranker->rank('seating', Audition::factory()->make());
})->throws(TabulationException::class, 'Invalid audition provided');
it('includes all entries of the given mode in the return', function () {
$audition = Audition::factory()->create();
$entries = Entry::factory()->seatingOnly()->count(10)->create(['audition_id' => $audition->id]);
$otherEntries = Entry::factory()->advanceOnly()->count(10)->create(['audition_id' => $audition->id]);
#$ranker = new RankAuditionEntries(new AllJudgesCount());
$ranker = App::make(RankAuditionEntries::class);
// Act
$return = $ranker->rank('seating', $audition);
// Assert
foreach ($entries as $entry) {
expect($return->pluck('id')->toArray())->toContain($entry->id);
}
foreach ($otherEntries as $entry) {
expect($return->pluck('id')->toArray())->not()->toContain($entry->id);
}
});
it('places entries in the proper order', function () {
// Arrange
Artisan::call('cache:clear');
loadSampleAudition();
$judge = User::factory()->create();
Room::find(1000)->addJudge($judge);
$entries = Entry::factory()->count(5)->create(['audition_id' => 1000]);
$scoreArray1 = [1001 => 90, 1002 => 90, 1003 => 90, 1004 => 90, 1005 => 90];
$scoreArray2 = [1001 => 60, 1002 => 60, 1003 => 60, 1004 => 60, 1005 => 60];
$scoreArray3 = [1001 => 80, 1002 => 80, 1003 => 80, 1004 => 80, 1005 => 80];
$scoreArray4 = [1001 => 100, 1002 => 100, 1003 => 100, 1004 => 100, 1005 => 100];
$scoreArray5 = [1001 => 70, 1002 => 70, 1003 => 70, 1004 => 70, 1005 => 70];
enterScore($judge, $entries[0], $scoreArray1);
enterScore($judge, $entries[1], $scoreArray2);
enterScore($judge, $entries[2], $scoreArray3);
enterScore($judge, $entries[3], $scoreArray4);
enterScore($judge, $entries[4], $scoreArray5);
Artisan::call('cache:clear');
$ranker = App::make(RankAuditionEntries::class);
$expectedOrder = [4, 1, 3, 5, 2];
// Act
$return = $ranker->rank('seating', Audition::find(1000));
// Assert
expect($return->pluck('id')->toArray())->toBe($expectedOrder);
});

View File

@ -169,6 +169,7 @@ it('has a forAdvancement scope that only returns those entries entered for advan
Entry::factory()->count(10)->create(['for_seating' => true, 'for_advancement' => true]);
Entry::factory()->count(5)->create(['for_seating' => false, 'for_advancement' => true]);
// Act & Assert
expect(Entry::forAdvancement()->count())->toBe(16)
->and(Entry::forAdvancement()->get()->first())->toBeInstanceOf(Entry::class)
->and(Entry::forAdvancement()->get()->first()->student->first_name)->toBe('Advance Only');

View File

@ -4,9 +4,12 @@ use App\Models\Entry;
use App\Models\School;
use App\Models\Student;
use App\Models\User;
use App\Services\EntryService;
use App\Services\Invoice\InvoiceOneFeePerEntry;
use App\Settings;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
@ -62,7 +65,8 @@ it('has a new school link', function () {
});
it('shows school data', function () {
// Arrange
$invoiceDataService = new App\Services\Invoice\InvoiceOneFeePerEntry(new App\Services\EntryService(new App\Services\AuditionService()));
#$invoiceDataService = new App\Services\Invoice\InvoiceOneFeePerEntry(new App\Services\EntryService(new App\Services\AuditionService()));
$invoiceDataService = App::make(InvoiceOneFeePerEntry::class);
Settings::set('school_fees', 1000);
Settings::set('late_fee', 2500);
actingAs($this->adminUser);

View File

@ -0,0 +1,41 @@
<?php
use App\Models\Audition;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
uses(RefreshDatabase::class);
it('does not answer a regular user or guest', function () {
get(route('advancement.status'))
->assertRedirect(route('home'));
actingAs(User::factory()->create());
get(route('advancement.status'))
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not authorized to perform this action');
});
it('responds to an admin or tab user', function () {
actAsAdmin();
get(route('advancement.status'))
->assertOk();
actAsTab();
get(route('advancement.status'))
->assertOk();
});
it('includes advancement auditions', function () {
$audition = Audition::factory()->create();
actAsAdmin();
get(route('advancement.status'))
->assertOk()
->assertSee($audition->name);
});
it('does not include auditions not for advancement', function () {
$audition = Audition::factory()->seatingOnly()->create();
actAsAdmin();
get(route('advancement.status'))
->assertOk()
->assertDontSee($audition->name);
});

View File

@ -48,7 +48,7 @@ it('has appropriate students in JS array for select', function () {
'id: ',
$student->id,
'name: ',
$student->full_name(true),
e($student->full_name(true)),
], false); // The false parameter makes the assertion case-sensitive and allows for HTML tags
});

View File

@ -0,0 +1,73 @@
<?php
use App\Models\Audition;
use App\Models\Entry;
use App\Models\Student;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\get;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->audition = Audition::factory()->create();
$this->r = route('seating.audition', $this->audition);
});
it('denies access to a guest', function () {
get($this->r)
->assertRedirect(route('home'));
});
it('denies access to a normal user', function () {
actAsNormal();
get($this->r)
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not authorized to perform this action');
});
it('grants access to admin', function () {
// Arrange
actAsAdmin();
// Act & Assert
get($this->r)->assertOk();
});
it('grants access to tabulators', function () {
// Arrange
actAsTab();
// Act & Assert
get($this->r)->assertOk();
});
// TODO make tests with varied information
it('returns the audition object and an array of info on each entry', function () {
// Arrange
$entry = Entry::factory()->create(['audition_id' => $this->audition->id]);
actAsAdmin();
// Act
$response = get($this->r);
$response
->assertOk()
->assertViewHas('audition', $this->audition);
$viewData = $response->viewData('entryData');
expect($viewData[0]['rank'])->toBe(1);
expect($viewData[0]['id'])->toBe($entry->id);
expect($viewData[0]['studentName'])->toBe($entry->student->full_name());
expect($viewData[0]['schoolName'])->toBe($entry->student->school->name);
expect($viewData[0]['drawNumber'])->toBe($entry->draw_number);
expect($viewData[0]['totalScore'])->toBe('No Score');
expect($viewData[0]['fullyScored'])->toBeFalse();
});
it('identifies a doubler', function () {
// Arrange
$audition1 = Audition::factory()->create(['event_id' => $this->audition->event_id]);
$audition2 = Audition::factory()->create(['event_id' => $this->audition->event_id]);
$student = Student::factory()->create();
Entry::factory()->create(['audition_id' => $audition1->id, 'student_id' => $student->id]);
Entry::factory()->create(['audition_id' => $audition2->id, 'student_id' => $student->id]);
Entry::factory()->create(['audition_id' => $this->audition->id, 'student_id' => $student->id]);
actAsAdmin();
// Act & Assert
$response = get($this->r);
$response->assertOk();
$viewData = $response->viewData('entryData');
expect($viewData[0]['doubleData'])->toBeTruthy();
});

View File

@ -0,0 +1,162 @@
<?php
use App\Models\Audition;
use App\Models\Entry;
use App\Models\ScoreSheet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\get;
uses(RefreshDatabase::class);
it('will not answer to guest or normal user', function () {
get(route('seating.status'))
->assertRedirect(route('home'));
actAsNormal();
get(route('seating.status'))
->assertRedirect(route('dashboard'))
->assertSessionHas('error', 'You are not authorized to perform this action');
});
it('responds to an admin', function () {
// Arrange
actAsAdmin();
// Act & Assert
get(route('seating.status'))
->assertOk();
});
it('responds to a tabulator', function () {
// Arrange
actAsTab();
// Act & Assert
get(route('seating.status'))
->assertOk();
});
it('sends the view a collection of audition data that includes data needed by the view', function () {
// Arrange
$auditions = Audition::factory()->count(5)->create();
actAsAdmin();
// Act
$response = get(route('seating.status'));
// Assert
foreach ($auditions as $audition) {
$response->assertOk()
->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['id'] === $audition->id;
});
$response->assertOk()
->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['name'] === $audition->name;
});
}
});
it('has correct count info for an audition with 5 entries none scored', function () {
$audition = Audition::factory()->create();
Entry::factory()->count(5)->create(['audition_id' => $audition->id]);
actAsAdmin();
$response = get(route('seating.status'));
$response->assertOk();
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoredEntriesCount'] === 0;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['totalEntriesCount'] === 5;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoredPercentage'] === 0;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoringComplete'] === false;
});
});
it('has correct count info for an audition with 8 entries 2 scored', function () {
$judge = User::factory()->create();
$audition = Audition::factory()->create();
$entries = Entry::factory()->count(2)->create(['audition_id' => $audition->id]);
$entries->each(fn ($entry) => ScoreSheet::create([
'user_id' => $judge->id,
'entry_id' => $entry->id,
'subscores' => 7,
]));
Entry::factory()->count(6)->create(['audition_id' => $audition->id]);
actAsAdmin();
$response = get(route('seating.status'));
$response->assertOk();
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoredEntriesCount'] === 2;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['totalEntriesCount'] === 8;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoredPercentage'] == 25;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoringComplete'] === false;
});
});
it('has correct count info for an audition with 1 entries 1 scored', function () {
$judge = User::factory()->create();
$audition = Audition::factory()->create();
$entry = Entry::factory()->create(['audition_id' => $audition->id]);
ScoreSheet::create([
'user_id' => $judge->id,
'entry_id' => $entry->id,
'subscores' => 3,
]);
actAsAdmin();
$response = get(route('seating.status'));
$response->assertOk();
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoredEntriesCount'] === 1;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['totalEntriesCount'] === 1;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoredPercentage'] == 100;
});
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['scoringComplete'] === true;
});
});
it('correctly shows a flag when the audition is flagged as seated', function () {
$audition = Audition::factory()->create();
actAsAdmin();
$response = get(route('seating.status'));
$response->assertOk();
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['seatsPublished'] === false;
});
$audition->addFlag('seats_published');
$response = get(route('seating.status'));
$response->assertOk();
$response->assertViewHas('auditionData', function ($viewAuditionData) use ($audition) {
return $viewAuditionData[$audition->id]['seatsPublished'] === true;
});
});
it('shows seating auditions', function() {
$audition = Audition::factory()->create();
actAsAdmin();
get(route('seating.status'))
->assertOk()
->assertSee($audition->name);
});
it('does not show advancement only auditions', function() {
$audition = Audition::factory()->advancementOnly()->create();
actAsAdmin();
get(route('seating.status'))
->assertOk()
->assertDontSee($audition->name);
});

View File

@ -4,6 +4,7 @@ use App\Models\Audition;
use App\Models\Entry;
use App\Services\DrawService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
@ -77,7 +78,8 @@ it('sets the draw_number column on each entry in the audition based on the rando
// Arrange
$audition = Audition::factory()->hasEntries(10)->create();
Entry::all()->each(fn ($entry) => expect($entry->draw_number)->toBeNull());
$drawService = new DrawService();
#$drawService = new DrawService();
$drawService = App::make(DrawService::class);
$drawService->runOneDraw($audition);
// Act & Assert
Entry::all()->each(fn ($entry) => expect($entry->draw_number)->not()->toBeNull());
@ -86,7 +88,8 @@ it('only sets draw numbers in the specified audition', function () {
// Arrange
$audition = Audition::factory()->hasEntries(10)->create();
$bonusEntry = Entry::factory()->create();
$drawService = new DrawService();
#$drawService = new DrawService();
$drawService = App::make(DrawService::class);
$drawService->runOneDraw($audition);
// Act & Assert
expect($bonusEntry->draw_number)->toBeNull();

View File

@ -0,0 +1,65 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
use App\Models\Audition;
use App\Models\Room;
use App\Models\User;
use App\Services\AuditionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
// getSubscores()
it('throws an exception when an invalid mode is requested', function () {
//$auditionService = new \App\Services\AuditionService();
$auditionService = App::make(AuditionService::class);
$this->expectException(\App\Exceptions\AuditionServiceException::class);
$auditionService->getSubscores(new Audition(), 'invalid_mode');
});
it('throws an exception when an invalid sort is requested', function () {
// Arrange
//$auditionService = new \App\Services\AuditionService();
$auditionService = App::make(AuditionService::class);
$this->expectException(\App\Exceptions\AuditionServiceException::class);
// Act
$auditionService->getSubscores(new Audition(), 'seating', 'invalid_sort');
});
it('throws an exception when an invalid audition is provided', function () {
// Arrange
//$auditionService = new \App\Services\AuditionService();
$auditionService = App::make(AuditionService::class);
$this->expectException(\App\Exceptions\AuditionServiceException::class);
$auditionService->getSubscores(new Audition(), 'seating', 'tiebreak');
// Act & Assert
});
it('gets subscores for an audition', function () {
// Arrange
loadSampleAudition();
//$auditionService = new \App\Services\AuditionService();
$auditionService = App::make(AuditionService::class);
// Act
$subscores = $auditionService->getSubscores(Audition::find(1000), 'seating', 'tiebreak');
// Assert
expect($subscores->toArray())->toBe(Audition::find(1000)->scoringGuide->subscores->where('for_seating',
true)->sortBy('tiebreak_order')->toArray());
});
// getJudges()
it('gets judges for an audition', function () {
loadSampleAudition();
$auditionService = App::make(AuditionService::class);
$judge = User::factory()->create();
$notJudge = User::factory()->create();
Room::find(1000)->addJudge($judge);
$testValue = $auditionService->getJudges(Audition::find(1000));
$test = $testValue->contains(function ($item) use ($judge) {
return $item->id === $judge->id;
});
$negativeTest = $testValue->contains(function ($item) use ($notJudge) {
return $item->id === $notJudge->id;
});
expect($test)->toBeTrue();
expect($negativeTest)->toBeFalse();
});

View File

@ -0,0 +1,78 @@
<?php
use App\Exceptions\TabulationException;
use App\Models\Audition;
use App\Models\Entry;
use App\Models\Event;
use App\Models\Student;
use App\Services\DoublerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
use function PHPUnit\Framework\assertArrayNotHasKey;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->doublerService = App::make(DoublerService::class);
});
it('throws an error if an invalid event is provided', function () {
$event = Event::factory()->make();
$this->doublerService->doublersForEvent($event);
})->throws(TabulationException::class, 'Invalid event provided');
it('returns doublers for an event', function () {
$concertEvent = Event::factory()->create(['name' => 'Concert Band', 'id' => 1000]);
$jazzEvent = Event::factory()->create(['name' => 'Jazz Band', 'id' => 1001]);
Audition::factory()->create([
'event_id' => 1000, 'name' => 'Alto Sax', 'minimum_grade' => 7, 'maximum_grade' => 12, 'id' => 1000,
]);
Audition::factory()->create([
'event_id' => 1000, 'name' => 'Tenor Sax', 'minimum_grade' => 7, 'maximum_grade' => 12, 'id' => 1001,
]);
Audition::factory()->create([
'event_id' => 1000, 'name' => 'Baritone Sax', 'minimum_grade' => 7, 'maximum_grade' => 12, 'id' => 1002,
]);
Audition::factory()->create([
'event_id' => 1000, 'name' => 'Clarinet', 'minimum_grade' => 7, 'maximum_grade' => 12, 'id' => 1003,
]);
Audition::factory()->create([
'event_id' => 1000, 'name' => 'Bass Clarinet',
'minimum_grade' => 7, 'maximum_grade' => 12, 'id' => 1004,
]);
Audition::factory()->create([
'event_id' => 1001, 'name' => 'Jazz Alto', 'minimum_grade' => 7,
'maximum_grade' => 12, 'id' => 1005,
]);
Audition::factory()->create([
'event_id' => 1001, 'name' => 'Jazz Tenor', 'minimum_grade' => 7,
'maximum_grade' => 12, 'id' => 1006,
]);
Audition::factory()->create([
'event_id' => 1001, 'name' => 'Jazz Baritone',
'minimum_grade' => 7, 'maximum_grade' => 12, 'id' => 1007,
]);
$allSaxDude = Student::factory()->create(['grade' => 11, 'id' => 1000]);
Student::factory()->create(['grade' => 9, 'id' => 1001]);
Student::factory()->create(['grade' => 9, 'id' => 1002]);
Entry::create(['student_id' => 1000, 'audition_id' => 1000]);
Entry::create(['student_id' => 1000, 'audition_id' => 1001]);
Entry::create(['student_id' => 1000, 'audition_id' => 1002]);
Entry::create(['student_id' => 1000, 'audition_id' => 1005]);
Entry::create(['student_id' => 1000, 'audition_id' => 1006]);
Entry::create(['student_id' => 1000, 'audition_id' => 1007]);
Entry::create(['student_id' => 1001, 'audition_id' => 1003]);
Entry::create(['student_id' => 1001, 'audition_id' => 1004]);
Entry::create(['student_id' => 1002, 'audition_id' => 1000]);
Entry::create(['student_id' => 1002, 'audition_id' => 1005]);
$return = $this->doublerService->doublersForEvent($concertEvent);
expect(count($return))->toBe(2)
->and($return[1000]['student_id'])->toBe($allSaxDude->id)
->and($return[1000]['entries']->count())->toBe(3)
->and($return[1001]['entries']->count())->toBe(2);
assertArrayNotHasKey(1002, $return);
$return = $this->doublerService->doublersForEvent($jazzEvent);
expect(count($return))->toBe(1)
->and($return[1000]['student_id'])->toBe($allSaxDude->id)
->and($return[1000]['entries']->count())->toBe(3);
});

View File

@ -0,0 +1,24 @@
<?php
use App\Models\Audition;
use App\Models\Entry;
use App\Services\EntryService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
beforeEach(function() {
$this->entryService = App::make(EntryService::class);
});
it('checks if an entry is late', function() {
$openAudition = Audition::factory()->create(['entry_deadline' => Carbon::tomorrow()]);
$closedAudition = Audition::factory()->create(['entry_deadline' => Carbon::yesterday()]);
$onTime = Entry::factory()->create(['audition_id' => $openAudition->id]);
$late = Entry::factory()->create(['audition_id' => $closedAudition]);
expect($this->entryService->isEntryLate($onTime))->toBeFalse();
expect($this->entryService->isEntryLate($late))->toBeTrue();
});

View File

@ -0,0 +1,38 @@
<?php
use App\Models\Audition;
use App\Models\Entry;
use App\Models\Room;
use App\Models\ScoreSheet;
use App\Models\User;
use App\Services\ScoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
beforeEach(function () {
#$this->scoreService = new ScoreService();
$this->scoreService = App::make(ScoreService::class);
});
it('can check if an entry is fully scored', function () {
$room = Room::factory()->create();
$judges = User::factory()->count(2)->create();
$judges->each(fn ($judge) => $room->addJudge($judge));
$audition = Audition::factory()->create(['room_id' => $room->id]);
$entry = Entry::factory()->create(['audition_id' => $audition->id]);
expect($this->scoreService->isEntryFullyScored($entry))->toBeFalse();
ScoreSheet::create([
'user_id' => $judges->first()->id,
'entry_id' => $entry->id,
'subscores' => 7,
]);
expect($this->scoreService->isEntryFullyScored($entry))->toBeFalse();
ScoreSheet::create([
'user_id' => $judges->last()->id,
'entry_id' => $entry->id,
'subscores' => 7,
]);
expect($this->scoreService->isEntryFullyScored($entry))->toBeTrue();
});

View File

@ -0,0 +1,19 @@
<?php
use App\Models\User;
use App\Services\UserService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\App;
uses(RefreshDatabase::class);
beforeEach(function() {
$this->userService = App::make(UserService::class);
});
it('checks if a user exists', function() {
$realUser = User::factory()->create();
$fakeUser = User::factory()->make();
expect ($this->userService->userExists($realUser))->toBeTrue();
expect ($this->userService->userExists($fakeUser))->toBeFalse();
});

Some files were not shown because too many files have changed in this diff Show More