Merge branch 'scoringRewrite'

* scoringRewrite: (67 commits)
  Advancement working.
  Correctly show advancement screen.
  Correct isssue in RankAuditionEntries action for advancmenet.
  Switch settings to be stored in a static property instead of cache.
  Remove depricated code.
  Fix lazy loading issue when an audition is seated.
  Add TODO
  Allow for bluk declining seats
  add ability to fictionalize data
  rename sync-doublers console command
  Remove depricated code from bonusscore model
  When appropriate, include bonus score in ranking entrie. Show if an entry has bonus scores when appropriate.
  Add console command to force recalculation of scores
  Deal with bonus scores when calculating total scores.
  Migration to add bonus score related columns to the total scores table.
  Remove depricated code from EnterBonusScore action.
  Cleanup Debugbar Code
  Seating Publication Working
  Everything ready for seating the audition.
  add ability to mark no-shows and accept/decline doublers from the seating page.
  ...
This commit is contained in:
Matt Young 2025-06-30 00:40:26 -05:00
commit d0aa29fb1a
61 changed files with 2172 additions and 1171 deletions

View File

@ -1,99 +0,0 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException;
use App\Models\Entry;
use App\Services\AuditionService;
use App\Services\EntryService;
use Illuminate\Support\Facades\Cache;
class AllJudgesCount implements CalculateEntryScore
{
protected CalculateScoreSheetTotal $calculator;
protected AuditionService $auditionService;
protected EntryService $entryService;
public function __construct(CalculateScoreSheetTotal $calculator, AuditionService $auditionService, EntryService $entryService)
{
$this->calculator = $calculator;
$this->auditionService = $auditionService;
$this->entryService = $entryService;
}
public function calculate(string $mode, Entry $entry): array
{
$cacheKey = 'entryScore-'.$entry->id.'-'.$mode;
return Cache::remember($cacheKey, 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');
}
}
}

View File

@ -1,161 +0,0 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException;
use App\Models\BonusScore;
use App\Models\CalculatedScore;
use App\Models\Entry;
use App\Services\AuditionService;
use App\Services\EntryService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use function auditionSetting;
class AllowForOlympicScoring implements CalculateEntryScore
{
protected CalculateScoreSheetTotal $calculator;
protected AuditionService $auditionService;
protected EntryService $entryService;
public function __construct(
CalculateScoreSheetTotal $calculator,
AuditionService $auditionService,
EntryService $entryService
) {
$this->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');
}
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\Audition;
use App\Models\Entry;
use Debugbar;
class CalculateAuditionScores
{
public function __construct()
{
}
public function __invoke(Audition $audition): void
{
$totaler = app(TotalEntryScores::class);
$scores_required = $audition->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);
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,74 +0,0 @@
<?php
/** @noinspection PhpUnhandledExceptionInspection */
namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException;
use App\Models\Entry;
use App\Models\ScoreSheet;
use App\Models\User;
use App\Services\AuditionService;
use App\Services\EntryService;
use App\Services\UserService;
class CalculateScoreSheetTotalDivideByWeightedPossible implements CalculateScoreSheetTotal
{
protected AuditionService $auditionService;
protected EntryService $entryService;
protected UserService $userService;
public function __construct(AuditionService $auditionService, EntryService $entryService, UserService $userService)
{
$this->auditionService = $auditionService;
$this->entryService = $entryService;
$this->userService = $userService;
}
public function __invoke(string $mode, Entry $entry, User $judge): array
{
$this->basicValidations($mode, $entry, $judge);
$scoreSheet = ScoreSheet::where('entry_id', $entry->id)->where('user_id', $judge->id)->first();
if (! $scoreSheet) {
throw new TabulationException('No score sheet by that judge for that entry');
}
$subscores = $this->auditionService->getSubscores($entry->audition, $mode);
$scoreTotal = 0;
$weightsTotal = 0;
$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');
}
}
}

View File

@ -6,7 +6,6 @@ namespace App\Actions\Tabulation;
use App\Exceptions\ScoreEntryException; use App\Exceptions\ScoreEntryException;
use App\Models\BonusScore; use App\Models\BonusScore;
use App\Models\CalculatedScore;
use App\Models\Entry; use App\Models\Entry;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -29,7 +28,6 @@ class EnterBonusScore
// Create the score for each related entry // Create the score for each related entry
foreach ($entries as $relatedEntry) { foreach ($entries as $relatedEntry) {
// Also delete any cached scores // Also delete any cached scores
CalculatedScore::where('entry_id', $relatedEntry->id)->delete();
BonusScore::create([ BonusScore::create([
'entry_id' => $relatedEntry->id, 'entry_id' => $relatedEntry->id,
'user_id' => $judge->id, 'user_id' => $judge->id,

View File

@ -0,0 +1,56 @@
<?php
namespace App\Actions\Tabulation;
use App\Exceptions\AuditionAdminException;
use App\Models\BonusScore;
use App\Models\Entry;
use App\Models\EntryTotalScore;
use App\Models\ScoreSheet;
use Illuminate\Support\Facades\DB;
class EnterNoShow
{
/**
* Handles the no-show or failed-prelim flagging for a given entry.
*
* This method ensures the specified flag type is valid and validates
* that the action can be performed based on the associated audition's state.
* Deletes related score records and applies the specified flag ('no_show'
* or 'failed_prelim') to the entry, returning a success message.
*
* @param Entry $entry The entry being flagged.
* @param string $flagType The type of flag to apply ('no-show' or 'failed-prelim').
* @return string A confirmation message about the flagging operation.
*
* @throws AuditionAdminException If an invalid flag type is provided,
* or the action violates business rules.
*/
public function __invoke(Entry $entry, string $flagType = 'noshow'): string
{
if ($flagType !== 'noshow' && $flagType !== 'failprelim') {
throw new AuditionAdminException('Invalid flag type');
}
if ($entry->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;
}
}

View File

@ -7,105 +7,32 @@
namespace App\Actions\Tabulation; namespace App\Actions\Tabulation;
use App\Exceptions\ScoreEntryException; use App\Exceptions\ScoreEntryException;
use App\Models\CalculatedScore; use App\Models\AuditLogEntry;
use App\Models\Entry; use App\Models\Entry;
use App\Models\EntryTotalScore;
use App\Models\ScoreSheet; use App\Models\ScoreSheet;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use function auth;
class EnterScore class EnterScore
{ {
/** /**
* @param User $user A user acting as the judge for this sheet * @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 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 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 * @throws ScoreEntryException
*/ */
public function __invoke(User $user, Entry $entry, array $scores, ScoreSheet|false $scoreSheet = false): ScoreSheet 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); $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; // Basic Validity Checks
}
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)
{
if (! $user->exists()) { if (! $user->exists()) {
throw new ScoreEntryException('User does not exist'); throw new ScoreEntryException('User does not exist');
} }
@ -118,9 +45,110 @@ class EnterScore
if ($entry->audition->hasFlag('advancement_published')) { if ($entry->audition->hasFlag('advancement_published')) {
throw new ScoreEntryException('Cannot score an entry in an audition with published advancement'); 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'); 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.'.<br />';
$log_message .= 'Judge: '.$user->full_name().'<br />';
foreach ($scoreSheet->subscores as $subscore) {
$log_message .= $subscore['subscore_name'].': '.$subscore['score'].'<br />';
}
$log_message .= 'Seating Total: '.$scoreSheet->seating_total.'<br />';
$log_message .= 'Advancement Total: '.$scoreSheet->advancement_total.'<br />';
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;
} }
} }

View File

@ -0,0 +1,16 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\Entry;
class ForceRecalculateTotalScores
{
public function __invoke(): void
{
$calculator = app(TotalEntryScores::class);
foreach (Entry::all() as $entry) {
$calculator($entry, true);
}
}
}

View File

@ -4,105 +4,109 @@
namespace App\Actions\Tabulation; namespace App\Actions\Tabulation;
use App\Exceptions\TabulationException; use App\Exceptions\AuditionAdminException;
use App\Models\Audition; use App\Models\Audition;
use Illuminate\Database\Eloquent\Collection; use App\Models\Entry;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Collection;
use function is_numeric;
class RankAuditionEntries class RankAuditionEntries
{ {
protected CalculateEntryScore $calculator;
public function __construct(CalculateEntryScore $calculator)
{
$this->calculator = $calculator;
}
public function rank(string $mode, Audition $audition): Collection
{
$cacheKey = 'audition'.$audition->id.$mode;
return Cache::remember($cacheKey, 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 * Get ranked entries for the provided audition for either seating or advancement.
* property rank that either is their rank or a flag reflecting no-show, declined, or failed-prelim status
* *
* @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<Entry>|void
*
* @throws AuditionAdminException
*/ */
public function calculateRank(string $mode, Audition $audition): Collection public function __invoke(Audition $audition, string $rank_type)
{ {
$this->basicValidation($mode, $audition); if ($rank_type !== 'seating' && $rank_type !== 'advancement') {
$entries = match ($mode) { throw new AuditionAdminException('Invalid rank type: '.$rank_type.' (must be seating or advancement)');
'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; $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);
}); });
$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)) { if ($rank_type === 'advancement') {
$rank++; return cache()->remember('rank_advancement_'.$audition->id, $cache_duration, function () use ($audition) {
} return $this->get_advancement_ranks($audition);
$rawRank++; });
} }
return $entries;
} }
protected function basicValidation($mode, Audition $audition): void private function get_seating_ranks(Audition $audition): Collection
{ {
if ($mode !== 'seating' && $mode !== 'advancement') { if ($audition->bonusScore()->count() > 0) {
throw new TabulationException('Mode must be seating or advancement'); $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();
}
} }

View File

@ -0,0 +1,90 @@
<?php
namespace App\Actions\Tabulation;
use App\Models\BonusScore;
use App\Models\Entry;
use App\Models\EntryTotalScore;
use App\Models\ScoreSheet;
/**
* Handles the calculation of a total score for an entry, including seating and advancement scores,
* based on scoring sheets and subscores defined in the audition's scoring guide.
*/
class TotalEntryScores
{
public function __construct()
{
}
public function __invoke(Entry $entry, bool $force_recalculation = false): void
{
// TODO Verify accuracy of calculations, particularly for olympic scoring
if ($force_recalculation) {
EntryTotalScore::where('entry_id', $entry->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();
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Actions\Tabulation\ForceRecalculateTotalScores;
use Illuminate\Console\Command;
class RecalculateScores extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'audition:recalculate-scores';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Forces the recalculation of total scores for all entries';
/**
* Execute the console command.
*/
public function handle(ForceRecalculateTotalScores $action): void
{
$this->info('Starting score recalculation...');
$action();
$this->info('Score recalculation completed successfully.');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Models\Doubler;
use App\Models\Event;
use Illuminate\Console\Command;
class SyncDoublers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'audition:sync-doublers {event? : Optional event ID}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Update doublers table based on current entries';
/**
* Execute the console command.
*/
public function handle()
{
if ($eventId = $this->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');
}
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\School;
use App\Models\Student;
use App\Models\User;
use Faker\Factory;
use Illuminate\Console\Command;
class fictionalize extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'audition:fictionalize';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
$faker = Factory::create();
foreach (Student::all() as $student) {
$student->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();
}
}
}

View File

@ -2,10 +2,6 @@
namespace App\Http\Controllers; 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\Models\School;
use App\Services\Invoice\InvoiceDataService; use App\Services\Invoice\InvoiceDataService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -28,33 +24,8 @@ class DashboardController extends Controller
} }
public function dashboard( public function dashboard(
CalculateEntryScore $scoreCalc,
GetEntrySeatingResult $resultGenerator,
RankAuditionEntries $ranker
) { ) {
return view('dashboard.dashboard');
// 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');
} }

View File

@ -8,6 +8,7 @@ use App\Models\Ensemble;
use App\Models\Entry; use App\Models\Entry;
use App\Models\Seat; use App\Models\Seat;
use App\Services\AuditionService; use App\Services\AuditionService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
@ -28,6 +29,7 @@ class ResultsPage extends Controller
*/ */
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
Model::preventLazyLoading(false);
$cacheKey = 'publicResultsPage'; $cacheKey = 'publicResultsPage';
if (Cache::has($cacheKey)) { if (Cache::has($cacheKey)) {

View File

@ -2,50 +2,62 @@
namespace App\Http\Controllers\Tabulation; namespace App\Http\Controllers\Tabulation;
use App\Actions\Tabulation\CalculateAuditionScores;
use App\Actions\Tabulation\RankAuditionEntries; use App\Actions\Tabulation\RankAuditionEntries;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Audition; use App\Models\Audition;
use App\Models\Entry; use App\Models\EntryFlag;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
class AdvancementController extends Controller class AdvancementController extends Controller
{ {
protected RankAuditionEntries $ranker;
public function __construct(RankAuditionEntries $ranker)
{
$this->ranker = $ranker;
}
public function status() 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() $auditions = Audition::forAdvancement()
->with('flags') ->with('flags')
->withCount([ ->withCount([
'entries' => function ($query) { 'entries' => function ($query) {
$query->where('for_advancement', 1); $query->where('for_advancement', true);
}, },
]) ])
->withCount([ ->withCount([
'unscoredEntries' => function ($query) { 'unscoredEntries' => function ($query) {
$query->where('for_advancement', 1); $query->where('for_advancement', true);
}, },
]) ])
->orderBy('score_order')
->get(); ->get();
$auditionData = []; $auditionData = [];
$auditions->each(function ($audition) use (&$auditionData) { $auditions->each(function (Audition $audition) use (&$auditionData) {
$scoredPercent = ($audition->entries_count > 0) ?
round((($audition->entries_count - $audition->unscored_entries_count) / $audition->entries_count) * 100)
: 100;
$auditionData[] = [ $auditionData[] = [
'id' => $audition->id, 'id' => $audition->id,
'name' => $audition->name, 'name' => $audition->name,
'entries_count' => $audition->entries_count, 'entries_count' => $audition->entries_count,
'unscored_entries_count' => $audition->unscored_entries_count, 'unscored_entries_count' => $audition->unscored_entries_count,
'scored_entries_count' => $audition->entries_count - $audition->unscored_entries_count, 'scored_entries_count' => $audition->entries_count - $audition->unscored_entries_count,
'scored_percentage' => $scoredPercent, '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, 'scoring_complete' => $audition->unscored_entries_count === 0,
'published' => $audition->hasFlag('advancement_published'), 'published' => $audition->hasFlag('advancement_published'),
]; ];
}); });
@ -55,11 +67,12 @@ class AdvancementController extends Controller
public function ranking(Request $request, Audition $audition) public function ranking(Request $request, Audition $audition)
{ {
$entries = $this->ranker->rank('advancement', $audition); $ranker = app(RankAuditionEntries::class);
$entries->load('advancementVotes'); $entries = $ranker($audition, 'advancement');
$entries->load(['advancementVotes', 'totalScore', 'student.school']);
$scoringComplete = $entries->every(function ($entry) { $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')); return view('tabulation.advancement.ranking', compact('audition', 'entries', 'scoringComplete'));
@ -68,16 +81,24 @@ class AdvancementController extends Controller
public function setAuditionPassers(Request $request, Audition $audition) public function setAuditionPassers(Request $request, Audition $audition)
{ {
$passingEntries = $request->input('pass'); $passingEntries = $request->input('pass');
$audition->addFlag('advancement_published'); $audition->addFlag('advancement_published');
if (! is_null($passingEntries)) { if (! is_null($passingEntries)) {
$passingEntries = array_keys($passingEntries); $passEntries = collect(array_keys($passingEntries));
$entries = Entry::whereIn('id', $passingEntries)->get(); EntryFlag::insert(
foreach ($entries as $entry) { $passEntries
$entry->addFlag('will_advance'); ->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('audition'.$audition->id.'advancement');
Cache::forget('publicResultsPage'); Cache::forget('publicResultsPage');
Cache::forget('rank_advancement_'.$audition->id);
return redirect()->route('advancement.ranking', ['audition' => $audition->id])->with('success', return redirect()->route('advancement.ranking', ['audition' => $audition->id])->with('success',
'Passers have been set successfully'); 'Passers have been set successfully');
@ -86,9 +107,10 @@ class AdvancementController extends Controller
public function clearAuditionPassers(Request $request, Audition $audition) public function clearAuditionPassers(Request $request, Audition $audition)
{ {
$audition->removeFlag('advancement_published'); $audition->removeFlag('advancement_published');
foreach ($audition->entries as $entry) { $audition->entries
$entry->removeFlag('will_advance'); ->filter(fn ($entry) => $entry->hasFlag('will_advance'))
} ->each(fn ($entry) => $entry->removeFlag('will_advance'));
Cache::forget('audition'.$audition->id.'advancement'); Cache::forget('audition'.$audition->id.'advancement');
Cache::forget('publicResultsPage'); Cache::forget('publicResultsPage');

View File

@ -2,13 +2,10 @@
namespace App\Http\Controllers\Tabulation; namespace App\Http\Controllers\Tabulation;
use App\Exceptions\AuditionAdminException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\BonusScore;
use App\Models\CalculatedScore;
use App\Models\Entry; use App\Models\Entry;
use App\Models\ScoreSheet;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use function to_route; use function to_route;
@ -69,27 +66,16 @@ class EntryFlagController extends Controller
'scores')); 'scores'));
} }
/**
* @throws AuditionAdminException
*/
public function enterNoShow(Entry $entry) public function enterNoShow(Entry $entry)
{ {
if ($entry->audition->hasFlag('seats_published')) { $recorder = app('App\Actions\Tabulation\EnterNoShow');
return to_route('entry-flags.noShowSelect')->with('error', try {
'Cannot enter a no-show for an entry in an audition where seats are published'); $msg = $recorder($entry, request()->input('noshow-type'));
} } catch (AuditionAdminException $e) {
if ($entry->audition->hasFlag('advancement_published')) { return to_route('entry-flags.noShowSelect')->with('error', $e->getMessage());
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.').';
} }
return to_route('entry-flags.noShowSelect')->with('success', $msg); return to_route('entry-flags.noShowSelect')->with('success', $msg);

View File

@ -2,10 +2,13 @@
namespace App\Http\Controllers\Tabulation; namespace App\Http\Controllers\Tabulation;
use App\Actions\Tabulation\EnterScore;
use App\Exceptions\ScoreEntryException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\CalculatedScore;
use App\Models\Entry; use App\Models\Entry;
use App\Models\EntryTotalScore;
use App\Models\ScoreSheet; use App\Models\ScoreSheet;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Session;
@ -22,12 +25,13 @@ class ScoreController extends Controller
public function destroyScore(ScoreSheet $score) 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')) { if ($score->entry->audition->hasFlag('seats_published')) {
return redirect()->back()->with('error', 'Cannot delete scores for an entry where seats are published'); return redirect()->back()->with('error', 'Cannot delete scores for an entry where seats are published');
} }
if ($score->entry->audition->hasFlag('advancement_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(); $score->delete();
@ -66,51 +70,35 @@ class ScoreController extends Controller
compact('entry', 'judges', 'scoring_guide', 'subscores', 'existing_sheets')); 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); $publishedCheck = $this->checkIfPublished($entry);
if ($publishedCheck) { if ($publishedCheck) {
return $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'); $entry->removeFlag('no_show');
$judges = $entry->audition->room->judges; return redirect()->route('scores.chooseEntry')->with('success', 'Scores saved');
$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');
} }
protected function checkIfPublished($entry) protected function checkIfPublished($entry)

View File

@ -2,184 +2,276 @@
namespace App\Http\Controllers\Tabulation; 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\Actions\Tabulation\RankAuditionEntries;
use App\Exceptions\AuditionAdminException; use App\Exceptions\AuditionAdminException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Audition; use App\Models\Audition;
use App\Services\AuditionService; use App\Models\Doubler;
use App\Services\DoublerService; use App\Models\Ensemble;
use App\Services\EntryService; use App\Models\Entry;
use App\Models\Seat;
use Debugbar;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use function redirect; use function redirect;
class SeatAuditionFormController extends Controller class SeatAuditionFormController extends Controller
{ {
protected CalculateEntryScore $calc; public function showForm(Request $request, Audition $audition)
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)
{ {
// If a seating proposal was posted, deal wth it $seatingProposal = (session('proposedSeatingArray-'.$audition->id));
if ($request->method() == 'POST' && $request->input('ensembleAccept')) { if ($audition->hasFlag('seats_published')) {
$requestedEnsembleAccepts = $request->input('ensembleAccept'); $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 { } else {
$requestedEnsembleAccepts = false; $publishedSeats = false;
} }
// Deal with a mass no-show request $ranker = app(RankAuditionEntries::class);
if ($request->input('mass-no-show')) { // Get scored entries in order
$entries = $audition->entries()->forSeating()->withCount('scoreSheets')->with('flags')->get(); $scored_entries = $ranker($audition, 'seating');
foreach ($entries as $entry) { $scored_entries->load(['student.doublers', 'student.school']);
if ($entry->scoreSheets_count == 0 && ! $entry->hasFlag('no_show')) { // Get unscored entries sorted by draw number
$entry->addFlag('no_show'); $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('entryScore-'.$entry->id.'-seating'); foreach ($doubler->entries() as $entry) {
Cache::forget('entryScore-'.$entry->id.'-advancement'); if ($entry->audition_id === $audition->id && $entry->hasFlag('declined')) {
continue 2;
} }
Cache::forget('audition'.$audition->id.'seating'); }
Cache::forget('audition'.$audition->id.'advancement'); $auditionHasUnresolvedDoublers = true;
} }
$entryData = []; $canSeat = ! $auditionHasUnresolvedDoublers && $unscored_entries->count() === 0;
$entries = $this->ranker->rank('seating', $audition);
// Deal with mass decline doubler request return view('tabulation.auditionSeating',
if ($request->input('decline-below')) { compact('audition',
Cache::forget('audition'.$audition->id.'seating'); 'scored_entries',
'unscored_entries',
'noshow_entries',
'failed_prelim_entries',
'doublerData',
'auditionHasUnresolvedDoublers',
'canSeat',
'seatingProposal',
'publishedSeats',
)
);
}
$changes_made = false; public function declineSeat(Audition $audition, Entry $entry)
foreach ($entries as $entry) { {
$doublerData = $this->doublerService->entryDoublerData($entry); $entry->addFlag('declined');
if ($doublerData && ! $entry->hasFlag('declined') && $entry->rank > $request->input('decline-below')) { Cache::forget('rank_seating_'.$entry->audition_id);
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');
}
Cache::forget('rank_seating_'.$entry->audition_id);
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');
}
}
return redirect()->route('seating.audition', ['audition' => $audition->id])->with('success',
$entry->student->full_name().' has accepted '.$audition->name);
}
public function noshow(
Audition $audition,
Entry $entry
) {
$recorder = app('App\Actions\Tabulation\EnterNoShow');
try { try {
$this->decider->decline($entry); $msg = $recorder($entry);
$changes_made = true;
} catch (AuditionAdminException $e) { } catch (AuditionAdminException $e) {
return redirect()->back()->with('error', $e->getMessage()); 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(); return redirect()->route('seating.audition', [$audition])->with('success', $msg);
}
} }
$entries->load('student.school'); public function draftSeats(
$entries->load('student.doublerRequests'); Audition $audition,
$seatable = [ Request $request
'allScored' => true, ) {
'doublersResolved' => true, $ranker = app(RankAuditionEntries::class);
]; $validated = $request->validate([
foreach ($entries as $entry) { 'ensemble' => ['required', 'array'],
$totalScoreColumn = 'No Score'; 'ensemble.*' => ['required', 'integer', 'min:0'],
$fullyScored = false; ]);
if ($entry->score_totals) { $proposedSeatingArray = [];
$totalScoreColumn = $entry->score_totals[0] >= 0 ? $entry->score_totals[0] : $entry->score_message; $rankedEntries = $ranker($audition, 'seating');
$fullyScored = $entry->score_totals[0] >= 0; $rankedEntries = $rankedEntries->reject(function ($entry) {
} return $entry->hasFlag('declined');
// 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;
}); });
$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;
} }
return view('tabulation.auditionSeating', $thisEntry = $rankedEntries->shift();
compact('entryData', 'audition', 'rightPanel', 'seatableEntries', 'requestedEnsembleAccepts')); $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;
} }
protected function pickRightPanel(Audition $audition, array $seatable) $ensembleRankOn++;
{
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) { $sessionKeyName = 'proposedSeatingArray-'.$audition->id;
$rightPanel['view'] = 'tabulation.auditionSeating-unable-to-seat-card'; $request->session()->put($sessionKeyName, $proposedSeatingArray, 10);
$rightPanel['data'] = $seatable;
return $rightPanel; return redirect()->route('seating.audition', ['audition' => $audition->id]);
} }
$rightPanel['view'] = 'tabulation.auditionSeating-right-complete-not-published'; public function clearDraft(
$rightPanel['data'] = $this->auditionService->getSeatingLimits($audition); Audition $audition
) {
session()->forget('proposedSeatingArray-'.$audition->id);
return $rightPanel; 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]);
} }
} }

View File

@ -0,0 +1,180 @@
<?php
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 Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use function redirect;
class SeatAuditionFormControllerOLD 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)
{
// 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;
}
}

View File

@ -2,9 +2,11 @@
namespace App\Http\Controllers\Tabulation; namespace App\Http\Controllers\Tabulation;
use App\Actions\Tabulation\CalculateAuditionScores;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Audition; use App\Models\Audition;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SeatingStatusController extends Controller class SeatingStatusController extends Controller
{ {
@ -13,6 +15,25 @@ class SeatingStatusController extends Controller
*/ */
public function __invoke(Request $request) 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() $auditions = Audition::forSeating()
->withCount([ ->withCount([
'entries' => function ($query) { 'entries' => function ($query) {
@ -25,6 +46,7 @@ class SeatingStatusController extends Controller
}, },
]) ])
->with('flags') ->with('flags')
->with('entries')
->get(); ->get();
$auditionData = []; $auditionData = [];
foreach ($auditions as $audition) { foreach ($auditions as $audition) {
@ -36,7 +58,7 @@ class SeatingStatusController extends Controller
'name' => $audition->name, 'name' => $audition->name,
'scoredEntriesCount' => $audition->entries_count - $audition->unscored_entries_count, 'scoredEntriesCount' => $audition->entries_count - $audition->unscored_entries_count,
'totalEntriesCount' => $audition->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, 'scoringComplete' => $audition->unscored_entries_count === 0,
'seatsPublished' => $audition->hasFlag('seats_published'), 'seatsPublished' => $audition->hasFlag('seats_published'),
'audition' => $audition, 'audition' => $audition,

View File

@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -15,7 +16,7 @@ class AuditLogEntry extends Model
public function getCreatedAtAttribute($value) public function getCreatedAtAttribute($value)
{ {
return \Carbon\Carbon::parse($value) return Carbon::parse($value)
->setTimezone('America/Chicago') ->setTimezone('America/Chicago')
->format('M j, Y H:i:s'); ->format('M j, Y H:i:s');
} }

View File

@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use function in_array; use function in_array;
@ -35,9 +36,12 @@ class Audition extends Model
public function unscoredEntries(): HasMany public function unscoredEntries(): HasMany
{ {
return $this->hasMany(Entry::class) return $this->hasMany(Entry::class)
->whereDoesntHave('scoreSheets') ->whereDoesntHave('totalScore')
->whereDoesntHave('flags', function ($query) { ->whereDoesntHave('flags', function ($query) {
$query->where('flag_name', 'no_show'); $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'); 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 public function display_fee(): string
{ {
return '$'.number_format($this->entry_fee / 100, 2); return '$'.number_format($this->entry_fee / 100, 2);
@ -129,6 +142,17 @@ class Audition extends Model
return $this->hasMany(Seat::class); 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 public function scopeOpen(Builder $query): void
{ {
$currentDate = Carbon::now('America/Chicago'); $currentDate = Carbon::now('America/Chicago');

View File

@ -4,28 +4,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Cache;
class BonusScore extends Model class BonusScore extends Model
{ {
protected $guarded = []; 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 public function entry(): BelongsTo
{ {
return $this->belongsTo(Entry::class); return $this->belongsTo(Entry::class);
@ -40,16 +23,4 @@ class BonusScore extends Model
{ {
return $this->belongsTo(Entry::class, 'originally_scored_entry'); 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');
}
}
} }

View File

@ -1,15 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CalculatedScore extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = ['calculatedScore' => 'json'];
}

104
app/Models/Doubler.php Normal file
View File

@ -0,0 +1,104 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Doubler extends Model
{
// Specify that we're not using a single primary key
protected $primaryKey = null;
public $incrementing = false;
protected $guarded = [];
protected $casts = [
'entries' => '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);
}
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DoublerEntryCount extends Model
{
protected $table = 'doubler_entry_counts';
public function student(): BelongsTo
{
return $this->belongsTo(Student::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
}

View File

@ -2,7 +2,9 @@
namespace App\Models; namespace App\Models;
use App\Actions\Tabulation\RankAuditionEntries;
use App\Enums\EntryFlags; use App\Enums\EntryFlags;
use App\Exceptions\AuditionAdminException;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -17,16 +19,45 @@ class Entry extends Model
protected $guarded = []; 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']; 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 public function student(): BelongsTo
{ {
return $this->belongsTo(Student::class); return $this->belongsTo(Student::class);
@ -98,7 +129,7 @@ class Entry extends Model
public function removeFlag($flag): void 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->flags()->where('flag_name', $flag)->delete();
$this->load('flags'); $this->load('flags');
} }
@ -120,11 +151,6 @@ class Entry extends Model
return $this->hasOne(Seat::class); return $this->hasOne(Seat::class);
} }
public function calculatedScores(): HasMany
{
return $this->hasMany(CalculatedScore::class);
}
public function scopeForSeating(Builder $query): void public function scopeForSeating(Builder $query): void
{ {
$query->where('for_seating', 1); $query->where('for_seating', 1);

View File

@ -15,36 +15,8 @@ class EntryFlag extends Model
'flag_name' => EntryFlags::class, '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 public function entry(): BelongsTo
{ {
return $this->belongsTo(Entry::class); 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');
}
}
} }

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EntryTotalScore extends Model
{
use HasFactory;
protected $casts = [
'seating_subscore_totals' => 'json',
'advancement_subscore_totals' => 'json',
];
public function entry(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
}

View File

@ -5,7 +5,6 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Support\Facades\Cache;
class ScoreSheet extends Model class ScoreSheet extends Model
{ {
@ -13,26 +12,12 @@ class ScoreSheet extends Model
'user_id', 'user_id',
'entry_id', 'entry_id',
'subscores', 'subscores',
'seating_total',
'advancement_total',
]; ];
protected $casts = ['subscores' => 'json']; 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 public function entry(): BelongsTo
{ {
return $this->belongsTo(Entry::class); return $this->belongsTo(Entry::class);
@ -60,16 +45,4 @@ class ScoreSheet extends Model
return $this->subscores[$id]['score'] ?? false; return $this->subscores[$id]['score'] ?? false;
// this function is used at resources/views/tabulation/entry_score_sheet.blade.php // 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');
}
}
} }

View File

@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Support\Collection;
class Student extends Model class Student extends Model
{ {
@ -50,6 +51,9 @@ class Student extends Model
return $this->hasMany(HistoricalSeat::class); return $this->hasMany(HistoricalSeat::class);
} }
/**
* Returns the directors at this student's school.
*/
public function users(): HasManyThrough public function users(): HasManyThrough
{ {
return $this->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 public function directors(): HasManyThrough
{ {
return $this->users(); return $this->users();
@ -85,4 +94,34 @@ class Student extends Model
{ {
return $this->hasMany(DoublerRequest::class); 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();
}
} }

View File

@ -127,7 +127,11 @@ class User extends Authenticatable implements MustVerifyEmail
public function isJudge(): bool 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 public function possibleSchools(): Collection

View File

@ -0,0 +1,52 @@
<?php
namespace App\Observers;
use App\Actions\Tabulation\TotalEntryScores;
use App\Models\BonusScore;
class BonusScoreObserver
{
/**
* Handle the ScoreSheet "created" event.
*/
public function created(BonusScore $bonusScore): void
{
$calculator = app(TotalEntryScores::class);
$calculator($bonusScore->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
{
//
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Observers;
use App\Models\Doubler;
use App\Models\EntryFlag;
class EntryFlagObserver
{
/**
* Handle the EntryFlag "created" event.
*/
public function created(EntryFlag $entryFlag): void
{
Doubler::syncDoublers();
}
/**
* Handle the EntryFlag "updated" event.
*/
public function updated(EntryFlag $entryFlag): void
{
Doubler::syncDoublers();
}
/**
* Handle the EntryFlag "deleted" event.
*/
public function deleted(EntryFlag $entryFlag): void
{
Doubler::syncDoublers();
}
/**
* Handle the EntryFlag "restored" event.
*/
public function restored(EntryFlag $entryFlag): void
{
//
}
/**
* Handle the EntryFlag "force deleted" event.
*/
public function forceDeleted(EntryFlag $entryFlag): void
{
//
}
}

View File

@ -2,8 +2,8 @@
namespace App\Observers; namespace App\Observers;
use App\Events\AuditionChange; use App\Models\Audition;
use App\Events\EntryChange; use App\Models\Doubler;
use App\Models\Entry; use App\Models\Entry;
class EntryObserver class EntryObserver
@ -13,6 +13,16 @@ class EntryObserver
*/ */
public function created(Entry $entry): void public function created(Entry $entry): void
{ {
// Count how many entries the student has for the event
$count = $entry->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 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 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);
} }
/** /**

View File

@ -2,7 +2,7 @@
namespace App\Observers; namespace App\Observers;
use App\Events\ScoreSheetChange; use App\Actions\Tabulation\TotalEntryScores;
use App\Models\ScoreSheet; use App\Models\ScoreSheet;
class ScoreSheetObserver class ScoreSheetObserver
@ -12,7 +12,8 @@ class ScoreSheetObserver
*/ */
public function created(ScoreSheet $scoreSheet): void 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 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 public function deleted(ScoreSheet $scoreSheet): void
{ {
// $calculator = app(TotalEntryScores::class);
$calculator($scoreSheet->entry, true);
} }
/** /**

View File

@ -5,11 +5,10 @@ namespace App\Providers;
use App\Actions\Entries\CreateEntry; use App\Actions\Entries\CreateEntry;
use App\Actions\Entries\UpdateEntry; use App\Actions\Entries\UpdateEntry;
use App\Actions\Schools\SetHeadDirector; use App\Actions\Schools\SetHeadDirector;
use App\Actions\Tabulation\AllowForOlympicScoring; use App\Actions\Tabulation\CalculateAuditionScores;
use App\Actions\Tabulation\CalculateEntryScore;
use App\Actions\Tabulation\CalculateScoreSheetTotal; use App\Actions\Tabulation\CalculateScoreSheetTotal;
use App\Actions\Tabulation\CalculateScoreSheetTotalDivideByTotalWeights; 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\NominationAdminController;
use App\Http\Controllers\NominationEnsembles\NominationEnsembleController; use App\Http\Controllers\NominationEnsembles\NominationEnsembleController;
use App\Http\Controllers\NominationEnsembles\NominationEnsembleEntryController; 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\ScobdaNominationEnsembleEntryController;
use App\Http\Controllers\NominationEnsembles\ScobdaNominationSeatingController; use App\Http\Controllers\NominationEnsembles\ScobdaNominationSeatingController;
use App\Models\Audition; use App\Models\Audition;
use App\Models\BonusScore;
use App\Models\Entry; use App\Models\Entry;
use App\Models\EntryFlag;
use App\Models\Room; use App\Models\Room;
use App\Models\RoomUser; use App\Models\RoomUser;
use App\Models\School; use App\Models\School;
@ -30,6 +31,8 @@ use App\Models\Student;
use App\Models\SubscoreDefinition; use App\Models\SubscoreDefinition;
use App\Models\User; use App\Models\User;
use App\Observers\AuditionObserver; use App\Observers\AuditionObserver;
use App\Observers\BonusScoreObserver;
use App\Observers\EntryFlagObserver;
use App\Observers\EntryObserver; use App\Observers\EntryObserver;
use App\Observers\RoomObserver; use App\Observers\RoomObserver;
use App\Observers\RoomUserObserver; use App\Observers\RoomUserObserver;
@ -58,8 +61,6 @@ class AppServiceProvider extends ServiceProvider
{ {
//$this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotal::class); //$this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotal::class);
//$this->app->singleton(CalculateScoreSheetTotal::class, CalculateScoreSheetTotalDivideByTotalWeights::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(DrawService::class, DrawService::class);
$this->app->singleton(AuditionService::class, AuditionService::class); $this->app->singleton(AuditionService::class, AuditionService::class);
$this->app->singleton(EntryService::class, EntryService::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(CreateEntry::class, CreateEntry::class);
$this->app->singleton(UpdateEntry::class, UpdateEntry::class); $this->app->singleton(UpdateEntry::class, UpdateEntry::class);
$this->app->singleton(SetHeadDirector::class, SetHeadDirector::class); $this->app->singleton(SetHeadDirector::class, SetHeadDirector::class);
$this->app->singleton(TotalEntryScores::class, TotalEntryScores::class);
$this->app->singleton(CalculateAuditionScores::class, CalculateAuditionScores::class);
// Nomination Ensemble // Nomination Ensemble
// $this->app->bind(NominationEnsembleController::class, ScobdaNominationEnsembleController::class); // $this->app->bind(NominationEnsembleController::class, ScobdaNominationEnsembleController::class);
@ -82,6 +85,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
BonusScore::observe(BonusScoreObserver::class);
Entry::observe(EntryObserver::class); Entry::observe(EntryObserver::class);
Audition::observe(AuditionObserver::class); Audition::observe(AuditionObserver::class);
Room::observe(RoomObserver::class); Room::observe(RoomObserver::class);
@ -93,7 +97,8 @@ class AppServiceProvider extends ServiceProvider
SubscoreDefinition::observe(SubscoreDefinitionObserver::class); SubscoreDefinition::observe(SubscoreDefinitionObserver::class);
User::observe(UserObserver::class); User::observe(UserObserver::class);
SeatingLimit::observe(SeatingLimitObserver::class); SeatingLimit::observe(SeatingLimitObserver::class);
EntryFlag::observe(EntryFlagObserver::class);
//Model::preventLazyLoading(! app()->isProduction()); Model::preventLazyLoading(! app()->isProduction());
} }
} }

View File

@ -3,10 +3,11 @@
namespace App; namespace App;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use Illuminate\Support\Facades\Cache;
class Settings class Settings
{ {
public static $settings = null;
protected static $cacheKey = 'site_settings'; protected static $cacheKey = 'site_settings';
public static function __callStatic($key, $arguments) public static function __callStatic($key, $arguments)
@ -14,19 +15,23 @@ class Settings
return self::get($key); return self::get($key);
} }
// Load settings from the database and cache them // Load settings from the database
public static function loadSettings() public static function loadSettings()
{ {
$settings = SiteSetting::all()->pluck('setting_value', 'setting_key')->toArray(); if (self::$settings === null) {
Cache::put(self::$cacheKey, $settings, 3600); // Cache for 1 hour self::$settings = SiteSetting::all()->pluck('setting_value', 'setting_key')->toArray();
}
} }
// Get a setting value by key // Get a setting value by key
public static function get($key, $default = null) 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 // Set a setting value by key
@ -35,15 +40,17 @@ class Settings
// Update the database // Update the database
SiteSetting::updateOrCreate(['setting_key' => $key], ['setting_value' => $value]); SiteSetting::updateOrCreate(['setting_key' => $key], ['setting_value' => $value]);
// Update the cache // Update the static property
$settings = Cache::get(self::$cacheKey, []); if (self::$settings === null) {
$settings[$key] = $value; self::loadSettings();
Cache::put(self::$cacheKey, $settings, 3600); // Cache for 1 hour }
self::$settings[$key] = $value;
} }
// Clear the cache // Clear the settings
public static function clearCache() public static function clearSettings()
{ {
Cache::forget(self::$cacheKey); self::$settings = null;
} }
} }

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('score_sheets', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use App\Models\Entry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('calculated_scores');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::create('calculated_scores', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Entry::class)->constrained()->cascadeOnDelete()->cascadeOnUpdate();
$table->string('mode');
$table->json('calculatedScore');
$table->timestamps();
});
}
};

View File

@ -0,0 +1,33 @@
<?php
use App\Models\Entry;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('entry_total_scores', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::statement('
CREATE VIEW doubler_entry_counts AS
SELECT
e.event_id,
ent.student_id,
COUNT(*) as entry_count
FROM entries ent
JOIN auditions e ON e.id = ent.audition_id
GROUP BY e.event_id, ent.student_id
HAVING COUNT(*) > 1
');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -0,0 +1,39 @@
<?php
use App\Models\Entry;
use App\Models\Event;
use App\Models\Student;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('doublers', function (Blueprint $table) {
// Foreign keys that will form the composite primary key
$table->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');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('entry_total_scores', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('audit_log_entries', function (Blueprint $table) {
$table->text('message')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('audit_log_entries', function (Blueprint $table) {
$table->string('message')->change();
});
}
};

View File

@ -17,54 +17,62 @@ class EntrySeeder extends Seeder
public function run(): void public function run(): void
{ {
$students = Student::all(); $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) { foreach ($students as $student) {
if ($student->grade > 9) { if ($student->grade > 9) {
$audition = Audition::where('maximum_grade', '=', '12')->inRandomOrder()->first(); $audition = Audition::where('maximum_grade', '=', '12')
->inRandomOrder()->first();
} }
if ($student->grade == 9) { if ($student->grade == 9) {
$audition = Audition::where('maximum_grade', '>', '8')->inRandomOrder()->first(); $audition = Audition::where('maximum_grade', '>', '8')
->inRandomOrder()->first();
} }
if ($student->grade == 8) { if ($student->grade == 8) {
$audition = Audition::where('maximum_grade', '=', '9')->inRandomOrder()->first(); $audition = Audition::where('maximum_grade', '=', '9')
->inRandomOrder()->first();
} }
if ($student->grade == 7) { if ($student->grade == 7) {
$audition = Audition::where('maximum_grade', '=', '7')->inRandomOrder()->first(); $audition = Audition::where('maximum_grade', '=', '7')
->inRandomOrder()->first();
} }
Entry::create([ Entry::create([
'student_id' => $student->id, 'student_id' => $student->id,
'audition_id' => $audition->id, 'audition_id' => $audition->id,
'for_seating' => 1,
'for_advancement' => 1,
]); ]);
if (mt_rand(1, 100) > 90) { if (mt_rand(1, 100) > 90) {
if ($student->grade > 9) { if ($student->grade > 9) {
$audition2 = Audition::where('maximum_grade', '=', '12')->where('id', '!=', $audition2 = Audition::where('maximum_grade', '=', '12')
$audition->id)->inRandomOrder()->first(); ->where('id', '!=', $audition->id)
->inRandomOrder()->first();
} }
if ($student->grade == 9) { if ($student->grade == 9) {
$audition2 = Audition::where('maximum_grade', '>', '8')->where('id', '!=', $audition2 = Audition::where('maximum_grade', '>', '8')
$audition->id)->inRandomOrder()->first(); ->where('id', '!=', $audition->id)
->inRandomOrder()->first();
} }
if ($student->grade == 8) { if ($student->grade == 8) {
$audition2 = Audition::where('maximum_grade', '=', '9')->where('id', '!=', $audition2 = Audition::where('maximum_grade', '=', '9')
$audition->id)->inRandomOrder()->first(); ->where('id', '!=', $audition->id)
->inRandomOrder()->first();
} }
if ($student->grade == 7) { if ($student->grade == 7) {
$audition2 = Audition::where('maximum_grade', '=', '7')->where('id', '!=', $audition2 = Audition::where('maximum_grade', '=', '7')
$audition->id)->inRandomOrder()->first(); ->where('id', '!=', $audition->id)
->inRandomOrder()->first();
} }
Entry::create([ Entry::create([
'student_id' => $student->id, 'student_id' => $student->id,
'audition_id' => $audition2->id, 'audition_id' => $audition2->id,
'for_seating' => 1,
'for_advancement' => 1,
]); ]);
}
// Triplers are possible
if (mt_rand(1, 100) > 90) { if (mt_rand(1, 100) > 90) {
if ($student->grade > 9) { if ($student->grade > 9) {
$audition3 = Audition::where('maximum_grade', '=', '12')->where('id', '!=', $audition3 = Audition::where('maximum_grade', '=', '12')->where('id', '!=',
@ -86,11 +94,10 @@ class EntrySeeder extends Seeder
Entry::create([ Entry::create([
'student_id' => $student->id, 'student_id' => $student->id,
'audition_id' => $audition3->id, 'audition_id' => $audition3->id,
'for_seating' => 1,
'for_advancement' => 1,
]); ]);
} }
} }
}
} }
} }

View File

@ -2,9 +2,9 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Actions\Tabulation\EnterScore;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use App\Models\ScoreSheet;
class ScoreAllAuditions extends Seeder class ScoreAllAuditions extends Seeder
{ {
@ -13,26 +13,19 @@ class ScoreAllAuditions extends Seeder
*/ */
public function run(): void public function run(): void
{ {
$recorder = app(EnterScore::class);
$judges = User::all(); $judges = User::all();
foreach ($judges as $judge) { foreach ($judges as $judge) { // Iterate over all users
foreach ($judge->rooms as $room) { foreach ($judge->rooms as $room) { // Iterate over each user's assigned rooms
foreach ($room->auditions as $audition) { foreach ($room->auditions as $audition) { // Iterate over each audition in that room
$scoringGuide = $audition->scoringGuide; $scoringGuide = $audition->scoringGuide; // Load the scoring guide for that audition
$subscores = $scoringGuide->subscores; $subscores = $scoringGuide->subscores; // Get the subscores for that audition
foreach ($audition->entries as $entry) { foreach ($audition->entries as $entry) { // Iterate over each entry in that audition
$scoreArray = []; $scoreArray = [];
foreach ($subscores as $subscore) { foreach ($subscores as $subscore) {
$scoreArray[$subscore->id] = [ $scoreArray[$subscore->id] = mt_rand(0, $subscore->max_score);
'score' => mt_rand(0, 100),
'subscore_id' => $subscore->id,
'subscore_name' => $subscore->name,
];
} }
ScoreSheet::create([ $recorder($judge, $entry, $scoreArray);
'user_id' => $judge->id,
'entry_id' => $entry->id,
'subscores' => $scoreArray,
]);
} }
} }
} }

View File

@ -55,7 +55,7 @@
@foreach($event_entries[$event->id] as $entry) @foreach($event_entries[$event->id] as $entry)
<tr> <tr>
<x-table.td>{{ $entry->id }}</x-table.td> <x-table.td>{{ $entry->id }}</x-table.td>
<x-table.td>{{ $entry->audition->name }}</x-table.td> <x-table.td><a href="{{ route ('seating.audition',[$entry->audition_id]) }}#entry-{{ $entry->id }}">{{ $entry->audition->name }}</a></x-table.td>
<x-table.td>{{ $entry->draw_number }}</x-table.td> <x-table.td>{{ $entry->draw_number }}</x-table.td>
<x-table.td> <x-table.td>
@if($entry->doubler_decision_frozen) @if($entry->doubler_decision_frozen)

View File

@ -27,24 +27,24 @@
</x-card.list.row> </x-card.list.row>
</a> </a>
@endif @endif
@if($showRecapLink) {{-- @if($showRecapLink)--}}
<a href="{{ route('recap.selectAudition') }}"> {{-- <a href="{{ route('recap.selectAudition') }}">--}}
<x-card.list.row class="hover:bg-gray-200"> {{-- <x-card.list.row class="hover:bg-gray-200">--}}
Audition Score Recaps {{-- Audition Score Recaps--}}
</x-card.list.row> {{-- </x-card.list.row>--}}
</a> {{-- </a>--}}
@endif {{-- @endif--}}
</x-card.list.body> </x-card.list.body>
</x-card.card> </x-card.card>
</div> </div>
@if(Auth::user()->school_id) {{-- @if(Auth::user()->school_id)--}}
<div class="md:col-span-3 pl-3"> <!--Column 2 Results --> {{-- <div class="md:col-span-3 pl-3"> <!--Column 2 Results -->--}}
<x-card.card> {{-- <x-card.card>--}}
<x-card.heading>My Results</x-card.heading> {{-- <x-card.heading>My Results</x-card.heading>--}}
@include('dashboard.results-table') {{-- @include('dashboard.results-table')--}}
</x-card.card> {{-- </x-card.card>--}}
</div> {{-- </div>--}}
@endif {{-- @endif--}}
<div> <div>

View File

@ -16,22 +16,15 @@
<x-table.body> <x-table.body>
@foreach($entries as $entry) @foreach($entries as $entry)
@php
if ($entry->score_totals[0] < 0) {
$score = $entry->score_message;
} else {
$score = number_format($entry->score_totals[0] ?? 0,4);
}
@endphp
<tr> <tr>
<x-table.td>{{ $entry->rank }}</x-table.td> <x-table.td>{{ $entry->rank('advancement') }}</x-table.td>
<x-table.td>{{ $entry->id }}</x-table.td> <x-table.td>{{ $entry->id }}</x-table.td>
<x-table.td>{{ $entry->draw_number }}</x-table.td> <x-table.td>{{ $entry->draw_number }}</x-table.td>
<x-table.td class="flex flex-col"> <x-table.td class="flex flex-col">
<span>{{ $entry->student->full_name() }}</span> <span>{{ $entry->student->full_name() }}</span>
<span class="text-xs text-gray-400">{{ $entry->student->school->name }}</span> <span class="text-xs text-gray-400">{{ $entry->student->school->name }}</span>
</x-table.td> </x-table.td>
<x-table.td>{{ $score }}</x-table.td> <x-table.td>{{ $entry->totalScore->advancement_total }}</x-table.td>
<x-table.td class="flex space-x-1"> <x-table.td class="flex space-x-1">
@foreach($entry->advancementVotes as $vote) @foreach($entry->advancementVotes as $vote)

View File

@ -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')
<ul role="list" class="">
@foreach($entry['doubleData'] as $double)
@php($isopen = $double['status'] == 'undecided')
<li class="pb-2 pt-0 px-0 my-2 rounded-xl border border-gray-200 max-w-xs" x-data="{ open: {{ $isopen ? 'true':'false' }} }">
<div class="flex items-start gap-x-3 bg-gray-100 px-3 py-2 rounded-t-xl" >
<p class="text-sm font-semibold leading-6 text-gray-900">
<a href="{{ route('seating.audition', $double['audition']) }}">
{{ $double['auditionName'] }} - {{ $double['status'] }}
</a>
</p>
<div class="w-full flex justify-end" >
<div x-on:click=" open = ! open ">
<x-icons.hamburger-menu />
</div>
</div>
</div>
<div class="grid grid-cols-4" x-show="open">
<div class="mt-1 px-3 text-xs leading-5 text-gray-500 col-span-3">
<ul>
<li class="">
<p class="whitespace-nowrap">Ranked {{ $double['rank'] }}</p>
<p class="truncate">{{ $double['unscored_entries'] }} Unscored</p>
</li>
@foreach($double['seating_limits'] as $limit)
<li>{{$limit['ensemble_name']}} accepts {{ $limit['accepts'] }}</li>
@endforeach
</ul>
</div>
<div class="flex flex-col justify-end gap-y-1 pt-1">
@if ($double['status'] == 'undecided')
<form method="POST" action="{{ route('doubler.accept',['entry'=>$double['entry']]) }}">
@csrf
<button class="{{ $doublerButtonClasses }}">Accept</button>
</form>
<form method="POST" action="{{ route('doubler.decline',['entry'=>$double['entry']]) }}">
@csrf
<button class="{{ $doublerButtonClasses }}">Decline</button>
</form>
@endif
</div>
</div>
</li>
@endforeach
</ul>
{{--Complete Badge--}}
{{--<p class="mt-0.5 whitespace-nowrap rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Complete</p>--}}
{{--In Progres Badge--}}
{{--<p class="mt-0.5 whitespace-nowrap rounded-md bg-yellow-50 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ring-1 ring-inset ring-yellow-600/20">In Progress</p>--}}

View File

@ -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') <div class="border-2 border-gray-200 p-2 m-2"> {{-- Begin block for doubler entry --}}
<ul role="list" class=""> <div class="font-semibold mb-2">
<a href="{{route('seating.audition',[$de->audition])}}#entry-{{ $de->id }}">{{ $de->audition->name }}</a> #{{$de->draw_number}}
@foreach($entry['doubleData'] as $double) ({{ $de->id }})
@php($isopen = $double['status'] == 'undecided')
<li class="pb-2 pt-0 px-0 my-2 rounded-xl border border-gray-200 max-w-xs" x-data="{ open: {{ $isopen ? 'true':'false' }} }">
<div class="flex items-start gap-x-3 bg-gray-100 px-3 py-2 rounded-t-xl" >
<p class="text-sm font-semibold leading-6 text-gray-900">
<a href="{{ route('seating.audition', $double['audition']) }}">
{{ $double['auditionName'] }} - {{ $double['status'] }}
</a>
</p>
<div class="w-full flex justify-end" >
<div x-on:click=" open = ! open ">
<x-icons.hamburger-menu />
</div> </div>
</div> @if($de->hasFlag('no_show'))
</div> <div class="text-red-500">NO-SHOW</div>
@elseif($de->hasFlag('failed_prelim'))
<div class="grid grid-cols-4" x-show="open"> <div class="text-red-500">Failed Prelim</div>
<div class="mt-1 px-3 text-xs leading-5 text-gray-500 col-span-3"> @elseif($de->hasFlag('declined'))
<ul> <div class="text-red-500">Declined</div>
<li class=""> @else
<p class="whitespace-nowrap">Ranked {{ $double['rank'] }}</p> @php($unscored = $de->audition->unscoredEntries()->count())
<p class="truncate">{{ $double['unscored_entries'] }} Unscored</p> @if($unscored > 0)
</li> <div>{{ $unscored }} Unscored Entries</div>
@foreach($double['seating_limits'] as $limit)
<li>{{$limit['ensemble_name']}} accepts {{ $limit['accepts'] }}</li>
@endforeach
</ul>
</div>
<div class="flex flex-col justify-end gap-y-1 pt-1">
@if ($double['status'] == 'undecided')
<form method="POST" action="{{ route('doubler.accept',['entry'=>$double['entry']]) }}">
@csrf
<button class="{{ $doublerButtonClasses }}">Accept</button>
</form>
<form method="POST" action="{{ route('doubler.decline',['entry'=>$double['entry']]) }}">
@csrf
<button class="{{ $doublerButtonClasses }}">Decline</button>
</form>
@endif @endif
</div>
</div> @if(! $de->rank('seating'))
</li> <div class="text-red-500">THIS ENTRY NOT SCORED</div>
@else
<div>Ranked: {{ $de->rank('seating') }}</div>
<div class="mt-2">
Acceptance Limits<br>
@foreach ($de->audition->SeatingLimits as $limit)
{{ $limit->ensemble->name }} -> {{ $limit->maximum_accepted }}
<br>
@endforeach @endforeach
</div>
<div class="mt-3">
{{-- TODO: Don't show the option to accept if it cannot be done --}}
<x-form.form method="POST" action="{{ route('seating.audition.accept',[$audition, $de]) }}" class="mb-3">
<x-form.button>Accept {{ $de->audition->name }}</x-form.button>
</x-form.form>
<x-form.form method="POST" action="{{ route('seating.audition.decline',[$audition,$de]) }}">
<x-form.button>Decline {{ $de->audition->name }}</x-form.button>
</x-form.form>
</div>
@endif
@endif
</ul> </div>
{{--Complete Badge--}}
{{--<p class="mt-0.5 whitespace-nowrap rounded-md bg-green-50 px-1.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Complete</p>--}}
{{--In Progres Badge--}}
{{--<p class="mt-0.5 whitespace-nowrap rounded-md bg-yellow-50 px-1.5 py-0.5 text-xs font-medium text-yellow-800 ring-1 ring-inset ring-yellow-600/20">In Progress</p>--}}

View File

@ -40,7 +40,7 @@
<p class="pt-3"><span class="font-semibold">Request: </span>{{$entry['doublerRequest']}} <p class="pt-3"><span class="font-semibold">Request: </span>{{$entry['doublerRequest']}}
</p> </p>
@endif @endif
@include('tabulation.auditionSeating-doubler-block') @include('tabulation.auditionSeating-doubler-block-OLD')
{{-- DOUBLER<br>--}} {{-- DOUBLER<br>--}}
{{-- @foreach($entry['doubleData'] as $double)--}} {{-- @foreach($entry['doubleData'] as $double)--}}
{{-- ID: {{ $double['entryId'] }} - {{ $double['name'] }} - {{ $double['rank'] }}<br>--}} {{-- ID: {{ $double['entryId'] }} - {{ $double['name'] }} - {{ $double['rank'] }}<br>--}}

View File

@ -1,29 +1,265 @@
@inject('doublerService','App\Services\DoublerService')
@php
$blockSeating = []
@endphp
<x-layout.app> <x-layout.app>
<x-slot:page_title>Audition Seating - {{ $audition->name }}</x-slot:page_title> <x-slot:page_title>Audition Seating - {{ $audition->name }}</x-slot:page_title>
<div class="grid grid-cols-4"></div> <div class="grid grid-cols-4 gap-4">
<div class="grid grid-cols-4">
<div class="col-span-3"> <div class="col-span-3"> {{-- Entry Ranking Table --}}
@include('tabulation.auditionSeating-results-table') <x-card.card class="px-3"> {{-- Scored Entries --}}
<x-card.heading class="-ml-3">Scored Entries</x-card.heading>
<x-table.table>
<thead>
<tr>
<x-table.th>Rank</x-table.th>
<x-table.th>ID</x-table.th>
<x-table.th>Draw #</x-table.th>
<x-table.th>Student</x-table.th>
<x-table.th>Doubler</x-table.th>
<x-table.th>Total Score
@if($audition->bonusScore()->count() > 0)
<br>
<div class="display: flex">
<span class="text-yellow-500">No Bonus Score</span>
</div> </div>
@endif
</x-table.th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($scored_entries as $entry)
<tr id="entry-{{ $entry->id }}">
<x-table.td class="align-top">{{ $entry->seatingRank }}</x-table.td>
<x-table.td class="align-top">{{ $entry->id }}</x-table.td>
<x-table.td class="align-top">{{ $entry->draw_number }}</x-table.td>
<x-table.td class="align-top">
<div>
<a href="{{ route('admin.students.edit',[$entry->student_id]) }}">{{ $entry->student->full_name() }}</a>
</div>
<div class="text-xs text-gray-400">{{ $entry->student->school->name }}</div>
</x-table.td>
<x-table.td class="align-top">
@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())
<div
class="border-2 border-gray-200 p-2 m-2"> {{-- Begin block seating request --}}
<div class="font-semibold mb-2">
Request
</div>
<div class="text-wrap">
<p>{{ $request->request }}</p>
</div>
</div>
@endif
@foreach($entry->student->entriesForEvent($entry->audition->event_id) as $de)
@include('tabulation.auditionSeating-doubler-block')
@endforeach
@endif
@endif
</x-table.td>
<x-table.td class="align-top">
@if($audition->bonusScore()->count() > 0)
@if($entry->totalScore->bonus_total)
<span>{{ $entry->totalScore->seating_total_with_bonus }}</span>
@else
<span class="text-yellow-500">{{ $entry->totalScore->seating_total_with_bonus }}</span>
@endif
@else
{{ $entry->totalScore->seating_total }}
@endif
</x-table.td>
</tr>
@endforeach
</tbody>
</x-table.table>
</x-card.card>
<x-card.card class="mt-3"> {{-- Unscored Entries --}}
<x-card.heading class="-ml-3">Unscored Entries</x-card.heading>
<x-table.table>
<thead>
<tr>
<x-table.th>Draw #</x-table.th>
<x-table.th>ID</x-table.th>
<x-table.th>Student</x-table.th>
<x-table.th></x-table.th>
</tr>
</thead>
<tbody>
@foreach($unscored_entries as $entry)
<tr>
<x-table.td>{{ $entry->draw_number }}</x-table.td>
<x-table.td>{{ $entry->id }}</x-table.td>
<x-table.td class="flex flex-col">
<span>{{ $entry->student->full_name() }}</span>
<span class="text-xs text-gray-400">{{ $entry->student->school->name }}</span>
</x-table.td>
<x-table.td>
<x-form.form method="POST"
action="{{ route('seating.audition.noshow',[$audition, $entry]) }}">
<x-form.button>Record No Show</x-form.button>
</x-form.form>
</x-table.td>
</tr>
@endforeach
</tbody>
</x-table.table>
</x-card.card>
<x-card.card class="mt-3"> {{-- No Show Entries --}}
<x-card.heading class="-ml-3">No Show Entries</x-card.heading>
<x-table.table>
<thead>
<tr>
<x-table.th>Draw #</x-table.th>
<x-table.th>ID</x-table.th>
<x-table.th>Student</x-table.th>
</tr>
</thead>
<tbody>
@foreach($noshow_entries as $entry)
<tr>
<x-table.td>{{ $entry->draw_number }}</x-table.td>
<x-table.td>{{ $entry->id }}</x-table.td>
<x-table.td class="flex flex-col">
<span>{{ $entry->student->full_name() }}</span>
<span class="text-xs text-gray-400">{{ $entry->student->school->name }}</span>
</x-table.td>
</tr>
@endforeach
</tbody>
</x-table.table>
</x-card.card>
<x-card.card class="mt-3"> {{-- Failed Prelim Entries --}}
<x-card.heading class="-ml-3">Failed Prelim Entries</x-card.heading>
<x-table.table>
<thead>
<tr>
<x-table.th>Draw #</x-table.th>
<x-table.th>ID</x-table.th>
<x-table.th>Student</x-table.th>
</tr>
</thead>
<tbody>
@foreach($failed_prelim_entries as $entry)
<tr>
<x-table.td>{{ $entry->draw_number }}</x-table.td>
<x-table.td>{{ $entry->id }}</x-table.td>
<x-table.td class="flex flex-col">
<span>{{ $entry->student->full_name() }}</span>
<span class="text-xs text-gray-400">{{ $entry->student->school->name }}</span>
</x-table.td>
</tr>
@endforeach
</tbody>
</x-table.table>
</x-card.card>
</div>
<div> {{-- Right Column Wrapper --}}
@if($audition->hasFlag('seats_published'))
<x-card.card>
<x-card.heading>Published Results</x-card.heading>
<x-card.list.body>
@php($previousEnsemble = '')
@foreach($publishedSeats as $seat)
@if($previousEnsemble !== $seat->ensemble->name)
@php($previousEnsemble = $seat->ensemble->name)
<x-card.list.row class="font-semibold">{{ $seat->ensemble->name }}</x-card.list.row>
@endif
<x-card.list.row>
<div>
<p>{{ $seat->seat }}. {{ $seat->student->full_name() }}</p>
<p class="ml-5 text-xs">{{ $seat->student->school->name }}</p>
</div>
</x-card.list.row>
@endforeach
</x-card.list.body>
</x-card.card>
<x-form.form method="POST" action="{{ route('seating.audition.unpublishSeats',[$audition]) }}">
<x-form.button class="mt-3">Unpublish Results</x-form.button>
</x-form.form>
@else
@if($canSeat)
@if($seatingProposal)
<x-card.card>
<x-card.heading>
Seating Proposal
<x-slot:subheading>Results are not yet published</x-slot:subheading>
</x-card.heading>
@foreach($seatingProposal as $proposedEnsemble)
<h3 class="m-3 font-semibold">{{ $proposedEnsemble['ensemble_name'] }}</h3>
<x-card.list.body>
@if(isset($proposedEnsemble['seats']))
@foreach($proposedEnsemble['seats'] as $seat)
<x-card.list.row>{{ $seat['seat'] }}
. {{ $seat['entry_name'] }}</x-card.list.row>
@endforeach
@endif
</x-card.list.body>
@endforeach
<x-form.form method="POST" action="{{ route('seating.audition.clearDraft',[$audition]) }}">
<x-form.button class="mb-3">Clear Draft</x-form.button>
</x-form.form>
<x-form.form method="POST"
action="{{ route('seating.audition.publishSeats',[$audition]) }}">
<x-form.button class="mb-3">Publish</x-form.button>
</x-form.form>
</x-card.card>
@else
<x-card.card class="p-3">
<x-form.form metohd="POST" action="{{ route('seating.audition.draftSeats',[$audition]) }}">
<x-card.heading class="-ml-5">Seat Audition
<x-slot:subheading>Choose how many entries to seat in each ensemble
</x-slot:subheading>
</x-card.heading>
@foreach($audition->SeatingLimits()->where('maximum_accepted','>',0)->get() as $limit)
<x-form.select name="ensemble[{{ $limit->ensemble_id }}]">
<x-slot:label>{{$limit->ensemble->name}}</x-slot:label>
@for($n = 0; $n< $limit->maximum_accepted; $n++)
<option value="{{$n}}">{{$n}}</option>
@endfor
<option value="{{$n}}" SELECTED>{{$n}}</option>
</x-form.select>
@endforeach
<x-form.button class="mt-3">Draft Seats</x-form.button>
</x-form.form>
</x-card.card>
@endif
@else
<div class="ml-4"> <div class="ml-4">
@include($rightPanel['view']) @if($unscored_entries->count() > 0)
<x-card.card class="p-3 text-red-500 mb-3">
Cannot seat the audition while entries are unscored.
</x-card.card>
@endif
@if($auditionHasUnresolvedDoublers)
<x-card.card class="p-3">
<p class="text-red-500">Cannot seat the audition while there are unresolved doublers.</p>
<x-form.form method="POST" action="{{ route('seating.audition.mass_decline',[$audition]) }}">
<x-form.field type="number" name="decline-below" label_text="Decline doubler ranked lower than:" />
<x-form.button>Decline</x-form.button>
</x-form.form>
</x-card.card>
@endif
</div>
@endif
@endif
</div> </div>
{{-- <div class="ml-4">--}}
{{-- @if($audition->hasFlag('seats_published'))--}}
{{-- @include('tabulation.auditionSeating-show-published-seats')--}}
{{-- @elseif(! $auditionComplete)--}}
{{-- @include('tabulation.auditionSeating-unable-to-seat-card')--}}
{{-- @else--}}
{{-- @include('tabulation.auditionSeating-fill-seats-form')--}}
{{-- @include('tabulation.auditionSeating-show-proposed-seats')--}}
{{-- @endif--}}
{{-- </div>--}}
</div> </div>

View File

@ -0,0 +1,30 @@
@inject('doublerService','App\Services\DoublerService')
@php
$blockSeating = []
@endphp
<x-layout.app>
<x-slot:page_title>Audition Seating - {{ $audition->name }}</x-slot:page_title>
<div class="grid grid-cols-4"></div>
<div class="grid grid-cols-4">
<div class="col-span-3">
@include('tabulation.auditionSeating-results-table')
</div>
<div class="ml-4">
@include($rightPanel['view'])
</div>
{{-- <div class="ml-4">--}}
{{-- @if($audition->hasFlag('seats_published'))--}}
{{-- @include('tabulation.auditionSeating-show-published-seats')--}}
{{-- @elseif(! $auditionComplete)--}}
{{-- @include('tabulation.auditionSeating-unable-to-seat-card')--}}
{{-- @else--}}
{{-- @include('tabulation.auditionSeating-fill-seats-form')--}}
{{-- @include('tabulation.auditionSeating-show-proposed-seats')--}}
{{-- @endif--}}
{{-- </div>--}}
</div>
</x-layout.app>

View File

@ -7,7 +7,6 @@ use App\Http\Controllers\Tabulation\DoublerDecisionController;
use App\Http\Controllers\Tabulation\EntryFlagController; use App\Http\Controllers\Tabulation\EntryFlagController;
use App\Http\Controllers\Tabulation\ScoreController; use App\Http\Controllers\Tabulation\ScoreController;
use App\Http\Controllers\Tabulation\SeatAuditionFormController; use App\Http\Controllers\Tabulation\SeatAuditionFormController;
use App\Http\Controllers\Tabulation\SeatingPublicationController;
use App\Http\Controllers\Tabulation\SeatingStatusController; use App\Http\Controllers\Tabulation\SeatingStatusController;
use App\Http\Middleware\CheckIfCanTab; use App\Http\Middleware\CheckIfCanTab;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -42,9 +41,17 @@ Route::middleware(['auth', 'verified', CheckIfCanTab::class])->group(function ()
// Seating Routes // Seating Routes
Route::prefix('seating/')->group(function () { Route::prefix('seating/')->group(function () {
Route::get('/', SeatingStatusController::class)->name('seating.status'); Route::get('/', SeatingStatusController::class)->name('seating.status');
Route::match(['get', 'post'], '/{audition}', SeatAuditionFormController::class)->name('seating.audition'); Route::get('/{audition}', [SeatAuditionFormController::class, 'showForm'])->name('seating.audition');
Route::post('/{audition}/publish', [SeatingPublicationController::class, 'publishSeats'])->name('seating.audition.publish'); Route::post('/{audition}/draftSeats', [SeatAuditionFormController::class, 'draftSeats'])->name('seating.audition.draftSeats');
Route::post('/{audition}/unpublish', [SeatingPublicationController::class, 'unpublishSeats'])->name('seating.audition.unpublish'); 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 // Advancement Routes

View File

@ -12,7 +12,7 @@ require __DIR__.'/tabulation.php';
require __DIR__.'/user.php'; require __DIR__.'/user.php';
require __DIR__.'/nominationEnsemble.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('/home', 'welcome')->middleware('guest')->name('landing');
Route::view('/', 'landing')->name('home'); Route::view('/', 'landing')->name('home');