This commit is contained in:
Matt Young 2024-07-09 12:31:06 -05:00
parent a1a9744305
commit 9058e8f06d
12 changed files with 16 additions and 672 deletions

View File

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

View File

@ -103,8 +103,6 @@ class TabulationController extends Controller
$audition->addFlag('seats_published');
$request->session()->forget($sessionKey);
Cache::forget('resultsSeatList');
Cache::forget('publishedAuditions');
Cache::forget('audition'.$audition->id.'seats');
// TODO move the previous Cache functions here and in unplublish to the services, need to add an event for publishing an audition as well
return redirect()->route('tabulation.audition.seat', ['audition' => $audition->id]);
@ -115,9 +113,6 @@ class TabulationController extends Controller
// TODO move this to SeatingService
$audition->removeFlag('seats_published');
Cache::forget('resultsSeatList');
Cache::forget('publishedAuditions');
Cache::forget('audition'.$audition->id.'seats');
$this->seatingService->forgetSeatsForAudition($audition->id);
Seat::where('audition_id', $audition->id)->delete();
return redirect()->route('tabulation.audition.seat', ['audition' => $audition->id]);

View File

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

View File

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

View File

@ -9,7 +9,6 @@ use Illuminate\Support\Facades\Cache;
class AuditionService
{
protected $cacheKey = 'auditions';
/**
* Create a new class instance.
@ -19,87 +18,5 @@ class AuditionService
//
}
/**
* Return or fill cache of auditions including the audition,
* scoringGuide.subscores, judges, judges_count, and entries_count
*/
public function getAuditions($mode = 'seating'): \Illuminate\Database\Eloquent\Collection
{
$auditions = Cache::remember($this->cacheKey, 3600, function () {
return Audition::with(['scoringGuide.subscores', 'judges'])
->withCount('judges')
->withCount('entries')
->withCount([
'entries as seating_entries_count' => function (Builder $query) {
$query->where('for_seating', true);
},
])
->withCount([
'entries as advancement_entries_count' => function (Builder $query) {
$query->where('for_advancement', true);
},
])
->orderBy('score_order')
->get()
->keyBy('id');
});
switch ($mode) {
case 'seating':
return $auditions->filter(fn ($audition) => $audition->for_seating);
case 'advancement':
return $auditions->filter(fn ($audition) => $audition->for_advancement);
default:
return $auditions;
}
}
public function getAudition($id): Audition
{
return $this->getAuditions()->firstWhere('id', $id);
}
public function refreshCache(): void
{
Cache::forget($this->cacheKey);
$this->getAuditions();
}
public function clearCache(): void
{
Cache::forget($this->cacheKey);
}
public function getPublishedAuditions()
{
$cacheKey = 'publishedAuditions';
return Cache::remember(
$cacheKey,
now()->addHour(),
function () {
return Audition::with('flags')->orderBy('score_order')->get()->filter(fn ($audition
) => $audition->hasFlag('seats_published'));
});
}
public function getPublishedAdvancementAuditions()
{
$cacheKey = 'publishedAdvancementAuditions';
return Cache::remember(
$cacheKey,
now()->addHour(),
function () {
return Audition::with('flags')->orderBy('score_order')->get()->filter(fn ($audition
) => $audition->hasFlag('advancement_published'));
});
}
public function clearPublishedAuditionsCache(): void
{
Cache::forget('publishedAuditions');
}
}

View File

@ -20,106 +20,15 @@ class DoublerService
/**
* Create a new class instance.
*/
public function __construct(AuditionService $auditionService, TabulationService $tabulationService, SeatingService $seatingService)
{
$this->auditionService = $auditionService;
public function __construct(
AuditionService $auditionService,
TabulationService $tabulationService,
SeatingService $seatingService
) {
$this->auditionService = $auditionService;
$this->tabulationService = $tabulationService;
$this->seatingService = $seatingService;
}
/**
* Returns a collection of students that have more than one entry
*/
public function getDoublers(): \Illuminate\Database\Eloquent\Collection
{
// TODO creating or destroying an entry should refresh the doubler cache
// TODO needs to split by event so that a doubler may enter jazz and concert events for example
$doublers = Cache::remember($this->doublersCacheKey, 60, function () {
return Student::withCount(['entries' => function (Builder $query) {
$query->where('for_seating', true);
}])
->with(['entries' => function (Builder $query) {
$query->where('for_seating', true);
}])
->havingRaw('entries_count > ?', [1])
->get();
});
return $doublers;
}
public function refreshDoublerCache()
{
Cache::forget($this->doublersCacheKey);
$this->getDoublers();
}
/**
* Returns an array of information about each entry for a specific doubler. Info for each entry includes
* entryID
* auditionID
* auditionName
* rank => This student's rank in the given audition
* unscored => How many entries remain to be scored in this audition
* limits => acceptance limits for this audition
* status => accepted, declined, or undecided
*
* @param int $studentId The ID of the doubler
*/
public function getDoublerInfo($studentId): array
{
$doubler = $this->getDoublers()->firstWhere('id', $studentId);
// Split $doubler->entries into two arrays based on the result of hasFlag('declined')
$undecidedEntries = $doubler->entries->filter(function ($entry) {
return ! $entry->hasFlag('declined');
});
$acceptedEntry = null;
if ($undecidedEntries->count() == 1) {
$acceptedEntry = $undecidedEntries->first();
}
// TODO can I rewrite this?
// When getting a doubler we need to know
// 1) What their entries are
// 2) For each audition they're entered in, what is their rank
// 3) For each audition they're entered in, how many entries are unscored
// 4) How many are accepted on that instrument
// 5) Status - accepted, declined or undecided
$info = [];
foreach ($doubler->entries as $entry) {
if ($entry->hasFlag('declined')) {
$status = 'declined';
} elseif ($entry === $acceptedEntry) {
$status = 'accepted';
} else {
$status = 'undecided';
}
$info[$entry->id] = [
'entryID' => $entry->id,
'auditionID' => $entry->audition_id,
'auditionName' => $this->auditionService->getAudition($entry->audition_id)->name,
'rank' => $this->tabulationService->entryRank($entry),
'unscored' => $this->tabulationService->remainingEntriesForAudition($entry->audition_id),
'limits' => $this->seatingService->getLimitForAudition($entry->audition_id),
'status' => $status,
];
$entry->audition = $this->auditionService->getAudition($entry->audition_id);
}
return $info;
}
/**
* Checks if a student is a doubler based on the given student ID
*
* @param int $studentId The ID of the student to check
* @return bool Returns true if the student is a doubler, false otherwise
*/
public function studentIsDoubler($studentId): bool
{
return $this->getDoublers()->contains('id', $studentId);
}
}

View File

@ -8,89 +8,16 @@ use Illuminate\Support\Facades\Cache;
class EntryService
{
protected $auditionCache;
/**
* Create a new class instance.
*/
public function __construct(AuditionService $auditionCache)
public function __construct()
{
$this->auditionCache = $auditionCache;
//
}
/**
* Return a collection of all entries for the provided auditionId along with the
* student.school for each entry.
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getEntriesForAudition($auditionId, $mode = 'seating')
{
// TODO this invokes a lot of lazy loading. Perhaps cache the data for all entries then draw from that for each audition
$cacheKey = 'audition'.$auditionId.'entries';
$entries = Cache::remember($cacheKey, 3600, function () use ($auditionId) {
return Entry::where('audition_id', $auditionId)
->with('student.school')
->get()
->keyBy('id');
});
switch ($mode) {
case 'seating':
return $entries->filter(function ($entry) {
return $entry->for_seating;
});
case 'advancement':
return $entries->filter(function ($entry) {
return $entry->for_advancement;
});
default:
return $entries;
}
}
/**
* Returns a collection of collections of entries, one collection for each audition.
* The outer collection is keyed by the audition ID. The included entries are
* with their student.school.
*/
public function getAllEntriesByAudition(): Collection
{
$auditions = $this->auditionCache->getAuditions();
$allEntries = [];
foreach ($auditions as $audition) {
$allEntries[$audition->id] = $this->getEntriesForAudition($audition->id);
}
return collect($allEntries);
}
public function getAllEntries()
{
$cacheKey = 'allEntries';
return Cache::remember($cacheKey, 5, function () {
return Entry::all();
});
}
public function clearEntryCacheForAudition($auditionId): void
{
$cacheKey = 'audition'.$auditionId.'entries';
Cache::forget($cacheKey);
Cache::forget('allEntries');
}
public function clearEntryCaches(): void
{
$auditions = $this->auditionCache->getAuditions();
foreach ($auditions as $audition) {
$this->clearEntryCacheForAudition($audition->id);
}
}
public function entryIsLate(Entry $entry): bool
public function isEntryLate(Entry $entry): bool
{
if ($entry->hasFlag('wave_late_fee')) {
return false;

View File

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

View File

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

View File

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

View File

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

View File

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