diff --git a/app/Actions/Tabulation/AllJudgesCount.php b/app/Actions/Tabulation/AllJudgesCount.php deleted file mode 100644 index 0089211..0000000 --- a/app/Actions/Tabulation/AllJudgesCount.php +++ /dev/null @@ -1,99 +0,0 @@ -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, 300, function () use ($mode, $entry) { - $this->isEntryANoShow($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)->pluck('id')->sort()->toArray(); - $existingJudgeIds = $entry->scoreSheets->pluck('user_id')->sort()->toArray(); - if ($validJudgeIds !== $existingJudgeIds) { - throw new TabulationException('Score exists from a judge not assigned to this audition'); - } - } - - protected function isEntryANoShow(Entry $entry): void - { - if ($entry->hasFlag('no_show')) { - throw new TabulationException('No Show'); - } - } -} diff --git a/app/Actions/Tabulation/AllowForOlympicScoring.php b/app/Actions/Tabulation/AllowForOlympicScoring.php deleted file mode 100644 index 44c237b..0000000 --- a/app/Actions/Tabulation/AllowForOlympicScoring.php +++ /dev/null @@ -1,161 +0,0 @@ -calculator = $calculator; - $this->auditionService = $auditionService; - $this->entryService = $entryService; - } - - public function calculate(string $mode, Entry $entry): array - { - $calculated = CalculatedScore::where('entry_id', $entry->id)->where('mode', $mode)->first(); - if ($calculated) { - return $calculated->calculatedScore; - } - - $cacheKey = 'entryScore-'.$entry->id.'-'.$mode; - - return Cache::remember($cacheKey, 300, function () use ($mode, $entry) { - $this->basicValidation($mode, $entry); - $this->isEntryANoShow($entry); - $this->areAllJudgesIn($entry); - $this->areAllJudgesValid($entry); - $calculatedScores = $this->getJudgeTotals($mode, $entry); - CalculatedScore::create([ - 'entry_id' => $entry->id, - 'mode' => $mode, - 'calculatedScore' => $calculatedScores, - ]); - - return $calculatedScores; - // return $this->getJudgeTotals($mode, $entry); - }); - - } - - protected function getJudgeTotals($mode, Entry $entry): array - { - - $scores = []; - foreach ($this->auditionService->getJudges($entry->audition) as $judge) { - $scores[] = $this->calculator->__invoke($mode, $entry, $judge); - } - // sort the scores array by the total score - usort($scores, function ($a, $b) { - return $a[0] <=> $b[0]; - }); - - // we can only really do olympic scoring if there are at least 3 scores - if (count($scores) >= 3 && auditionSetting('olympic_scoring')) { - // remove the highest and lowest scores - array_pop($scores); - array_shift($scores); - } - $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++; - } - } - // add the bonus points for a seating mode - if ($mode === 'seating' && $sums) { - - $sums[0] += $this->getBonusPoints($entry); - } - - return $sums; - } - - protected function getBonusPoints(Entry $entry) - { - - $bonusScoreDefinition = $entry->audition->bonusScore()->first(); - if (! $bonusScoreDefinition) { - return 0; - } - /** @noinspection PhpPossiblePolymorphicInvocationInspection */ - $bonusJudges = $bonusScoreDefinition->judges; - $bonusScoreSheets = BonusScore::where('entry_id', $entry->id)->get(); - foreach ($bonusScoreSheets as $sheet) { - if (! $bonusJudges->contains($sheet->user_id)) { - throw new TabulationException('Entry has a bonus score from unassigned judge'); - } - } - - // sum the score property of the $bonusScoreSheets - return $bonusScoreSheets->sum('score'); - } - - protected function basicValidation($mode, $entry): void - { - if ($mode !== 'seating' && $mode !== 'advancement') { - 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)->pluck('id')->toArray(); - $existingJudgeIds = $entry->scoreSheets->pluck('user_id')->toArray(); - if (array_diff($existingJudgeIds, $validJudgeIds)) { - Log::debug('EntryID: '.$entry->id); - Log::debug('Valid judge ids: ('.gettype($validJudgeIds).') '.json_encode($validJudgeIds)); - Log::debug('Existing judge ids: ('.gettype($existingJudgeIds).') '.json_encode($existingJudgeIds)); - throw new TabulationException('Score exists from a judge not assigned to this audition'); - } - } - - protected function isEntryANoShow(Entry $entry): void - { - if ($entry->hasFlag('failed_prelim')) { - throw new TabulationException('Failed Prelim'); - } - - if ($entry->hasFlag('no_show')) { - throw new TabulationException('No Show'); - } - } -} diff --git a/app/Actions/Tabulation/CalculateAuditionScores.php b/app/Actions/Tabulation/CalculateAuditionScores.php new file mode 100644 index 0000000..72d774c --- /dev/null +++ b/app/Actions/Tabulation/CalculateAuditionScores.php @@ -0,0 +1,30 @@ +judges->count(); + $pending_entries = Entry::where('audition_id', $audition->id) + ->has('scoreSheets', '=', $scores_required) + ->whereDoesntHave('totalScore') + ->with('audition.scoringGuide.subscores') + ->get(); + foreach ($pending_entries as $entry) { + Debugbar::debug('Calculating scores for entry: '.$entry->id); + $totaler->__invoke($entry); + } + } +} diff --git a/app/Actions/Tabulation/CalculateEntryScore.php b/app/Actions/Tabulation/CalculateEntryScore.php deleted file mode 100644 index 8cb48a8..0000000 --- a/app/Actions/Tabulation/CalculateEntryScore.php +++ /dev/null @@ -1,11 +0,0 @@ -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'); - } - } -} diff --git a/app/Actions/Tabulation/CalculateScoreSheetTotalDivideByWeightedPossible.php b/app/Actions/Tabulation/CalculateScoreSheetTotalDivideByWeightedPossible.php deleted file mode 100644 index 55107bc..0000000 --- a/app/Actions/Tabulation/CalculateScoreSheetTotalDivideByWeightedPossible.php +++ /dev/null @@ -1,74 +0,0 @@ -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; - $weightedMaxPossible = 0; - $scoreArray = []; - foreach ($subscores as $subscore) { - $weight = $subscore['weight']; - $score = $scoreSheet->subscores[$subscore->id]['score']; - $maxPossible = $subscore['max_score']; - $scoreArray[] = $score; - $scoreTotal += ($score * $weight); - $weightsTotal += $weight; - $weightedMaxPossible += $maxPossible; - } - if ($weightedMaxPossible > 0) { - $finalScore = ($scoreTotal / $weightedMaxPossible) * 100; - } else { - $finalScore = 0; - } - // 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'); - } - } -} diff --git a/app/Actions/Tabulation/EnterBonusScore.php b/app/Actions/Tabulation/EnterBonusScore.php index b42a183..7f57d8e 100644 --- a/app/Actions/Tabulation/EnterBonusScore.php +++ b/app/Actions/Tabulation/EnterBonusScore.php @@ -6,7 +6,6 @@ namespace App\Actions\Tabulation; use App\Exceptions\ScoreEntryException; use App\Models\BonusScore; -use App\Models\CalculatedScore; use App\Models\Entry; use App\Models\User; use Illuminate\Database\Eloquent\Collection; @@ -29,7 +28,6 @@ class EnterBonusScore // Create the score for each related entry foreach ($entries as $relatedEntry) { // Also delete any cached scores - CalculatedScore::where('entry_id', $relatedEntry->id)->delete(); BonusScore::create([ 'entry_id' => $relatedEntry->id, 'user_id' => $judge->id, diff --git a/app/Actions/Tabulation/EnterNoShow.php b/app/Actions/Tabulation/EnterNoShow.php new file mode 100644 index 0000000..3bd1718 --- /dev/null +++ b/app/Actions/Tabulation/EnterNoShow.php @@ -0,0 +1,56 @@ +audition->hasFlag('seats_published')) { + throw new AuditionAdminException('Cannot enter a no-show for an entry in an audition where seats are published'); + } + if ($entry->audition->hasFlag('advancement_published')) { + throw new AuditionAdminException('Cannot enter a no-show for an entry in an audition where advancement is published'); + } + DB::table('score_sheets')->where('entry_id', $entry->id)->delete(); + + ScoreSheet::where('entry_id', $entry->id)->delete(); + BonusScore::where('entry_id', $entry->id)->delete(); + EntryTotalScore::where('entry_id', $entry->id)->delete(); + if ($flagType == 'failprelim') { + $msg = 'Failed prelim has been entered for '.$entry->audition->name.' #'.$entry->draw_number.' (ID: '.$entry->id.').'; + $entry->addFlag('failed_prelim'); + } else { + $entry->addFlag('no_show'); + $msg = 'No Show has been entered for '.$entry->audition->name.' #'.$entry->draw_number.' (ID: '.$entry->id.').'; + } + + return $msg; + } +} diff --git a/app/Actions/Tabulation/EnterScore.php b/app/Actions/Tabulation/EnterScore.php index 407f4cb..e38faa8 100644 --- a/app/Actions/Tabulation/EnterScore.php +++ b/app/Actions/Tabulation/EnterScore.php @@ -7,105 +7,32 @@ namespace App\Actions\Tabulation; use App\Exceptions\ScoreEntryException; -use App\Models\CalculatedScore; +use App\Models\AuditLogEntry; use App\Models\Entry; +use App\Models\EntryTotalScore; use App\Models\ScoreSheet; use App\Models\User; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +use function auth; + class EnterScore { /** * @param User $user A user acting as the judge for this sheet * @param Entry $entry An entry to which this score should be assigned * @param array $scores Scores to be entered in the form of SubscoreID => score + * @param ScoreSheet|false $scoreSheet If this is an update to an existing scoresheet, pass it here + * @return ScoreSheet The scoresheet that was created or updated * * @throws ScoreEntryException */ public function __invoke(User $user, Entry $entry, array $scores, ScoreSheet|false $scoreSheet = false): ScoreSheet { - CalculatedScore::where('entry_id', $entry->id)->delete(); + EntryTotalScore::where('entry_id', $entry->id)->delete(); $scores = collect($scores); - $this->basicChecks($user, $entry, $scores); - $this->checkJudgeAssignment($user, $entry); - $this->checkForExistingScore($user, $entry, $scoreSheet); - $this->validateScoresSubmitted($entry, $scores); - $entry->removeFlag('no_show'); - if ($scoreSheet instanceof ScoreSheet) { - $scoreSheet->update([ - 'user_id' => $user->id, - 'entry_id' => $entry->id, - 'subscores' => $this->subscoresForStorage($entry, $scores), - ]); - } else { - $scoreSheet = ScoreSheet::create([ - 'user_id' => $user->id, - 'entry_id' => $entry->id, - 'subscores' => $this->subscoresForStorage($entry, $scores), - ]); - } - return $scoreSheet; - } - - 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, $existingScoreSheet) - { - if (! $existingScoreSheet) { - 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'); - } - } else { - if ($existingScoreSheet->user_id !== $user->id) { - throw new ScoreEntryException('Existing score sheet is from a different judge'); - } - if ($existingScoreSheet->entry_id !== $entry->id) { - throw new ScoreEntryException('Existing score sheet is for a different 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) - { + // Basic Validity Checks if (! $user->exists()) { throw new ScoreEntryException('User does not exist'); } @@ -118,9 +45,110 @@ class EnterScore 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) { + + // Check that the specified user is assigned to judge this 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'); + } + + // Check if a score already exists + if (! $scoreSheet) { + 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'); + } + } else { + if ($scoreSheet->user_id !== $user->id) { + throw new ScoreEntryException('Existing score sheet is from a different judge'); + } + if ($scoreSheet->entry_id !== $entry->id) { + throw new ScoreEntryException('Existing score sheet is for a different entry'); + } + } + + // Check the validity of submitted subscores, format array for storage, and sum score + $subscoresRequired = $entry->audition->scoringGuide->subscores; + $subscoresStorageArray = []; + $seatingTotal = 0; + $seatingMaxPossible = 0; + $advancementTotal = 0; + $advancementMaxPossible = 0; + if ($scores->count() !== $subscoresRequired->count()) { throw new ScoreEntryException('Invalid number of scores'); } + + 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'); + } + + // Add subscore to the storage array + $subscoresStorageArray[$subscore->id] = [ + 'score' => $scores[$subscore->id], + 'subscore_id' => $subscore->id, + 'subscore_name' => $subscore->name, + ]; + + // If included in seating, multiply by weight and add to the total and max possible + if ($subscore->for_seating) { + $seatingTotal += ($subscore->weight * $scores[$subscore->id]); + $seatingMaxPossible += ($subscore->weight * $subscore->max_score); + } + + // If included in advancement, multiply by weight and add to the total and max possible + if ($subscore->for_advance) { + $advancementTotal += ($subscore->weight * $scores[$subscore->id]); + $advancementMaxPossible += ($subscore->weight * $subscore->max_score); + } + } + $finalSeatingTotal = ($seatingMaxPossible === 0) ? 0 : (($seatingTotal / $seatingMaxPossible) * 100); + $finalAdvancementTotal = ($advancementMaxPossible === 0) ? 0 : (($advancementTotal / $advancementMaxPossible) * 100); + + $entry->removeFlag('no_show'); + if ($scoreSheet instanceof ScoreSheet) { + $scoreSheet->update([ + 'user_id' => $user->id, + 'entry_id' => $entry->id, + 'subscores' => $subscoresStorageArray, + 'seating_total' => $finalSeatingTotal, + 'advancement_total' => $finalAdvancementTotal, + ]); + } else { + $scoreSheet = ScoreSheet::create([ + 'user_id' => $user->id, + 'entry_id' => $entry->id, + 'subscores' => $subscoresStorageArray, + 'seating_total' => $finalSeatingTotal, + 'advancement_total' => $finalAdvancementTotal, + ]); + } + + // Log the score entry + $log_message = 'Entered Score for entry id '.$entry->id.'.
'; + $log_message .= 'Judge: '.$user->full_name().'
'; + foreach ($scoreSheet->subscores as $subscore) { + $log_message .= $subscore['subscore_name'].': '.$subscore['score'].'
'; + } + $log_message .= 'Seating Total: '.$scoreSheet->seating_total.'
'; + $log_message .= 'Advancement Total: '.$scoreSheet->advancement_total.'
'; + AuditLogEntry::create([ + 'user' => auth()->user()->email ?? 'no user', + 'ip_address' => request()->ip(), + 'message' => $log_message, + 'affected' => [ + 'entries' => [$entry->id], + 'users' => [$user->id], + 'auditions' => [$entry->audition_id], + 'students' => [$entry->student_id], + ], + ]); + + return $scoreSheet; } } diff --git a/app/Actions/Tabulation/ForceRecalculateTotalScores.php b/app/Actions/Tabulation/ForceRecalculateTotalScores.php new file mode 100644 index 0000000..d5c6bde --- /dev/null +++ b/app/Actions/Tabulation/ForceRecalculateTotalScores.php @@ -0,0 +1,16 @@ +calculator = $calculator; - } - - public function rank(string $mode, Audition $audition): Collection - { - $cacheKey = 'audition'.$audition->id.$mode; - - return Cache::remember($cacheKey, 300, function () use ($mode, $audition) { - return $this->calculateRank($mode, $audition); - }); - - } - /** - * For a given audition, return a collection of entries ranked by total score. Each entry will have a - * property rank that either is their rank or a flag reflecting no-show, declined, or failed-prelim status + * Get ranked entries for the provided audition for either seating or advancement. * - * @throws TabulationException + * If the rank_type is seating, the ranked entries are returned in descending order of seating total. + * If the rank_type is advancement, the ranked entries are returned in descending order of advancement total. + * + * The ranked entries are returned as a Collection of Entry objects. + * + * @param string $rank_type advancement|seating + * @return Collection|void + * + * @throws AuditionAdminException */ - public function calculateRank(string $mode, Audition $audition): Collection + public function __invoke(Audition $audition, string $rank_type) { - $this->basicValidation($mode, $audition); - $entries = match ($mode) { - 'seating' => $audition->entries()->forSeating()->with('scoreSheets')->withCount('bonusScores')->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 (! array_key_exists($i, $a->score_totals)) { - return -1; - } - if (! array_key_exists($i, $b->score_totals)) { - return -1; - } - 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; - $rawRank = 1; - foreach ($entries as $entry) { - $entry->rank = $rank; - $entry->raw_rank = $rawRank; - // We don't really get a rank for seating if we have certain flags - if ($mode === 'seating') { - if ($entry->hasFlag('failed_prelim')) { - $entry->rank = 'Failed Prelim'; - } elseif ($entry->hasFlag('declined')) { - $entry->rank = 'Declined'; - } elseif ($entry->hasFlag('no_show')) { - $entry->rank = 'No Show'; - } - } - - if (is_numeric($entry->rank)) { - $rank++; - } - $rawRank++; + if ($rank_type !== 'seating' && $rank_type !== 'advancement') { + throw new AuditionAdminException('Invalid rank type: '.$rank_type.' (must be seating or advancement)'); + } + + $cache_duration = 15; + + if ($rank_type === 'seating') { + return cache()->remember('rank_seating_'.$audition->id, $cache_duration, function () use ($audition) { + return $this->get_seating_ranks($audition); + }); + } + + if ($rank_type === 'advancement') { + return cache()->remember('rank_advancement_'.$audition->id, $cache_duration, function () use ($audition) { + return $this->get_advancement_ranks($audition); + }); } - return $entries; } - protected function basicValidation($mode, Audition $audition): void + private function get_seating_ranks(Audition $audition): Collection { - if ($mode !== 'seating' && $mode !== 'advancement') { - throw new TabulationException('Mode must be seating or advancement'); + if ($audition->bonusScore()->count() > 0) { + $totalColumn = 'seating_total_with_bonus'; + } else { + $totalColumn = 'seating_total'; } - if (! $audition->exists()) { - throw new TabulationException('Invalid audition provided'); + + $sortedEntries = $audition->entries() + ->whereHas('totalScore') + ->with('totalScore') + ->with('student.school') + ->with('audition') + ->join('entry_total_scores', 'entries.id', '=', 'entry_total_scores.entry_id') + ->orderBy('entry_total_scores.'.$totalColumn, 'desc') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[0]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[1]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[2]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[3]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[4]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[5]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[6]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[7]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[8]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.seating_subscore_totals, "$[9]"), -999999) DESC') + ->select('entries.*') + ->get(); + + $rankOn = 1; + foreach ($sortedEntries as $entry) { + if ($entry->hasFlag('declined')) { + $entry->seatingRank = 'declined'; + } else { + $entry->seatingRank = $rankOn; + $rankOn++; + } } + + return $sortedEntries; + } + + private function get_advancement_ranks(Audition $audition): Collection + { + return $audition->entries() + ->whereHas('totalScore') + ->with('totalScore') + ->with('student.school') + ->with('audition') + ->join('entry_total_scores', 'entries.id', '=', 'entry_total_scores.entry_id') + ->orderBy('entry_total_scores.advancement_total', 'desc') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[0]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[1]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[2]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[3]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[4]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[5]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[6]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[7]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[8]"), -999999) DESC') + ->orderByRaw('COALESCE(JSON_EXTRACT(entry_total_scores.advancement_subscore_totals, "$[9]"), -999999) DESC') + ->select('entries.*') + ->get(); } } diff --git a/app/Actions/Tabulation/TotalEntryScores.php b/app/Actions/Tabulation/TotalEntryScores.php new file mode 100644 index 0000000..1e2a252 --- /dev/null +++ b/app/Actions/Tabulation/TotalEntryScores.php @@ -0,0 +1,90 @@ +id)->delete(); + } + // bail out if a total score is already calculated + if (EntryTotalScore::where('entry_id', $entry->id)->count() > 0) { + return; + } + $requiredSubscores = $entry->audition->scoringGuide->subscores; + $newTotaledScore = EntryTotalScore::make(); + $newTotaledScore->entry_id = $entry->id; + + // deal with seating scores + // TODO: Consider a rewrite to pull the scoreSheets from the entry model so they may be preloaded + $scoreSheets = ScoreSheet::where('entry_id', $entry->id)->orderBy('seating_total', 'desc')->get(); + // bail out if there are no score sheets + if ($scoreSheets->count() == 0) { + return; + } + if (auditionSetting('olympic_scoring' && $scoreSheets->count() > 2)) { + // under olympic scoring, drop the first and last element + $scoreSheets->shift(); + $scoreSheets->pop(); + } + $newTotaledScore->seating_total = $scoreSheets->avg('seating_total'); + $seatingSubscores = $requiredSubscores + ->filter(fn ($subscore) => $subscore->for_seating == true) + ->sortBy('tiebreak_order'); + $total_seating_subscores = []; + foreach ($seatingSubscores as $subscore) { + $runningTotal = 0; + foreach ($scoreSheets as $scoreSheet) { + $runningTotal += $scoreSheet->subscores[$subscore->id]['score']; + } + $total_seating_subscores[] = $runningTotal / $scoreSheets->count(); + } + $newTotaledScore->seating_subscore_totals = $total_seating_subscores; + + // deal with advancement scores + $scoreSheets = ScoreSheet::where('entry_id', $entry->id)->orderBy('advancement_total', 'desc')->get(); + if (auditionSetting('olympic_scoring' && $scoreSheets->count() > 2)) { + // under olympic scoring, drop the first and last element + $scoreSheets->shift(); + $scoreSheets->pop(); + } + $newTotaledScore->advancement_total = $scoreSheets->avg('advancement_total'); + $advancement_subscores = $requiredSubscores + ->filter(fn ($subscore) => $subscore->for_advance == true) + ->sortBy('tiebreak_order'); + $total_advancement_subscores = []; + foreach ($advancement_subscores as $subscore) { + $runningTotal = 0; + foreach ($scoreSheets as $scoreSheet) { + $runningTotal += $scoreSheet->subscores[$subscore->id]['score']; + } + $total_advancement_subscores[] = $runningTotal / $scoreSheets->count(); + } + $newTotaledScore->advancement_subscore_totals = $total_advancement_subscores; + + // pull in bonus scores + $bonusScores = BonusScore::where('entry_id', $entry->id) + ->selectRaw('SUM(score) as total') + ->value('total'); + + $newTotaledScore->bonus_total = $bonusScores; + + $newTotaledScore->save(); + } +} diff --git a/app/Console/Commands/RecalculateScores.php b/app/Console/Commands/RecalculateScores.php new file mode 100644 index 0000000..acc43ad --- /dev/null +++ b/app/Console/Commands/RecalculateScores.php @@ -0,0 +1,35 @@ +info('Starting score recalculation...'); + + $action(); + + $this->info('Score recalculation completed successfully.'); + } +} diff --git a/app/Console/Commands/SyncDoublers.php b/app/Console/Commands/SyncDoublers.php new file mode 100644 index 0000000..a79a93c --- /dev/null +++ b/app/Console/Commands/SyncDoublers.php @@ -0,0 +1,39 @@ +argument('event')) { + $event = Event::findOrFail($eventId); + Doubler::syncForEvent($event); + $this->info("Synced doublers for event {$event->name}"); + } else { + Doubler::syncDoublers(); + $this->info('Synced doublers for all events'); + } + } +} diff --git a/app/Console/Commands/fictionalize.php b/app/Console/Commands/fictionalize.php new file mode 100644 index 0000000..b860470 --- /dev/null +++ b/app/Console/Commands/fictionalize.php @@ -0,0 +1,52 @@ +first_name = $faker->firstName(); + $student->last_name = $faker->lastName(); + $student->save(); + } + + foreach (School::all() as $school) { + $school->name = $faker->city().' High School'; + $school->save(); + } + + foreach (User::where('email', '!=', 'matt@mattyoung.us')->get() as $user) { + $user->email = $faker->email(); + $user->first_name = $faker->firstName(); + $user->last_name = $faker->lastName(); + $user->cell_phone = $faker->phoneNumber(); + $user->save(); + } + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 3e04766..8ece8e6 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -2,10 +2,6 @@ namespace App\Http\Controllers; -use App\Actions\Entries\GetEntrySeatingResult; -use App\Actions\Tabulation\CalculateEntryScore; -use App\Actions\Tabulation\RankAuditionEntries; -use App\Models\AuditionFlag; use App\Models\School; use App\Services\Invoice\InvoiceDataService; use Illuminate\Support\Facades\Auth; @@ -28,33 +24,8 @@ class DashboardController extends Controller } public function dashboard( - CalculateEntryScore $scoreCalc, - GetEntrySeatingResult $resultGenerator, - RankAuditionEntries $ranker ) { - - // Info for director results report - $entries = Auth::user()->entries; - $entries = $entries->filter(function ($entry) { - return $entry->audition->hasFlag('seats_published'); - }); - $entries = $entries->sortBy(function ($entry) { - return $entry->student->full_name(true); - }); - $scores = []; - $results = []; - $ranks = []; - foreach ($entries as $entry) { - $results[$entry->id] = $resultGenerator->getResult($entry); - if (! $entry->hasFlag('no_show') && ! $entry->hasFlag('failed_prelim')) { - $scores[$entry->id] = $scoreCalc->calculate('seating', $entry); - $auditionResults = $ranker->rank('seating', $entry->audition); - $ranks[$entry->id] = $auditionResults->firstWhere('id', $entry->id)->raw_rank; - } - } - $showRecapLink = AuditionFlag::where('flag_name', 'seats_published')->count() > 0; - - return view('dashboard.dashboard', compact('entries', 'scores', 'results', 'ranks', 'showRecapLink')); + return view('dashboard.dashboard'); // return view('dashboard.dashboard'); } diff --git a/app/Http/Controllers/ResultsPage.php b/app/Http/Controllers/ResultsPage.php index ba2ddf0..32fcbd3 100644 --- a/app/Http/Controllers/ResultsPage.php +++ b/app/Http/Controllers/ResultsPage.php @@ -8,6 +8,7 @@ use App\Models\Ensemble; use App\Models\Entry; use App\Models\Seat; use App\Services\AuditionService; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\View; @@ -28,6 +29,7 @@ class ResultsPage extends Controller */ public function __invoke(Request $request) { + Model::preventLazyLoading(false); $cacheKey = 'publicResultsPage'; if (Cache::has($cacheKey)) { diff --git a/app/Http/Controllers/Tabulation/AdvancementController.php b/app/Http/Controllers/Tabulation/AdvancementController.php index 5b18550..10bbb7c 100644 --- a/app/Http/Controllers/Tabulation/AdvancementController.php +++ b/app/Http/Controllers/Tabulation/AdvancementController.php @@ -2,50 +2,62 @@ namespace App\Http\Controllers\Tabulation; +use App\Actions\Tabulation\CalculateAuditionScores; use App\Actions\Tabulation\RankAuditionEntries; use App\Http\Controllers\Controller; use App\Models\Audition; -use App\Models\Entry; +use App\Models\EntryFlag; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; class AdvancementController extends Controller { - protected RankAuditionEntries $ranker; - - public function __construct(RankAuditionEntries $ranker) - { - $this->ranker = $ranker; - } - public function status() { + // Total auditions scores if we haven't done it lately + if (! Cache::has('advancement_status_audition_totaler_throttle')) { + $lock = Cache::lock('advancement_status_audition_totaler_lock'); + + if ($lock->get()) { + try { + $totaler = app(CalculateAuditionScores::class); + foreach (Audition::forAdvancement()->with('judges')->get() as $audition) { + $totaler($audition); + } + + // set throttle + Cache::put('advancement_status_audition_totaler_throttle', true, 15); + } finally { + $lock->release(); + } + } + } + $auditions = Audition::forAdvancement() ->with('flags') ->withCount([ 'entries' => function ($query) { - $query->where('for_advancement', 1); + $query->where('for_advancement', true); }, ]) ->withCount([ 'unscoredEntries' => function ($query) { - $query->where('for_advancement', 1); + $query->where('for_advancement', true); }, ]) + ->orderBy('score_order') ->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; + $auditions->each(function (Audition $audition) use (&$auditionData) { $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, + 'scored_percentage' => $audition->entries_count > 0 ? ((($audition->entries_count - $audition->unscored_entries_count) / $audition->entries_count) * 100) : 0, + 'scoring_complete' => $audition->unscored_entries_count === 0, 'published' => $audition->hasFlag('advancement_published'), ]; }); @@ -55,11 +67,12 @@ class AdvancementController extends Controller public function ranking(Request $request, Audition $audition) { - $entries = $this->ranker->rank('advancement', $audition); - $entries->load('advancementVotes'); + $ranker = app(RankAuditionEntries::class); + $entries = $ranker($audition, 'advancement'); + $entries->load(['advancementVotes', 'totalScore', 'student.school']); $scoringComplete = $entries->every(function ($entry) { - return $entry->score_totals[0] >= 0 || $entry->hasFlag('no_show'); + return $entry->totalScore || $entry->hasFlag('no_show'); }); return view('tabulation.advancement.ranking', compact('audition', 'entries', 'scoringComplete')); @@ -68,16 +81,24 @@ class AdvancementController extends Controller public function setAuditionPassers(Request $request, Audition $audition) { $passingEntries = $request->input('pass'); + $audition->addFlag('advancement_published'); if (! is_null($passingEntries)) { - $passingEntries = array_keys($passingEntries); - $entries = Entry::whereIn('id', $passingEntries)->get(); - foreach ($entries as $entry) { - $entry->addFlag('will_advance'); - } + $passEntries = collect(array_keys($passingEntries)); + EntryFlag::insert( + $passEntries + ->map(fn ($entryId) => [ + 'entry_id' => $entryId, + 'flag_name' => 'will_advance', + 'created_at' => now(), + 'updated_at' => now(), + ])->toArray() + ); + } Cache::forget('audition'.$audition->id.'advancement'); Cache::forget('publicResultsPage'); + Cache::forget('rank_advancement_'.$audition->id); return redirect()->route('advancement.ranking', ['audition' => $audition->id])->with('success', 'Passers have been set successfully'); @@ -86,9 +107,10 @@ class AdvancementController extends Controller public function clearAuditionPassers(Request $request, Audition $audition) { $audition->removeFlag('advancement_published'); - foreach ($audition->entries as $entry) { - $entry->removeFlag('will_advance'); - } + $audition->entries + ->filter(fn ($entry) => $entry->hasFlag('will_advance')) + ->each(fn ($entry) => $entry->removeFlag('will_advance')); + Cache::forget('audition'.$audition->id.'advancement'); Cache::forget('publicResultsPage'); diff --git a/app/Http/Controllers/Tabulation/EntryFlagController.php b/app/Http/Controllers/Tabulation/EntryFlagController.php index 249816b..5a1a8c0 100644 --- a/app/Http/Controllers/Tabulation/EntryFlagController.php +++ b/app/Http/Controllers/Tabulation/EntryFlagController.php @@ -2,13 +2,10 @@ namespace App\Http\Controllers\Tabulation; +use App\Exceptions\AuditionAdminException; use App\Http\Controllers\Controller; -use App\Models\BonusScore; -use App\Models\CalculatedScore; use App\Models\Entry; -use App\Models\ScoreSheet; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use function to_route; @@ -69,27 +66,16 @@ class EntryFlagController extends Controller 'scores')); } + /** + * @throws AuditionAdminException + */ public function enterNoShow(Entry $entry) { - if ($entry->audition->hasFlag('seats_published')) { - return to_route('entry-flags.noShowSelect')->with('error', - 'Cannot enter a no-show for an entry in an audition where seats are published'); - } - if ($entry->audition->hasFlag('advancement_published')) { - return to_route('entry-flags.noShowSelect')->with('error', - 'Cannot enter a no-show for an entry in an audition where advancement is published'); - } - DB::table('score_sheets')->where('entry_id', $entry->id)->delete(); - - $entry->addFlag('no_show'); - ScoreSheet::where('entry_id', $entry->id)->delete(); - CalculatedScore::where('entry_id', $entry->id)->delete(); - BonusScore::where('entry_id', $entry->id)->delete(); - if (request()->input('noshow-type') == 'failprelim') { - $msg = 'Failed prelim has been entered for '.$entry->audition->name.' #'.$entry->draw_number.' (ID: '.$entry->id.').'; - $entry->addFlag('failed_prelim'); - } else { - $msg = 'No Show has been entered for '.$entry->audition->name.' #'.$entry->draw_number.' (ID: '.$entry->id.').'; + $recorder = app('App\Actions\Tabulation\EnterNoShow'); + try { + $msg = $recorder($entry, request()->input('noshow-type')); + } catch (AuditionAdminException $e) { + return to_route('entry-flags.noShowSelect')->with('error', $e->getMessage()); } return to_route('entry-flags.noShowSelect')->with('success', $msg); diff --git a/app/Http/Controllers/Tabulation/ScoreController.php b/app/Http/Controllers/Tabulation/ScoreController.php index d9b5931..b4863a8 100644 --- a/app/Http/Controllers/Tabulation/ScoreController.php +++ b/app/Http/Controllers/Tabulation/ScoreController.php @@ -2,10 +2,13 @@ namespace App\Http\Controllers\Tabulation; +use App\Actions\Tabulation\EnterScore; +use App\Exceptions\ScoreEntryException; use App\Http\Controllers\Controller; -use App\Models\CalculatedScore; use App\Models\Entry; +use App\Models\EntryTotalScore; use App\Models\ScoreSheet; +use App\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Facades\Session; @@ -22,12 +25,13 @@ class ScoreController extends Controller public function destroyScore(ScoreSheet $score) { - CalculatedScore::where('entry_id', $score->entry_id)->delete(); + EntryTotalScore::where('entry_id', $score->entry_id)->delete(); if ($score->entry->audition->hasFlag('seats_published')) { return redirect()->back()->with('error', 'Cannot delete scores for an entry where seats are published'); } if ($score->entry->audition->hasFlag('advancement_published')) { - return redirect()->back()->with('error', 'Cannot delete scores for an entry where advancement is published'); + return redirect()->back()->with('error', + 'Cannot delete scores for an entry where advancement is published'); } $score->delete(); @@ -66,51 +70,35 @@ class ScoreController extends Controller compact('entry', 'judges', 'scoring_guide', 'subscores', 'existing_sheets')); } - public function saveEntryScoreSheet(Request $request, Entry $entry) + public function saveEntryScoreSheet(Request $request, Entry $entry, EnterScore $scoreRecorder) { - CalculatedScore::where('entry_id', $entry->id)->delete(); $publishedCheck = $this->checkIfPublished($entry); if ($publishedCheck) { return $publishedCheck; } + foreach ($request->all() as $key => $value) { + if (! str_contains($key, 'judge')) { + continue; + } + $judge_id = str_replace('judge', '', $key); + $judge = User::find($judge_id); + $existingScore = ScoreSheet::where('entry_id', $entry->id) + ->where('user_id', $judge->id)->first(); + if ($existingScore === null) { + $existingScore = false; + } + try { + $scoreRecorder($judge, $entry, $value, $existingScore); + } catch (ScoreEntryException $e) { + return redirect()->route('scores.entryScoreSheet', ['entry_id' => $entry->id]) + ->with('error', $e->getMessage()); + } + } + // Since we're entering a score, this apparently isn't a no show. $entry->removeFlag('no_show'); - $judges = $entry->audition->room->judges; - - $subscores = $entry->audition->scoringGuide->subscores->sortBy('tiebreak_order'); - $scoringGuide = $entry->audition->scoringGuide; - $preparedScoreSheets = []; - foreach ($judges as $judge) { - $preparedScoreSheets[$judge->id]['user_id'] = $judge->id; - $preparedScoreSheets[$judge->id]['entry_id'] = $entry->id; - - $scoreValidation = $scoringGuide->validateScores($request->input('judge'.$judge->id)); - if ($scoreValidation != 'success') { - return redirect(url()->previous())->with('error', - $judge->full_name().': '.$scoreValidation)->with('oldScores', $request->all()); - } - - $scoreSubmission = $request->input('judge'.$judge->id); - $scoresToSave = []; - foreach ($subscores as $subscore) { - $scoresToSave[$subscore->id] = [ - 'subscore_id' => $subscore->id, - 'subscore_name' => $subscore->name, - 'score' => intval($scoreSubmission[$subscore->id]), - ]; - } - $preparedScoreSheets[$judge->id]['scores'] = $scoresToSave; - } - foreach ($preparedScoreSheets as $sheet) { - ScoreSheet::updateOrCreate( - ['entry_id' => $sheet['entry_id'], 'user_id' => $sheet['user_id']], - ['subscores' => $sheet['scores']] - ); - } - // TODO rewrite to use EnterScore action or clear score cache - - return redirect()->route('scores.chooseEntry')->with('success', count($preparedScoreSheets).' Scores saved'); + return redirect()->route('scores.chooseEntry')->with('success', 'Scores saved'); } protected function checkIfPublished($entry) diff --git a/app/Http/Controllers/Tabulation/SeatAuditionFormController.php b/app/Http/Controllers/Tabulation/SeatAuditionFormController.php index 84dcf06..7f3ac23 100644 --- a/app/Http/Controllers/Tabulation/SeatAuditionFormController.php +++ b/app/Http/Controllers/Tabulation/SeatAuditionFormController.php @@ -2,184 +2,276 @@ namespace App\Http\Controllers\Tabulation; -use App\Actions\Entries\DoublerDecision; -use App\Actions\Tabulation\CalculateEntryScore; -use App\Actions\Tabulation\GetAuditionSeats; use App\Actions\Tabulation\RankAuditionEntries; use App\Exceptions\AuditionAdminException; use App\Http\Controllers\Controller; use App\Models\Audition; -use App\Services\AuditionService; -use App\Services\DoublerService; -use App\Services\EntryService; +use App\Models\Doubler; +use App\Models\Ensemble; +use App\Models\Entry; +use App\Models\Seat; +use Debugbar; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use function redirect; class SeatAuditionFormController extends Controller { - protected CalculateEntryScore $calc; - - protected DoublerService $doublerService; - - protected RankAuditionEntries $ranker; - - protected EntryService $entryService; - - protected AuditionService $auditionService; - - protected DoublerDecision $decider; - - public function __construct( - CalculateEntryScore $calc, - RankAuditionEntries $ranker, - DoublerService $doublerService, - EntryService $entryService, - AuditionService $auditionService, - DoublerDecision $decider, - ) { - $this->calc = $calc; - $this->ranker = $ranker; - $this->doublerService = $doublerService; - $this->entryService = $entryService; - $this->auditionService = $auditionService; - $this->decider = $decider; - } - - public function __invoke(Request $request, Audition $audition) + public function showForm(Request $request, Audition $audition) { - // If a seating proposal was posted, deal wth it - if ($request->method() == 'POST' && $request->input('ensembleAccept')) { - $requestedEnsembleAccepts = $request->input('ensembleAccept'); + $seatingProposal = (session('proposedSeatingArray-'.$audition->id)); + if ($audition->hasFlag('seats_published')) { + $publishedSeats = Seat::where('audition_id', $audition->id) + ->join('ensembles', 'seats.ensemble_id', '=', 'ensembles.id') + ->orderBy('ensembles.rank') + ->orderBy('seats.seat') + ->select('seats.*') + ->with(['ensemble', 'student.school']) + ->get(); } else { - $requestedEnsembleAccepts = false; + $publishedSeats = false; } - // Deal with a mass no-show request - if ($request->input('mass-no-show')) { - $entries = $audition->entries()->forSeating()->withCount('scoreSheets')->with('flags')->get(); - foreach ($entries as $entry) { - if ($entry->scoreSheets_count == 0 && ! $entry->hasFlag('no_show')) { - $entry->addFlag('no_show'); - } - Cache::forget('entryScore-'.$entry->id.'-seating'); - Cache::forget('entryScore-'.$entry->id.'-advancement'); + $ranker = app(RankAuditionEntries::class); + // Get scored entries in order + $scored_entries = $ranker($audition, 'seating'); + $scored_entries->load(['student.doublers', 'student.school']); + // Get unscored entries sorted by draw number + $unscored_entries = $audition->entries() + ->whereDoesntHave('totalScore') + ->whereDoesntHave('flags', function ($query) { + $query->where('flag_name', 'no_show'); + }) + ->whereDoesntHave('flags', function ($query) { + $query->where('flag_name', 'failed_prelim'); + }) + ->with('student.school') + ->orderBy('draw_number', 'asc') + ->get(); + + // Get no show entries sorted by draw number + $noshow_entries = $audition->entries() + ->whereDoesntHave('totalScore') + ->whereHas('flags', function ($query) { + $query->where('flag_name', 'no_show'); + }) + ->with('student.school') + ->orderBy('draw_number', 'asc') + ->get(); + + // Get failed prelim entries sorted by draw number + $failed_prelim_entries = $audition->entries() + ->whereDoesntHave('totalScore') + ->whereHas('flags', function ($query) { + $query->where('flag_name', 'failed_prelim'); + }) + ->with('student.school') + ->orderBy('draw_number', 'asc') + ->get(); + + // Get Doublers + $doublerData = Doubler::where('event_id', $audition->event_id) + ->whereIn('student_id', $scored_entries->pluck('student_id')) + ->get() + ->keyBy('student_id'); + + $auditionHasUnresolvedDoublers = false; + foreach ($doublerData as $doubler) { + if (! is_null($doubler->accepted_entry)) { + continue; } - Cache::forget('audition'.$audition->id.'seating'); - Cache::forget('audition'.$audition->id.'advancement'); + foreach ($doubler->entries() as $entry) { + if ($entry->audition_id === $audition->id && $entry->hasFlag('declined')) { + continue 2; + } + } + $auditionHasUnresolvedDoublers = true; } - $entryData = []; - $entries = $this->ranker->rank('seating', $audition); - - // Deal with mass decline doubler request - if ($request->input('decline-below')) { - Cache::forget('audition'.$audition->id.'seating'); - - $changes_made = false; - foreach ($entries as $entry) { - $doublerData = $this->doublerService->entryDoublerData($entry); - if ($doublerData && ! $entry->hasFlag('declined') && $entry->rank > $request->input('decline-below')) { - try { - $this->decider->decline($entry); - $changes_made = true; - } catch (AuditionAdminException $e) { - return redirect()->back()->with('error', $e->getMessage()); - } - } - } - if ($changes_made) { - $cache_key = 'event'.$audition->event_id.'doublers-seating'; - Cache::forget($cache_key); - - return redirect()->back(); - } - } - - $entries->load('student.school'); - $entries->load('student.doublerRequests'); - $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; - } - // No Shows are fully scored - if ($entry->hasFlag('no_show')) { - $fullyScored = true; - } - $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, - 'hasBonusScores' => $entry->bonus_scores_count > 0, - 'doubleData' => $doublerData, - 'doublerRequest' => $entry->student->doublerRequests()->where('event_id', - $audition->event_id)->first()?->request, - ]; - // 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; - } - if ($entry->hasFlag('failed_prelim')) { - return true; - } - - return false; - }); - } + $canSeat = ! $auditionHasUnresolvedDoublers && $unscored_entries->count() === 0; return view('tabulation.auditionSeating', - compact('entryData', 'audition', 'rightPanel', 'seatableEntries', 'requestedEnsembleAccepts')); + compact('audition', + 'scored_entries', + 'unscored_entries', + 'noshow_entries', + 'failed_prelim_entries', + 'doublerData', + 'auditionHasUnresolvedDoublers', + 'canSeat', + 'seatingProposal', + 'publishedSeats', + ) + ); } - protected function pickRightPanel(Audition $audition, array $seatable) + public function declineSeat(Audition $audition, Entry $entry) { - if ($audition->hasFlag('seats_published')) { - $resultsWindow = new GetAuditionSeats; - $rightPanel['view'] = 'tabulation.auditionSeating-show-published-seats'; - $rightPanel['data'] = $resultsWindow($audition); + $entry->addFlag('declined'); + Cache::forget('rank_seating_'.$entry->audition_id); - return $rightPanel; + return redirect()->route('seating.audition', ['audition' => $audition->id])->with('success', + $entry->student->full_name().' has declined '.$audition->name); + } + + public function massDecline(Audition $audition) + { + $validData = request()->validate([ + 'decline-below' => ['required', 'integer', 'min:0'], + ]); + $ranker = app(RankAuditionEntries::class); + // Get scored entries in order + $scored_entries = $ranker($audition, 'seating'); + $scored_entries->load(['student.doublers', 'student.school']); + foreach ($scored_entries as $entry) { + Debugbar::info('Starting entry '.$entry->student->full_name()); + if ($entry->hasFlag('declined')) { + Debugbar::info('Skipping '.$entry->student->full_name().' because they have already been declined'); + + continue; + } + if (! $entry->student->isDoublerInEvent($audition->event_id)) { + Debugbar::info('Skipping '.$entry->student->full_name().' because they are not a doubler'); + + continue; + } + if ($entry->student->doublers->where('event_id', $audition->event_id)->first()->accepted_entry) { + Debugbar::info('Skipping '.$entry->student->full_name().' because they have already accepted a seat'); + + continue; + } + $entry->addFlag('declined'); } - if ($seatable['allScored'] == false || $seatable['doublersResolved'] == false) { - $rightPanel['view'] = 'tabulation.auditionSeating-unable-to-seat-card'; - $rightPanel['data'] = $seatable; + Cache::forget('rank_seating_'.$entry->audition_id); - return $rightPanel; + return redirect()->route('seating.audition', ['audition' => $audition->id]); + + } + + public function acceptSeat( + Audition $audition, + Entry $entry + ) { + $doublerData = Doubler::findDoubler($entry->student_id, $audition->event_id); + foreach ($doublerData->entries() as $doublerEntry) { + if (! $doublerEntry->totalScore && ! $doublerEntry->hasFlag('declined') && ! $doublerEntry->hasFlag('no_show') && ! $doublerEntry->hasFlag('failed_prelim')) { + return redirect()->route('seating.audition', ['audition' => $audition->id])->with('error', + 'Cannot accept seating for '.$entry->student->full_name().' because student has unscored entries'); + } + } + foreach ($doublerData->entries() as $doublerEntry) { + Cache::forget('rank_seating_'.$doublerEntry->audition_id); + if ($doublerEntry->id !== $entry->id && ! $doublerEntry->hasFlag('no_show') && ! $doublerEntry->hasFlag('failed_prelim') && ! $doublerEntry->hasFlag('declined')) { + $doublerEntry->addFlag('declined'); + } } - $rightPanel['view'] = 'tabulation.auditionSeating-right-complete-not-published'; - $rightPanel['data'] = $this->auditionService->getSeatingLimits($audition); + return redirect()->route('seating.audition', ['audition' => $audition->id])->with('success', + $entry->student->full_name().' has accepted '.$audition->name); + } - return $rightPanel; + public function noshow( + Audition $audition, + Entry $entry + ) { + $recorder = app('App\Actions\Tabulation\EnterNoShow'); + try { + $msg = $recorder($entry); + } catch (AuditionAdminException $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + + return redirect()->route('seating.audition', [$audition])->with('success', $msg); + } + + public function draftSeats( + Audition $audition, + Request $request + ) { + $ranker = app(RankAuditionEntries::class); + $validated = $request->validate([ + 'ensemble' => ['required', 'array'], + 'ensemble.*' => ['required', 'integer', 'min:0'], + ]); + $proposedSeatingArray = []; + $rankedEntries = $ranker($audition, 'seating'); + $rankedEntries = $rankedEntries->reject(function ($entry) { + return $entry->hasFlag('declined'); + }); + + $rankedEntries->load(['student.school']); + $rankedEnembles = Ensemble::orderBy('rank')->where('event_id', $audition->event_id)->get(); + $ensembleRankOn = 1; + foreach ($rankedEnembles as $ensemble) { + if (! Arr::has($validated['ensemble'], $ensemble->id)) { + continue; + } + $proposedSeatingArray[$ensembleRankOn]['ensemble_id'] = $ensemble->id; + $proposedSeatingArray[$ensembleRankOn]['ensemble_name'] = $ensemble->name; + $proposedSeatingArray[$ensembleRankOn]['accept_count'] = $validated['ensemble'][$ensemble->id]; + for ($n = 1; $n <= $validated['ensemble'][$ensemble->id]; $n++) { + // Escape the loop if we're out of entries + if ($rankedEntries->isEmpty()) { + break; + } + + $thisEntry = $rankedEntries->shift(); + $proposedSeatingArray[$ensembleRankOn]['seats'][$n]['seat'] = $n; + $proposedSeatingArray[$ensembleRankOn]['seats'][$n]['entry_id'] = $thisEntry->id; + $proposedSeatingArray[$ensembleRankOn]['seats'][$n]['entry_name'] = $thisEntry->student->full_name(); + $proposedSeatingArray[$ensembleRankOn]['seats'][$n]['entry_school'] = $thisEntry->student->school->name; + } + + $ensembleRankOn++; + } + $sessionKeyName = 'proposedSeatingArray-'.$audition->id; + $request->session()->put($sessionKeyName, $proposedSeatingArray, 10); + + return redirect()->route('seating.audition', ['audition' => $audition->id]); + } + + public function clearDraft( + Audition $audition + ) { + session()->forget('proposedSeatingArray-'.$audition->id); + + return redirect()->route('seating.audition', ['audition' => $audition->id]); + } + + public function publishSeats( + Audition $audition + ) { + $publisher = app('App\Actions\Tabulation\PublishSeats'); + $seatingProposal = (session('proposedSeatingArray-'.$audition->id)); + $proposal = []; + foreach ($seatingProposal as $ensemble) { + $ensembleId = $ensemble['ensemble_id']; + if (isset($ensemble['seats'])) { + foreach ($ensemble['seats'] as $seat) { + $proposal[] = [ + 'ensemble_id' => $ensembleId, + 'audition_id' => $audition->id, + 'seat' => $seat['seat'], + 'entry_id' => $seat['entry_id'], + ]; + } + } + } + $publisher($audition, $proposal); + session()->forget('proposedSeatingArray-'.$audition->id); + + return redirect()->route('seating.audition', [$audition]); + } + + public function unpublishSeats( + Audition $audition + ) { + $unpublisher = app('App\Actions\Tabulation\UnpublishSeats'); + $unpublisher($audition); + session()->forget('proposedSeatingArray-'.$audition->id); + + return redirect()->route('seating.audition', [$audition]); } } diff --git a/app/Http/Controllers/Tabulation/SeatAuditionFormControllerOLD.php b/app/Http/Controllers/Tabulation/SeatAuditionFormControllerOLD.php new file mode 100644 index 0000000..9e27ec4 --- /dev/null +++ b/app/Http/Controllers/Tabulation/SeatAuditionFormControllerOLD.php @@ -0,0 +1,180 @@ +calc = $calc; + $this->ranker = $ranker; + $this->doublerService = $doublerService; + $this->entryService = $entryService; + $this->auditionService = $auditionService; + $this->decider = $decider; + } + + public function __invoke(Request $request, Audition $audition) + { + // If a seating proposal was posted, deal wth it + if ($request->method() == 'POST' && $request->input('ensembleAccept')) { + $requestedEnsembleAccepts = $request->input('ensembleAccept'); + } else { + $requestedEnsembleAccepts = false; + } + + // Deal with a mass no-show request + if ($request->input('mass-no-show')) { + $entries = $audition->entries()->forSeating()->withCount('scoreSheets')->with('flags')->get(); + foreach ($entries as $entry) { + if ($entry->scoreSheets_count == 0 && ! $entry->hasFlag('no_show')) { + $entry->addFlag('no_show'); + } + Cache::forget('entryScore-'.$entry->id.'-seating'); + Cache::forget('entryScore-'.$entry->id.'-advancement'); + } + Cache::forget('audition'.$audition->id.'seating'); + Cache::forget('audition'.$audition->id.'advancement'); + } + + $entryData = []; + $entries = $this->ranker->rank('seating', $audition); + + // Deal with mass decline doubler request + if ($request->input('decline-below')) { + Cache::forget('audition'.$audition->id.'seating'); + + $changes_made = false; + foreach ($entries as $entry) { + $doublerData = $this->doublerService->entryDoublerData($entry); + if ($doublerData && ! $entry->hasFlag('declined') && $entry->rank > $request->input('decline-below')) { + try { + $this->decider->decline($entry); + $changes_made = true; + } catch (AuditionAdminException $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + } + if ($changes_made) { + $cache_key = 'event'.$audition->event_id.'doublers-seating'; + Cache::forget($cache_key); + + return redirect()->back(); + } + } + + $entries->load('student.school'); + $entries->load('student.doublerRequests'); + $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; + } + // No Shows are fully scored + if ($entry->hasFlag('no_show')) { + $fullyScored = true; + } + $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, + 'hasBonusScores' => $entry->bonus_scores_count > 0, + 'doubleData' => $doublerData, + 'doublerRequest' => $entry->student->doublerRequests()->where('event_id', + $audition->event_id)->first()?->request, + ]; + // 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; + } + if ($entry->hasFlag('failed_prelim')) { + 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; + } +} diff --git a/app/Http/Controllers/Tabulation/SeatingStatusController.php b/app/Http/Controllers/Tabulation/SeatingStatusController.php index 19f5a0c..e31721f 100644 --- a/app/Http/Controllers/Tabulation/SeatingStatusController.php +++ b/app/Http/Controllers/Tabulation/SeatingStatusController.php @@ -2,9 +2,11 @@ namespace App\Http\Controllers\Tabulation; +use App\Actions\Tabulation\CalculateAuditionScores; use App\Http\Controllers\Controller; use App\Models\Audition; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; class SeatingStatusController extends Controller { @@ -13,6 +15,25 @@ class SeatingStatusController extends Controller */ public function __invoke(Request $request) { + // Total auditions scores if we haven't done it lately + if (! Cache::has('seating_status_audition_totaler_throttle')) { + $lock = Cache::lock('seating_status_audition_totaler_lock'); + + if ($lock->get()) { + try { + $totaler = app(CalculateAuditionScores::class); + foreach (Audition::forSeating()->with('judges')->get() as $audition) { + $totaler($audition); + } + + // set throttle + Cache::put('seating_status_audition_totaler_throttle', true, 15); + } finally { + $lock->release(); + } + } + } + $auditions = Audition::forSeating() ->withCount([ 'entries' => function ($query) { @@ -25,6 +46,7 @@ class SeatingStatusController extends Controller }, ]) ->with('flags') + ->with('entries') ->get(); $auditionData = []; foreach ($auditions as $audition) { @@ -36,7 +58,7 @@ class SeatingStatusController extends Controller '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, + '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, diff --git a/app/Models/AuditLogEntry.php b/app/Models/AuditLogEntry.php index 205fd5b..8208348 100644 --- a/app/Models/AuditLogEntry.php +++ b/app/Models/AuditLogEntry.php @@ -2,6 +2,7 @@ namespace App\Models; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -15,7 +16,7 @@ class AuditLogEntry extends Model public function getCreatedAtAttribute($value) { - return \Carbon\Carbon::parse($value) + return Carbon::parse($value) ->setTimezone('America/Chicago') ->format('M j, Y H:i:s'); } diff --git a/app/Models/Audition.php b/app/Models/Audition.php index ecf32f9..d4d6e26 100644 --- a/app/Models/Audition.php +++ b/app/Models/Audition.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; use function in_array; @@ -35,9 +36,12 @@ class Audition extends Model public function unscoredEntries(): HasMany { return $this->hasMany(Entry::class) - ->whereDoesntHave('scoreSheets') + ->whereDoesntHave('totalScore') ->whereDoesntHave('flags', function ($query) { $query->where('flag_name', 'no_show'); + }) + ->whereDoesntHave('flags', function ($query) { + $query->where('flag_name', 'failed_prelim'); }); } @@ -56,6 +60,15 @@ class Audition extends Model return $this->belongsToMany(BonusScoreDefinition::class, 'bonus_score_audition_assignment'); } + public function SeatingLimits(): HasMany + { + return $this->hasMany(SeatingLimit::class) + ->with('ensemble') + ->join('ensembles', 'seating_limits.ensemble_id', '=', 'ensembles.id') + ->orderBy('ensembles.rank') + ->select('seating_limits.*'); + } + public function display_fee(): string { return '$'.number_format($this->entry_fee / 100, 2); @@ -129,6 +142,17 @@ class Audition extends Model return $this->hasMany(Seat::class); } + public function getDoublerEntries(): Collection + { + return $this->entries() + ->whereIn('student_id', function ($query) { + $query->select('student_id') + ->from('doubler_entry_counts') + ->where('event_id', $this->event_id); + }) + ->get(); + } + public function scopeOpen(Builder $query): void { $currentDate = Carbon::now('America/Chicago'); diff --git a/app/Models/BonusScore.php b/app/Models/BonusScore.php index ece28cb..0423629 100644 --- a/app/Models/BonusScore.php +++ b/app/Models/BonusScore.php @@ -4,28 +4,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Support\Facades\Cache; class BonusScore extends Model { protected $guarded = []; - protected static function boot() - { - parent::boot(); - static::created(function ($bonusScore) { - $bonusScore->deleteRelatedCalculatedScores(); - }); - - static::updated(function ($bonusScore) { - $bonusScore->deleteRelatedCalculatedScores(); - }); - - static::deleted(function ($bonusScore) { - $bonusScore->deleteRelatedCalculatedScores(); - }); - } - public function entry(): BelongsTo { return $this->belongsTo(Entry::class); @@ -40,16 +23,4 @@ class BonusScore extends Model { return $this->belongsTo(Entry::class, 'originally_scored_entry'); } - - public function deleteRelatedCalculatedScores(): void - { - $entry = $this->entry; - if ($entry) { - $entry->calculatedScores()->delete(); - Cache::forget('entryScore-'.$entry->id.'-seating'); - Cache::forget('entryScore-'.$entry->id.'-advancement'); - Cache::forget('audition'.$entry->audition_id.'seating'); - Cache::forget('audition'.$entry->audition_id.'advancement'); - } - } } diff --git a/app/Models/CalculatedScore.php b/app/Models/CalculatedScore.php deleted file mode 100644 index f514d8d..0000000 --- a/app/Models/CalculatedScore.php +++ /dev/null @@ -1,15 +0,0 @@ - 'json']; -} diff --git a/app/Models/Doubler.php b/app/Models/Doubler.php new file mode 100644 index 0000000..ad7c4f9 --- /dev/null +++ b/app/Models/Doubler.php @@ -0,0 +1,104 @@ + 'array', + ]; + + public function student(): BelongsTo + { + return $this->belongsTo(Student::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function entries() + { + return Entry::whereIn('id', $this->entries)->get(); + } + + // Find a doubler based on both keys + public static function findDoubler($studentId, $eventId) + { + return static::where('student_id', $studentId) + ->where('event_id', $eventId) + ->first(); + } + + /** + * Sync doubler records for a specified event + */ + public static function syncForEvent($eventId): void + { + + if ($eventId instanceof Event) { + $eventId = $eventId->id; + } + + // Get students with multiple entries in this event's auditions + $studentsWithMultipleEntries = Student::query() + ->select('students.id') + ->join('entries', 'students.id', '=', 'entries.student_id') + ->join('auditions', 'entries.audition_id', '=', 'auditions.id') + ->where('auditions.event_id', $eventId) + ->groupBy('students.id') + ->havingRaw('COUNT(entries.id) > 1') + ->with('entries') + ->get(); + Doubler::where('event_id', $eventId)->delete(); + foreach ($studentsWithMultipleEntries as $student) { + // Get entries that are not declined. If only one, they're our accepted entry. + $entryList = collect(); // List of entry ids for th is student in this event + $undecidedEntries = collect(); // List of entry ids that are not declined, no-show, or failed prelim + $entryList = $student->entriesForEvent($eventId)->pluck('id'); + $undecidedEntries = $student->entriesForEvent($eventId)->filter(function ($entry) { + return ! $entry->hasFlag('declined') + && ! $entry->hasFlag('no_show') + && ! $entry->hasFlag('failed_prelim'); + })->pluck('id'); + if ($undecidedEntries->count() < 2) { + $acceptedEntryId = $undecidedEntries->first(); + } else { + $acceptedEntryId = null; + } + + // Create or update the doubler record + static::create([ + 'student_id' => $student->id, + 'event_id' => $eventId, + 'entries' => $entryList, + 'accepted_entry' => $acceptedEntryId, + ]); + + } + + // remove doubler records for students who no longer have multiple entries + static::where('event_id', $eventId) + ->whereNotIn('student_id', $studentsWithMultipleEntries->pluck('id')) + ->delete(); + } + + public static function syncDoublers(): void + { + $events = Event::all(); + foreach ($events as $event) { + static::syncForEvent($event); + } + } +} diff --git a/app/Models/DoublerEntryCount.php b/app/Models/DoublerEntryCount.php new file mode 100644 index 0000000..38ffb02 --- /dev/null +++ b/app/Models/DoublerEntryCount.php @@ -0,0 +1,21 @@ +belongsTo(Student::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } +} diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 2bcde85..b7c0e82 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -2,7 +2,9 @@ namespace App\Models; +use App\Actions\Tabulation\RankAuditionEntries; use App\Enums\EntryFlags; +use App\Exceptions\AuditionAdminException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,16 +19,45 @@ class Entry extends Model protected $guarded = []; - protected $hasCheckedScoreSheets = false; - - public $final_scores_array; // Set by TabulationService - - public $scoring_complete; // Set by TabulationService - - public $is_doubler; // Set by DoublerService - protected $with = ['flags']; + public function totalScore(): HasOne + { + return $this->hasOne(EntryTotalScore::class); + } + + /** + * @throws AuditionAdminException + */ + public function rank(string $type) + { + $ranker = app(RankAuditionEntries::class); + + if ($type !== 'seating' && $type !== 'advancement') { + throw new AuditionAdminException('Invalid type specified. Must be either seating or advancement.'); + } + + // Return false if no score. If we have no score, we can't have a rank + if (! $this->totalScore) { + return false; + } + + // Get the ranked entries for this entries audition + $rankedEntries = $ranker($this->audition, $type); + + // If we're looking for seating rank, return the rank from the list of ranked entries + if ($type === 'seating') { + return $rankedEntries->where('id', $this->id)->first()->seatingRank; + } + + // Find position of current entry in the ranked entries (1-based index) + $position = $rankedEntries->search(fn ($entry) => $entry->id === $this->id); + + // Return false if entry not found, otherwise return 1-based position + return $position === false ? false : $position + 1; + + } + public function student(): BelongsTo { return $this->belongsTo(Student::class); @@ -98,7 +129,7 @@ class Entry extends Model public function removeFlag($flag): void { - // remove related auditionFlag where flag_name = $flag + // remove the related auditionFlag where flag_name = $flag $this->flags()->where('flag_name', $flag)->delete(); $this->load('flags'); } @@ -120,11 +151,6 @@ class Entry extends Model return $this->hasOne(Seat::class); } - public function calculatedScores(): HasMany - { - return $this->hasMany(CalculatedScore::class); - } - public function scopeForSeating(Builder $query): void { $query->where('for_seating', 1); diff --git a/app/Models/EntryFlag.php b/app/Models/EntryFlag.php index 4e2db12..ee1eaa0 100644 --- a/app/Models/EntryFlag.php +++ b/app/Models/EntryFlag.php @@ -15,36 +15,8 @@ class EntryFlag extends Model 'flag_name' => EntryFlags::class, ]; - protected static function boot() - { - parent::boot(); - static::created(function ($flag) { - $flag->deleteRelatedCalculatedScores(); - }); - - static::updated(function ($flag) { - $flag->deleteRelatedCalculatedScores(); - }); - - static::deleted(function ($flag) { - $flag->deleteRelatedCalculatedScores(); - }); - } - public function entry(): BelongsTo { return $this->belongsTo(Entry::class); } - - public function deleteRelatedCalculatedScores(): void - { - $entry = $this->entry; - if ($entry) { - $entry->calculatedScores()->delete(); - Cache::forget('entryScore-'.$entry->id.'-seating'); - Cache::forget('entryScore-'.$entry->id.'-advancement'); - Cache::forget('audition'.$entry->audition_id.'seating'); - Cache::forget('audition'.$entry->audition_id.'advancement'); - } - } } diff --git a/app/Models/EntryTotalScore.php b/app/Models/EntryTotalScore.php new file mode 100644 index 0000000..9ec59ea --- /dev/null +++ b/app/Models/EntryTotalScore.php @@ -0,0 +1,22 @@ + 'json', + 'advancement_subscore_totals' => 'json', + ]; + + public function entry(): BelongsTo + { + return $this->belongsTo(Entry::class); + } +} diff --git a/app/Models/ScoreSheet.php b/app/Models/ScoreSheet.php index b8b4698..e025272 100644 --- a/app/Models/ScoreSheet.php +++ b/app/Models/ScoreSheet.php @@ -5,7 +5,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOneThrough; -use Illuminate\Support\Facades\Cache; class ScoreSheet extends Model { @@ -13,26 +12,12 @@ class ScoreSheet extends Model 'user_id', 'entry_id', 'subscores', + 'seating_total', + 'advancement_total', ]; protected $casts = ['subscores' => 'json']; - protected static function boot() - { - parent::boot(); - static::created(function ($scoreSheet) { - $scoreSheet->deleteRelatedCalculatedScores(); - }); - - static::updated(function ($scoreSheet) { - $scoreSheet->deleteRelatedCalculatedScores(); - }); - - static::deleted(function ($scoreSheet) { - $scoreSheet->deleteRelatedCalculatedScores(); - }); - } - public function entry(): BelongsTo { return $this->belongsTo(Entry::class); @@ -60,16 +45,4 @@ class ScoreSheet extends Model return $this->subscores[$id]['score'] ?? false; // this function is used at resources/views/tabulation/entry_score_sheet.blade.php } - - public function deleteRelatedCalculatedScores(): void - { - $entry = $this->entry; - if ($entry) { - $entry->calculatedScores()->delete(); - Cache::forget('entryScore-'.$entry->id.'-seating'); - Cache::forget('entryScore-'.$entry->id.'-advancement'); - Cache::forget('audition'.$entry->audition_id.'seating'); - Cache::forget('audition'.$entry->audition_id.'advancement'); - } - } } diff --git a/app/Models/Student.php b/app/Models/Student.php index 2554aa2..f68c4b4 100644 --- a/app/Models/Student.php +++ b/app/Models/Student.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Support\Collection; class Student extends Model { @@ -50,6 +51,9 @@ class Student extends Model return $this->hasMany(HistoricalSeat::class); } + /** + * Returns the directors at this student's school. + */ public function users(): HasManyThrough { return $this->hasManyThrough( @@ -62,6 +66,11 @@ class Student extends Model ); } + /** + * Returns the directors at this student's school. + * Alias of users()) + * ' + */ public function directors(): HasManyThrough { return $this->users(); @@ -85,4 +94,34 @@ class Student extends Model { return $this->hasMany(DoublerRequest::class); } + + public function doublers(): HasMany + { + return $this->hasMany(Doubler::class); + } + + public function isDoublerInEvent(Event|int $event): bool + { + $eventId = $event instanceof Event ? $event->id : $event; + + return Doubler::where([ + 'event_id' => $eventId, + 'student_id' => $this->id, + ])->exists(); + } + + public function entriesForEvent(Event|int $event): Collection + { + $eventId = $event instanceof Event ? $event->id : $event; + + return Entry::query() + ->where('student_id', $this->id) + ->whereHas('audition', function ($query) use ($event) { + $query->where('event_id', $event); + }) + ->with('audition.SeatingLimits') // Eager load the audition relation if needed + ->with('totalScore') + ->get(); + + } } diff --git a/app/Models/User.php b/app/Models/User.php index 1c6907b..78968d5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -127,7 +127,11 @@ class User extends Authenticatable implements MustVerifyEmail public function isJudge(): bool { - return $this->judgingAssignments()->count() > 0 || $this->bonusJudgingAssignments()->count() > 0; + return once(function () { + return $this->judgingAssignments()->count() > 0 + || $this->bonusJudgingAssignments()->count() > 0; + }); + } public function possibleSchools(): Collection diff --git a/app/Observers/BonusScoreObserver.php b/app/Observers/BonusScoreObserver.php new file mode 100644 index 0000000..79878fd --- /dev/null +++ b/app/Observers/BonusScoreObserver.php @@ -0,0 +1,52 @@ +entry, true); + } + + /** + * Handle the ScoreSheet "updated" event. + */ + public function updated(BonusScore $bonusScore): void + { + $calculator = app(TotalEntryScores::class); + $calculator($bonusScore->entry, true); + } + + /** + * Handle the ScoreSheet "deleted" event. + */ + public function deleted(BonusScore $bonusScore): void + { + $calculator = app(TotalEntryScores::class); + $calculator($bonusScore->entry, true); + } + + /** + * Handle the ScoreSheet "restored" event. + */ + public function restored(BonusScore $bonusScore): void + { + // + } + + /** + * Handle the ScoreSheet "force deleted" event. + */ + public function forceDeleted(BonusScore $bonusScore): void + { + // + } +} diff --git a/app/Observers/EntryFlagObserver.php b/app/Observers/EntryFlagObserver.php new file mode 100644 index 0000000..7747ca1 --- /dev/null +++ b/app/Observers/EntryFlagObserver.php @@ -0,0 +1,50 @@ +student->entriesForEvent($entry->audition->event_id)->count(); + + // If less than two entries, they're not a doubler + if ($count < 2) { + return; + } + + // Update doublers for the event + Doubler::syncForEvent($entry->audition->event_id); } @@ -21,7 +31,12 @@ class EntryObserver */ public function updated(Entry $entry): void { - + // Update doubler table when an entry is updated + Doubler::syncForEvent($entry->audition->event_id); + if ($entry->wasChanged('audition_id')) { + $originalData = $entry->getOriginal(); + Doubler::syncForEvent($originalData->audition->event_id); + } } /** @@ -29,7 +44,9 @@ class EntryObserver */ public function deleted(Entry $entry): void { - + Doubler::where('student_id', $entry->student_id)->delete(); + $audition = Audition::where('id', $entry->audition_id)->first(); + Doubler::syncForEvent($audition->event_id); } /** diff --git a/app/Observers/ScoreSheetObserver.php b/app/Observers/ScoreSheetObserver.php index ceb8711..5ade31e 100644 --- a/app/Observers/ScoreSheetObserver.php +++ b/app/Observers/ScoreSheetObserver.php @@ -2,7 +2,7 @@ namespace App\Observers; -use App\Events\ScoreSheetChange; +use App\Actions\Tabulation\TotalEntryScores; use App\Models\ScoreSheet; class ScoreSheetObserver @@ -12,7 +12,8 @@ class ScoreSheetObserver */ public function created(ScoreSheet $scoreSheet): void { - // + $calculator = app(TotalEntryScores::class); + $calculator($scoreSheet->entry, true); } /** @@ -20,7 +21,8 @@ class ScoreSheetObserver */ public function updated(ScoreSheet $scoreSheet): void { - // + $calculator = app(TotalEntryScores::class); + $calculator($scoreSheet->entry, true); } /** @@ -28,7 +30,8 @@ class ScoreSheetObserver */ public function deleted(ScoreSheet $scoreSheet): void { - // + $calculator = app(TotalEntryScores::class); + $calculator($scoreSheet->entry, true); } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5211a7e..744c46e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,11 +5,10 @@ namespace App\Providers; use App\Actions\Entries\CreateEntry; use App\Actions\Entries\UpdateEntry; use App\Actions\Schools\SetHeadDirector; -use App\Actions\Tabulation\AllowForOlympicScoring; -use App\Actions\Tabulation\CalculateEntryScore; +use App\Actions\Tabulation\CalculateAuditionScores; use App\Actions\Tabulation\CalculateScoreSheetTotal; use App\Actions\Tabulation\CalculateScoreSheetTotalDivideByTotalWeights; -use App\Actions\Tabulation\CalculateScoreSheetTotalDivideByWeightedPossible; +use App\Actions\Tabulation\TotalEntryScores; use App\Http\Controllers\NominationEnsembles\NominationAdminController; use App\Http\Controllers\NominationEnsembles\NominationEnsembleController; use App\Http\Controllers\NominationEnsembles\NominationEnsembleEntryController; @@ -19,7 +18,9 @@ use App\Http\Controllers\NominationEnsembles\ScobdaNominationEnsembleController; use App\Http\Controllers\NominationEnsembles\ScobdaNominationEnsembleEntryController; use App\Http\Controllers\NominationEnsembles\ScobdaNominationSeatingController; use App\Models\Audition; +use App\Models\BonusScore; use App\Models\Entry; +use App\Models\EntryFlag; use App\Models\Room; use App\Models\RoomUser; use App\Models\School; @@ -30,6 +31,8 @@ use App\Models\Student; use App\Models\SubscoreDefinition; use App\Models\User; use App\Observers\AuditionObserver; +use App\Observers\BonusScoreObserver; +use App\Observers\EntryFlagObserver; use App\Observers\EntryObserver; use App\Observers\RoomObserver; use App\Observers\RoomUserObserver; @@ -58,8 +61,6 @@ class AppServiceProvider extends ServiceProvider { //$this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotal::class); //$this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotalDivideByTotalWeights::class); - $this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotalDivideByWeightedPossible::class); - $this->app->singleton(CalculateEntryScore::class, AllowForOlympicScoring::class); $this->app->singleton(DrawService::class, DrawService::class); $this->app->singleton(AuditionService::class, AuditionService::class); $this->app->singleton(EntryService::class, EntryService::class); @@ -69,6 +70,8 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(CreateEntry::class, CreateEntry::class); $this->app->singleton(UpdateEntry::class, UpdateEntry::class); $this->app->singleton(SetHeadDirector::class, SetHeadDirector::class); + $this->app->singleton(TotalEntryScores::class, TotalEntryScores::class); + $this->app->singleton(CalculateAuditionScores::class, CalculateAuditionScores::class); // Nomination Ensemble // $this->app->bind(NominationEnsembleController::class, ScobdaNominationEnsembleController::class); @@ -82,6 +85,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + BonusScore::observe(BonusScoreObserver::class); Entry::observe(EntryObserver::class); Audition::observe(AuditionObserver::class); Room::observe(RoomObserver::class); @@ -93,7 +97,8 @@ class AppServiceProvider extends ServiceProvider SubscoreDefinition::observe(SubscoreDefinitionObserver::class); User::observe(UserObserver::class); SeatingLimit::observe(SeatingLimitObserver::class); + EntryFlag::observe(EntryFlagObserver::class); - //Model::preventLazyLoading(! app()->isProduction()); + Model::preventLazyLoading(! app()->isProduction()); } } diff --git a/app/Settings.php b/app/Settings.php index d26e314..ac4f15e 100644 --- a/app/Settings.php +++ b/app/Settings.php @@ -3,10 +3,11 @@ namespace App; use App\Models\SiteSetting; -use Illuminate\Support\Facades\Cache; class Settings { + public static $settings = null; + protected static $cacheKey = 'site_settings'; public static function __callStatic($key, $arguments) @@ -14,19 +15,23 @@ class Settings return self::get($key); } - // Load settings from the database and cache them + // Load settings from the database public static function loadSettings() { - $settings = SiteSetting::all()->pluck('setting_value', 'setting_key')->toArray(); - Cache::put(self::$cacheKey, $settings, 3600); // Cache for 1 hour + if (self::$settings === null) { + self::$settings = SiteSetting::all()->pluck('setting_value', 'setting_key')->toArray(); + } + } // Get a setting value by key public static function get($key, $default = null) { - $settings = Cache::get(self::$cacheKey, []); + if (self::$settings === null) { + self::loadSettings(); + } - return $settings[$key] ?? $default; + return self::$settings[$key] ?? $default; } // Set a setting value by key @@ -35,15 +40,17 @@ class Settings // Update the database SiteSetting::updateOrCreate(['setting_key' => $key], ['setting_value' => $value]); - // Update the cache - $settings = Cache::get(self::$cacheKey, []); - $settings[$key] = $value; - Cache::put(self::$cacheKey, $settings, 3600); // Cache for 1 hour + // Update the static property + if (self::$settings === null) { + self::loadSettings(); + } + self::$settings[$key] = $value; + } - // Clear the cache - public static function clearCache() + // Clear the settings + public static function clearSettings() { - Cache::forget(self::$cacheKey); + self::$settings = null; } } diff --git a/database/migrations/2025_06_11_043508_add_sheet_total_column_to_score_sheets_table.php b/database/migrations/2025_06_11_043508_add_sheet_total_column_to_score_sheets_table.php new file mode 100644 index 0000000..16a372a --- /dev/null +++ b/database/migrations/2025_06_11_043508_add_sheet_total_column_to_score_sheets_table.php @@ -0,0 +1,32 @@ +decimal('seating_total', 9, 6)->after('subscores'); + }); + Schema::table('score_sheets', function (Blueprint $table) { + $table->decimal('advancement_total', 9, 6)->after('seating_total'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('score_sheets', function (Blueprint $table) { + $table->dropColumn('seating_total'); + $table->dropColumn('advancement_total'); + }); + } +}; diff --git a/database/migrations/2025_06_12_034045_drop_caculated_scores_table.php b/database/migrations/2025_06_12_034045_drop_caculated_scores_table.php new file mode 100644 index 0000000..5dd6b4d --- /dev/null +++ b/database/migrations/2025_06_12_034045_drop_caculated_scores_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignIdFor(Entry::class)->constrained()->cascadeOnDelete()->cascadeOnUpdate(); + $table->string('mode'); + $table->json('calculatedScore'); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_06_12_042434_create_entry_total_scores_table.php b/database/migrations/2025_06_12_042434_create_entry_total_scores_table.php new file mode 100644 index 0000000..467c2dd --- /dev/null +++ b/database/migrations/2025_06_12_042434_create_entry_total_scores_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignIdFor(Entry::class)->constrained()->cascadeOnDelete()->cascadeOnUpdate(); + $table->decimal('seating_total', 9, 6); + $table->decimal('advancement_total', 9, 6); + $table->json('seating_subscore_totals'); + $table->json('advancement_subscore_totals'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entry_total_scores'); + } +}; diff --git a/database/migrations/2025_06_14_142507_doubler_entry_counts.php b/database/migrations/2025_06_14_142507_doubler_entry_counts.php new file mode 100644 index 0000000..886aa51 --- /dev/null +++ b/database/migrations/2025_06_14_142507_doubler_entry_counts.php @@ -0,0 +1,35 @@ + 1 + '); + + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/2025_06_20_175904_create_doublers_table.php b/database/migrations/2025_06_20_175904_create_doublers_table.php new file mode 100644 index 0000000..60208c9 --- /dev/null +++ b/database/migrations/2025_06_20_175904_create_doublers_table.php @@ -0,0 +1,39 @@ +foreignIdFor(Student::class)->constrained()->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignIdFor(Event::class)->constrained()->cascadeOnDelete()->cascadeOnUpdate(); + + // Doubler Specific Fields + $table->json('entries')->nullable(); + $table->foreignIdFor(Entry::class, 'accepted_entry')->nullable()->constrained('entries')->cascadeOnDelete()->cascadeOnUpdate(); + + // Set the composite primary key + $table->primary(['student_id', 'event_id']); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('doublers'); + } +}; diff --git a/database/migrations/2025_06_26_131356_add_bonus_columns_to_entry_total_scores.php b/database/migrations/2025_06_26_131356_add_bonus_columns_to_entry_total_scores.php new file mode 100644 index 0000000..54c4c08 --- /dev/null +++ b/database/migrations/2025_06_26_131356_add_bonus_columns_to_entry_total_scores.php @@ -0,0 +1,32 @@ +decimal('bonus_total', 9, 6)->nullable()->after('advancement_subscore_totals'); + $table->decimal('seating_total_with_bonus', 9, 6) + ->storedAs('seating_total + COALESCE(bonus_total, 0)') + ->after('bonus_total'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('entry_total_scores', function (Blueprint $table) { + $table->dropColumn('bonus_total'); + $table->dropColumn('seating_total_with_bonus'); + }); + } +}; diff --git a/database/migrations/2025_06_26_155129_change_audit_log_entries_message_column_to_text.php b/database/migrations/2025_06_26_155129_change_audit_log_entries_message_column_to_text.php new file mode 100644 index 0000000..fde2bcd --- /dev/null +++ b/database/migrations/2025_06_26_155129_change_audit_log_entries_message_column_to_text.php @@ -0,0 +1,28 @@ +text('message')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('audit_log_entries', function (Blueprint $table) { + $table->string('message')->change(); + }); + } +}; diff --git a/database/seeders/EntrySeeder.php b/database/seeders/EntrySeeder.php index 54e710d..c0acbfe 100644 --- a/database/seeders/EntrySeeder.php +++ b/database/seeders/EntrySeeder.php @@ -17,80 +17,87 @@ class EntrySeeder extends Seeder public function run(): void { $students = Student::all(); - $hs_auditions = Audition::where('maximum_grade', '=', '12'); - $freshman_auditions = Audition::where('maximum_grade', '>', '8'); - $jh_auditions = Audition::where('maximum_grade', '=', '9'); - $seventh_auditions = Audition::where('maximum_grade', '=', '7'); foreach ($students as $student) { if ($student->grade > 9) { - $audition = Audition::where('maximum_grade', '=', '12')->inRandomOrder()->first(); + $audition = Audition::where('maximum_grade', '=', '12') + ->inRandomOrder()->first(); } if ($student->grade == 9) { - $audition = Audition::where('maximum_grade', '>', '8')->inRandomOrder()->first(); + $audition = Audition::where('maximum_grade', '>', '8') + ->inRandomOrder()->first(); } if ($student->grade == 8) { - $audition = Audition::where('maximum_grade', '=', '9')->inRandomOrder()->first(); + $audition = Audition::where('maximum_grade', '=', '9') + ->inRandomOrder()->first(); } if ($student->grade == 7) { - $audition = Audition::where('maximum_grade', '=', '7')->inRandomOrder()->first(); + $audition = Audition::where('maximum_grade', '=', '7') + ->inRandomOrder()->first(); } Entry::create([ 'student_id' => $student->id, 'audition_id' => $audition->id, + 'for_seating' => 1, + 'for_advancement' => 1, ]); if (mt_rand(1, 100) > 90) { if ($student->grade > 9) { - $audition2 = Audition::where('maximum_grade', '=', '12')->where('id', '!=', - $audition->id)->inRandomOrder()->first(); + $audition2 = Audition::where('maximum_grade', '=', '12') + ->where('id', '!=', $audition->id) + ->inRandomOrder()->first(); } if ($student->grade == 9) { - $audition2 = Audition::where('maximum_grade', '>', '8')->where('id', '!=', - $audition->id)->inRandomOrder()->first(); + $audition2 = Audition::where('maximum_grade', '>', '8') + ->where('id', '!=', $audition->id) + ->inRandomOrder()->first(); } if ($student->grade == 8) { - $audition2 = Audition::where('maximum_grade', '=', '9')->where('id', '!=', - $audition->id)->inRandomOrder()->first(); + $audition2 = Audition::where('maximum_grade', '=', '9') + ->where('id', '!=', $audition->id) + ->inRandomOrder()->first(); } if ($student->grade == 7) { - $audition2 = Audition::where('maximum_grade', '=', '7')->where('id', '!=', - $audition->id)->inRandomOrder()->first(); + $audition2 = Audition::where('maximum_grade', '=', '7') + ->where('id', '!=', $audition->id) + ->inRandomOrder()->first(); } Entry::create([ 'student_id' => $student->id, 'audition_id' => $audition2->id, + 'for_seating' => 1, + 'for_advancement' => 1, ]); - - // Triplers are possible - if (mt_rand(1, 100) > 90) { - if ($student->grade > 9) { - $audition3 = Audition::where('maximum_grade', '=', '12')->where('id', '!=', - $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); - } - if ($student->grade == 9) { - $audition3 = Audition::where('maximum_grade', '>', '8')->where('id', '!=', - $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); - } - if ($student->grade == 8) { - $audition3 = Audition::where('maximum_grade', '=', '9')->where('id', '!=', - $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); - } - if ($student->grade == 7) { - $audition3 = Audition::where('maximum_grade', '=', '7')->where('id', '!=', - $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); - } - - Entry::create([ - 'student_id' => $student->id, - 'audition_id' => $audition3->id, - ]); - } } + if (mt_rand(1, 100) > 90) { + if ($student->grade > 9) { + $audition3 = Audition::where('maximum_grade', '=', '12')->where('id', '!=', + $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); + } + if ($student->grade == 9) { + $audition3 = Audition::where('maximum_grade', '>', '8')->where('id', '!=', + $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); + } + if ($student->grade == 8) { + $audition3 = Audition::where('maximum_grade', '=', '9')->where('id', '!=', + $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); + } + if ($student->grade == 7) { + $audition3 = Audition::where('maximum_grade', '=', '7')->where('id', '!=', + $audition->id)->where('id', '!=', $audition2->id)->inRandomOrder()->first(); + } + Entry::create([ + 'student_id' => $student->id, + 'audition_id' => $audition3->id, + 'for_seating' => 1, + 'for_advancement' => 1, + ]); + } } } } diff --git a/database/seeders/ScoreAllAuditions.php b/database/seeders/ScoreAllAuditions.php index 776d074..4d90c54 100644 --- a/database/seeders/ScoreAllAuditions.php +++ b/database/seeders/ScoreAllAuditions.php @@ -2,9 +2,9 @@ namespace Database\Seeders; +use App\Actions\Tabulation\EnterScore; use App\Models\User; use Illuminate\Database\Seeder; -use App\Models\ScoreSheet; class ScoreAllAuditions extends Seeder { @@ -13,26 +13,19 @@ class ScoreAllAuditions extends Seeder */ public function run(): void { + $recorder = app(EnterScore::class); $judges = User::all(); - foreach ($judges as $judge) { - foreach ($judge->rooms as $room) { - foreach ($room->auditions as $audition) { - $scoringGuide = $audition->scoringGuide; - $subscores = $scoringGuide->subscores; - foreach ($audition->entries as $entry) { + foreach ($judges as $judge) { // Iterate over all users + foreach ($judge->rooms as $room) { // Iterate over each user's assigned rooms + foreach ($room->auditions as $audition) { // Iterate over each audition in that room + $scoringGuide = $audition->scoringGuide; // Load the scoring guide for that audition + $subscores = $scoringGuide->subscores; // Get the subscores for that audition + foreach ($audition->entries as $entry) { // Iterate over each entry in that audition $scoreArray = []; foreach ($subscores as $subscore) { - $scoreArray[$subscore->id] = [ - 'score' => mt_rand(0, 100), - 'subscore_id' => $subscore->id, - 'subscore_name' => $subscore->name, - ]; + $scoreArray[$subscore->id] = mt_rand(0, $subscore->max_score); } - ScoreSheet::create([ - 'user_id' => $judge->id, - 'entry_id' => $entry->id, - 'subscores' => $scoreArray, - ]); + $recorder($judge, $entry, $scoreArray); } } } diff --git a/resources/views/admin/students/edit.blade.php b/resources/views/admin/students/edit.blade.php index c908aa4..e7df592 100644 --- a/resources/views/admin/students/edit.blade.php +++ b/resources/views/admin/students/edit.blade.php @@ -55,7 +55,7 @@ @foreach($event_entries[$event->id] as $entry) {{ $entry->id }} - {{ $entry->audition->name }} + {{ $entry->audition->name }} {{ $entry->draw_number }} @if($entry->doubler_decision_frozen) diff --git a/resources/views/dashboard/dashboard.blade.php b/resources/views/dashboard/dashboard.blade.php index 7eda747..cf0a19c 100644 --- a/resources/views/dashboard/dashboard.blade.php +++ b/resources/views/dashboard/dashboard.blade.php @@ -27,24 +27,24 @@ @endif - @if($showRecapLink) - - - Audition Score Recaps - - - @endif +{{-- @if($showRecapLink)--}} +{{-- --}} +{{-- --}} +{{-- Audition Score Recaps--}} +{{-- --}} +{{-- --}} +{{-- @endif--}} - @if(Auth::user()->school_id) -
- - My Results - @include('dashboard.results-table') - -
- @endif +{{-- @if(Auth::user()->school_id)--}} +{{--
--}} +{{-- --}} +{{-- My Results--}} +{{-- @include('dashboard.results-table')--}} +{{-- --}} +{{--
--}} +{{-- @endif--}}
diff --git a/resources/views/tabulation/advancement/results-table.blade.php b/resources/views/tabulation/advancement/results-table.blade.php index 0cba0b4..9d3f81e 100644 --- a/resources/views/tabulation/advancement/results-table.blade.php +++ b/resources/views/tabulation/advancement/results-table.blade.php @@ -16,22 +16,15 @@ @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 - {{ $entry->rank }} + {{ $entry->rank('advancement') }} {{ $entry->id }} {{ $entry->draw_number }} {{ $entry->student->full_name() }} {{ $entry->student->school->name }} - {{ $score }} + {{ $entry->totalScore->advancement_total }} @foreach($entry->advancementVotes as $vote) diff --git a/resources/views/tabulation/auditionSeating-doubler-block-OLD.blade.php b/resources/views/tabulation/auditionSeating-doubler-block-OLD.blade.php new file mode 100644 index 0000000..9f7635d --- /dev/null +++ b/resources/views/tabulation/auditionSeating-doubler-block-OLD.blade.php @@ -0,0 +1,55 @@ +@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') +
    + + @foreach($entry['doubleData'] as $double) + @php($isopen = $double['status'] == 'undecided') +
  • + + +
    +
    +
      +
    • +

      Ranked {{ $double['rank'] }}

      +

      {{ $double['unscored_entries'] }} Unscored

      +
    • + @foreach($double['seating_limits'] as $limit) +
    • {{$limit['ensemble_name']}} accepts {{ $limit['accepts'] }}
    • + @endforeach +
    +
    + +
    + @if ($double['status'] == 'undecided') +
    + @csrf + +
    +
    + @csrf + +
    + @endif +
    +
    +
  • + @endforeach + +
+ +{{--Complete Badge--}} +{{--

Complete

--}} + +{{--In Progres Badge--}} +{{--

In Progress

--}} diff --git a/resources/views/tabulation/auditionSeating-doubler-block.blade.php b/resources/views/tabulation/auditionSeating-doubler-block.blade.php index 9f7635d..6b7fcbb 100644 --- a/resources/views/tabulation/auditionSeating-doubler-block.blade.php +++ b/resources/views/tabulation/auditionSeating-doubler-block.blade.php @@ -1,55 +1,41 @@ -@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') -
    +
    {{-- Begin block for doubler entry --}} +
    + {{ $de->audition->name }} #{{$de->draw_number}} + ({{ $de->id }}) +
    + @if($de->hasFlag('no_show')) +
    NO-SHOW
    + @elseif($de->hasFlag('failed_prelim')) +
    Failed Prelim
    + @elseif($de->hasFlag('declined')) +
    Declined
    + @else + @php($unscored = $de->audition->unscoredEntries()->count()) + @if($unscored > 0) +
    {{ $unscored }} Unscored Entries
    + @endif - @foreach($entry['doubleData'] as $double) - @php($isopen = $double['status'] == 'undecided') -
  • -
    -

    - - {{ $double['auditionName'] }} - {{ $double['status'] }} - -

    -
    -
    - -
    -
    + @if(! $de->rank('seating')) +
    THIS ENTRY NOT SCORED
    + @else +
    Ranked: {{ $de->rank('seating') }}
    +
    + Acceptance Limits
    + @foreach ($de->audition->SeatingLimits as $limit) + {{ $limit->ensemble->name }} -> {{ $limit->maximum_accepted }} +
    + @endforeach
    - -
    -
    -
      -
    • -

      Ranked {{ $double['rank'] }}

      -

      {{ $double['unscored_entries'] }} Unscored

      -
    • - @foreach($double['seating_limits'] as $limit) -
    • {{$limit['ensemble_name']}} accepts {{ $limit['accepts'] }}
    • - @endforeach -
    -
    - -
    - @if ($double['status'] == 'undecided') -
    - @csrf - -
    -
    - @csrf - -
    - @endif -
    +
    + {{-- TODO: Don't show the option to accept if it cannot be done --}} + + Accept {{ $de->audition->name }} + + + Decline {{ $de->audition->name }} +
    -
  • - @endforeach + @endif + @endif -
- -{{--Complete Badge--}} -{{--

Complete

--}} - -{{--In Progres Badge--}} -{{--

In Progress

--}} +
diff --git a/resources/views/tabulation/auditionSeating-results-table.blade.php b/resources/views/tabulation/auditionSeating-results-table.blade.php index 41f7691..0e3552d 100644 --- a/resources/views/tabulation/auditionSeating-results-table.blade.php +++ b/resources/views/tabulation/auditionSeating-results-table.blade.php @@ -40,7 +40,7 @@

Request: {{$entry['doublerRequest']}}

@endif - @include('tabulation.auditionSeating-doubler-block') + @include('tabulation.auditionSeating-doubler-block-OLD') {{-- DOUBLER
--}} {{-- @foreach($entry['doubleData'] as $double)--}} {{-- ID: {{ $double['entryId'] }} - {{ $double['name'] }} - {{ $double['rank'] }}
--}} diff --git a/resources/views/tabulation/auditionSeating.blade.php b/resources/views/tabulation/auditionSeating.blade.php index 065ae86..78771ac 100644 --- a/resources/views/tabulation/auditionSeating.blade.php +++ b/resources/views/tabulation/auditionSeating.blade.php @@ -1,29 +1,265 @@ -@inject('doublerService','App\Services\DoublerService') -@php - $blockSeating = [] -@endphp Audition Seating - {{ $audition->name }} -
-
-
- @include('tabulation.auditionSeating-results-table') -
-
- @include($rightPanel['view']) -
-{{--
--}} -{{-- @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--}} +
+ +
{{-- Entry Ranking Table --}} + {{-- Scored Entries --}} + Scored Entries + + + + Rank + ID + Draw # + Student + Doubler + Total Score + @if($audition->bonusScore()->count() > 0) +
+
+ No Bonus Score +
+ @endif +
+ + + + @foreach($scored_entries as $entry) + + {{ $entry->seatingRank }} + {{ $entry->id }} + {{ $entry->draw_number }} + + +
{{ $entry->student->school->name }}
+
+ + @php($doubler = $doublerData->get($entry->student_id)) + @if($doubler) + @if($doubler->accepted_entry == $entry->id) + ACCEPTED + @elseif($entry->hasFlag('declined')) + DECLINED + @else + @if($request = $entry->student->doublerRequests()->where('event_id',$entry->audition->event_id)->first()) +
{{-- Begin block seating request --}} +
+ Request +
+
+

{{ $request->request }}

+
+ +
+ + @endif + @foreach($entry->student->entriesForEvent($entry->audition->event_id) as $de) + @include('tabulation.auditionSeating-doubler-block') + @endforeach + @endif + @endif + + +
+ + @if($audition->bonusScore()->count() > 0) + @if($entry->totalScore->bonus_total) + {{ $entry->totalScore->seating_total_with_bonus }} + @else + {{ $entry->totalScore->seating_total_with_bonus }} + @endif + @else + {{ $entry->totalScore->seating_total }} + @endif + + + @endforeach + +
+
+ + {{-- Unscored Entries --}} + Unscored Entries + + + + Draw # + ID + Student + + + + + @foreach($unscored_entries as $entry) + + {{ $entry->draw_number }} + {{ $entry->id }} + + {{ $entry->student->full_name() }} + {{ $entry->student->school->name }} + + + + Record No Show + + + + @endforeach + + + + + {{-- No Show Entries --}} + No Show Entries + + + + Draw # + ID + Student + + + + @foreach($noshow_entries as $entry) + + {{ $entry->draw_number }} + {{ $entry->id }} + + {{ $entry->student->full_name() }} + {{ $entry->student->school->name }} + + + @endforeach + + + + + {{-- Failed Prelim Entries --}} + Failed Prelim Entries + + + + Draw # + ID + Student + + + + @foreach($failed_prelim_entries as $entry) + + {{ $entry->draw_number }} + {{ $entry->id }} + + {{ $entry->student->full_name() }} + {{ $entry->student->school->name }} + + + @endforeach + + + + + +
+ +
{{-- Right Column Wrapper --}} + @if($audition->hasFlag('seats_published')) + + Published Results + + @php($previousEnsemble = '') + @foreach($publishedSeats as $seat) + @if($previousEnsemble !== $seat->ensemble->name) + @php($previousEnsemble = $seat->ensemble->name) + {{ $seat->ensemble->name }} + @endif + +
+

{{ $seat->seat }}. {{ $seat->student->full_name() }}

+

{{ $seat->student->school->name }}

+
+
+ @endforeach +
+
+ + + Unpublish Results + + @else + @if($canSeat) + @if($seatingProposal) + + + Seating Proposal + Results are not yet published + + @foreach($seatingProposal as $proposedEnsemble) +

{{ $proposedEnsemble['ensemble_name'] }}

+ + @if(isset($proposedEnsemble['seats'])) + @foreach($proposedEnsemble['seats'] as $seat) + {{ $seat['seat'] }} + . {{ $seat['entry_name'] }} + @endforeach + @endif + + @endforeach + + Clear Draft + + + Publish + +
+ @else + + + Seat Audition + Choose how many entries to seat in each ensemble + + + @foreach($audition->SeatingLimits()->where('maximum_accepted','>',0)->get() as $limit) + + {{$limit->ensemble->name}} + @for($n = 0; $n< $limit->maximum_accepted; $n++) + + @endfor + + + @endforeach + Draft Seats + + + @endif + @else +
+ @if($unscored_entries->count() > 0) + + Cannot seat the audition while entries are unscored. + + @endif + + @if($auditionHasUnresolvedDoublers) + +

Cannot seat the audition while there are unresolved doublers.

+ + + Decline + +
+ @endif +
+ @endif + @endif +
-{{--
--}}
diff --git a/resources/views/tabulation/auditionSeatingOLD.blade.php b/resources/views/tabulation/auditionSeatingOLD.blade.php new file mode 100644 index 0000000..065ae86 --- /dev/null +++ b/resources/views/tabulation/auditionSeatingOLD.blade.php @@ -0,0 +1,30 @@ +@inject('doublerService','App\Services\DoublerService') +@php + $blockSeating = [] +@endphp + + Audition Seating - {{ $audition->name }} +
+
+
+ @include('tabulation.auditionSeating-results-table') +
+
+ @include($rightPanel['view']) +
+{{--
--}} +{{-- @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--}} + + +{{--
--}} +
+ + +
diff --git a/routes/tabulation.php b/routes/tabulation.php index fe60a38..b566875 100644 --- a/routes/tabulation.php +++ b/routes/tabulation.php @@ -7,7 +7,6 @@ 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\Middleware\CheckIfCanTab; use Illuminate\Support\Facades\Route; @@ -42,9 +41,17 @@ Route::middleware(['auth', 'verified', CheckIfCanTab::class])->group(function () // 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'); + Route::get('/{audition}', [SeatAuditionFormController::class, 'showForm'])->name('seating.audition'); + Route::post('/{audition}/draftSeats', [SeatAuditionFormController::class, 'draftSeats'])->name('seating.audition.draftSeats'); + Route::post('/{audition}/clearDraft', [SeatAuditionFormController::class, 'clearDraft'])->name('seating.audition.clearDraft'); + Route::post('/{audition}/{entry}/decline', [SeatAuditionFormController::class, 'declineSeat'])->name('seating.audition.decline'); + Route::post('/{audition}/mass_decline', [SeatAuditionFormController::class, 'massDecline'])->name('seating.audition.mass_decline'); + Route::post('/{audition}/{entry}/accept', [SeatAuditionFormController::class, 'acceptSeat'])->name('seating.audition.accept'); + Route::post('/{audition}/{entry}/noshow', [SeatAuditionFormController::class, 'noshow'])->name('seating.audition.noshow'); + Route::post('/{audition}/publish', + [SeatAuditionFormController::class, 'publishSeats'])->name('seating.audition.publishSeats'); + Route::post('/{audition}/unpublish', + [SeatAuditionFormController::class, 'unpublishSeats'])->name('seating.audition.unpublishSeats'); }); // Advancement Routes diff --git a/routes/web.php b/routes/web.php index 8ca8d03..8221a7c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,7 +12,7 @@ require __DIR__.'/tabulation.php'; require __DIR__.'/user.php'; require __DIR__.'/nominationEnsemble.php'; -Route::get('/test', [TestController::class, 'flashTest'])->middleware('auth', 'verified'); +Route::get('/test', [TestController::class, 'test'])->middleware('auth', 'verified'); Route::view('/home', 'welcome')->middleware('guest')->name('landing'); Route::view('/', 'landing')->name('home');