diff --git a/app/Actions/Draw/ClearDraw.php b/app/Actions/Draw/ClearDraw.php
new file mode 100644
index 0000000..66ecdd8
--- /dev/null
+++ b/app/Actions/Draw/ClearDraw.php
@@ -0,0 +1,38 @@
+clearDraw($auditions);
+ }
+ if ($auditions instanceof Collection) {
+ $this->clearDraws($auditions);
+ }
+ }
+
+ public function clearDraw(Audition $audition): void
+ {
+ $audition->removeFlag('drawn');
+ DB::table('entries')->where('audition_id', $audition->id)->update(['draw_number' => null]);
+ $message = 'Cleared draw for audition #'.$audition->id.' '.$audition->name;
+ $affected['auditions'] = [$audition->id];
+ auditionLog($message, $affected);
+ }
+
+ public function clearDraws(Collection $auditions): void
+ {
+ foreach ($auditions as $audition) {
+ $this->clearDraw($audition);
+ }
+ }
+}
diff --git a/app/Actions/Draw/RunDraw.php b/app/Actions/Draw/RunDraw.php
new file mode 100644
index 0000000..45dc878
--- /dev/null
+++ b/app/Actions/Draw/RunDraw.php
@@ -0,0 +1,54 @@
+runDraw($auditions);
+
+ return;
+ } elseif ($auditions instanceof Collection) {
+ $this->runDrawMultiple($auditions);
+
+ return;
+ }
+ }
+
+ public function runDraw(Audition $audition): void
+ {
+ // start off by clearing any existing draw numbers in the audition
+ DB::table('entries')->where('audition_id', $audition->id)->update(['draw_number' => null]);
+
+ $randomizedEntries = $audition->entries->shuffle();
+
+ // Move entries flagged as no show to the end
+ [$noShowEntries, $otherEntries] = $randomizedEntries->partition(function ($entry) {
+ return $entry->hasFlag('no_show');
+ });
+ $randomizedEntries = $otherEntries->merge($noShowEntries);
+
+ // Save draw numbers back to the entries\
+ $nextNumber = 1;
+ foreach ($randomizedEntries as $index => $entry) {
+ $entry->update(['draw_number' => $nextNumber]);
+ $nextNumber++;
+ }
+ $audition->addFlag('drawn');
+ }
+
+ public function runDrawMultiple(Collection $auditions): void
+ {
+ // Eager load the 'entries' relationship on all auditions if not already loaded
+ $auditions->loadMissing('entries');
+
+ $auditions->each(fn ($audition) => $this->runDraw($audition));
+ }
+}
diff --git a/app/Http/Controllers/Admin/DrawController.php b/app/Http/Controllers/Admin/DrawController.php
index 64d15e8..b97a6eb 100644
--- a/app/Http/Controllers/Admin/DrawController.php
+++ b/app/Http/Controllers/Admin/DrawController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin;
+use App\Actions\Draw\RunDraw;
use App\Http\Controllers\Controller;
use App\Http\Requests\ClearDrawRequest;
use App\Http\Requests\RunDrawRequest;
@@ -10,7 +11,6 @@ use App\Models\Event;
use App\Services\DrawService;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Log;
use function array_keys;
use function to_route;
@@ -26,6 +26,7 @@ class DrawController extends Controller
public function index(Request $request)
{
$events = Event::with('auditions.flags')->get();
+
// $drawnAuditionsExist is true if any audition->hasFlag('drawn') is true
$drawnAuditionsExist = Audition::whereHas('flags', function ($query) {
$query->where('flag_name', 'drawn');
@@ -36,18 +37,23 @@ class DrawController extends Controller
public function store(RunDrawRequest $request)
{
+ // Request will contain audition which is an array of audition IDs all with a value of 1
+ // Code below results in a collection of auditions that were checked on the form
$auditions = Audition::with('flags')->findMany(array_keys($request->input('audition', [])));
if ($this->drawService->checkCollectionForDrawnAuditions($auditions)) {
return to_route('admin.draw.index')->with('error',
- 'Invalid attempt to draw an audition that has already been drawn');
+ 'Cannot run draw. Some auditions have already been drawn.');
}
- $this->drawService->runDrawsOnCollection($auditions);
+ app(RunDraw::class)($auditions);
- return to_route('admin.draw.index')->with('status', 'Draw completed successfully');
+ return to_route('admin.draw.index')->with('success', 'Draw completed successfully');
}
+ /**
+ * generates the page with checkboxes for each drawn audition with an intent to clear them
+ */
public function edit(Request $request)
{
$drawnAuditions = Audition::whereHas('flags', function ($query) {
@@ -57,12 +63,17 @@ class DrawController extends Controller
return view('admin.draw.edit', compact('drawnAuditions'));
}
+ /**
+ * Clears the draw for auditions
+ */
public function destroy(ClearDrawRequest $request)
{
+ // Request will contain audition which is an array of audition IDs all with a value of 1
+ // Code below results in a collection of auditions that were checked on the form
$auditions = Audition::with('flags')->findMany(array_keys($request->input('audition', [])));
$this->drawService->clearDrawsOnCollection($auditions);
- return to_route('admin.draw.index')->with('status', 'Draw completed successfully');
+ return to_route('admin.draw.index')->with('success', 'Draws cleared successfully');
}
}
diff --git a/app/Observers/AuditionObserver.php b/app/Observers/AuditionObserver.php
new file mode 100644
index 0000000..237d554
--- /dev/null
+++ b/app/Observers/AuditionObserver.php
@@ -0,0 +1,48 @@
+id.' '.$audition->name.' to event '.$audition->event->name;
+ $message .= '
Deadline: '.$audition->entry_deadline->format('m/d/Y');
+ $message .= '
Entry Fee: '.$audition->display_fee();
+ $message .= '
Grade Range: '.$audition->minimum_grade.' - '.$audition->maximum_grade;
+ $affected = ['auditions' => [$audition->id], 'events' => [$audition->event_id]];
+ auditionLog($message, $affected);
+ }
+
+ public function updated(Audition $audition): void
+ {
+ $message = 'Updated audition #'.$audition->getOriginal('name').' '.$audition->name;
+ if ($audition->event_id !== $audition->getOriginal('event_id')) {
+ $message .= '
Event: '.Event::find($audition->getOriginal('event_id'))->name.' -> '.Event::find($audition->event_id)->name;
+ $affected['events'] = [$audition->event_id, $audition->getOriginal('event_id')];
+ } else {
+ $affected['auditions'] = [$audition->id];
+ }
+ if ($audition->entry_deadline !== $audition->getOriginal('entry_deadline')) {
+ $message .= '
Deadline: '.$audition->entry_deadline->format('m/d/Y');
+ }
+ if ($audition->entryFee !== $audition->getOriginal('entryFee')) {
+ $message .= '
Entry Fee: '.$audition->display_fee();
+ }
+ if ($audition->minimum_grade !== $audition->getOriginal('minimum_grade') || $audition->maximum_grade !== $audition->getOriginal('maximum_grade')) {
+ $message .= '
Grade Range: '.$audition->minimum_grade.' - '.$audition->maximum_grade;
+ }
+ $affected['auditions'] = [$audition->id];
+ auditionLog($message, $affected);
+ }
+
+ public function deleted(Audition $audition): void
+ {
+ $message = 'Deleted audition #'.$audition->id.' '.$audition->name;
+ $affected = ['auditions' => [$audition->id]];
+ auditionLog($message, $affected);
+ }
+}
diff --git a/app/Observers/EntryObserver.php b/app/Observers/EntryObserver.php
index d36ec45..9133ec6 100644
--- a/app/Observers/EntryObserver.php
+++ b/app/Observers/EntryObserver.php
@@ -7,6 +7,8 @@ use App\Models\Audition;
use App\Models\Doubler;
use App\Models\Entry;
+use function auditionSetting;
+
class EntryObserver
{
/**
@@ -30,6 +32,9 @@ class EntryObserver
$message .= '
Student: '.$entry->student->full_name();
$message .= '
Grade: '.$entry->student->grade;
$message .= '
School: '.$entry->student->school->name;
+ if ($entry->draw_number) {
+ $message .= '
Draw Number: '.$entry->draw_number;
+ }
$affected = [
'students' => [$entry->student_id],
@@ -48,6 +53,46 @@ class EntryObserver
$syncer = app(DoublerSync::class);
// Update doubler table when an entry is updated
$syncer();
+
+ // Log entry changes
+ $message = 'Updated Entry #'.$entry->id;
+ $affected['entries'] = [$entry->id];
+ $affected['auditions'] = [$entry->audition_id];
+ $affected['students'] = [$entry->student_id];
+ $shouldLog = false;
+ if ($entry->wasChanged('audition_id')) {
+ $originalAuditionName = Audition::find($entry->getOriginal('audition_id'))->name;
+ $message .= '
Audition: '.$originalAuditionName.' -> '.$entry->audition->name;
+ $affected['auditions'][] = $entry->getOriginal('audition_id');
+ $shouldLog = true;
+ }
+ if ($entry->wasChanged('student_id')) {
+ $originalStudentName = $entry->getOriginal('student')->full_name();
+ $message .= '
Student: '.$originalStudentName.' -> '.$entry->student->full_name();
+ $affected['students'][] = $entry->getOriginal('student_id');
+ $shouldLog = true;
+ }
+ if ($entry->wasChanged('for_advancement' && auditionSetting('advanceTo'))) {
+ $message .= '
Entered for '.auditionSetting('advanceTo');
+ $shouldLog = true;
+ }
+ if ($entry->wasChanged('for_seating')) {
+ $message .= '
Entered for seating';
+ $shouldLog = true;
+ }
+ if ($shouldLog) {
+ auditionLog($message, $affected);
+ }
+
+ if ($entry->wasChanged('draw_number')) {
+ $message = 'Assigned Entry #'.$entry->id.' draw number '.$entry->draw_number;
+ $message .= '
Audition: '.$entry->audition->name;
+ $message .= '
Student: '.$entry->student->full_name();
+ $affected['students'] = [$entry->student_id];
+ $affected['auditions'] = [$entry->audition_id];
+ $affected['entries'] = [$entry->id];
+ auditionLog($message, $affected);
+ }
}
/**
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 9b20c3f..398ee75 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -7,6 +7,7 @@ use App\Actions\Entries\UpdateEntry;
use App\Actions\Schools\SetHeadDirector;
use App\Actions\Tabulation\CalculateAuditionScores;
use App\Actions\Tabulation\TotalEntryScores;
+use App\Models\Audition;
use App\Models\BonusScore;
use App\Models\Entry;
use App\Models\EntryFlag;
@@ -17,6 +18,7 @@ use App\Models\ScoreSheet;
use App\Models\ScoringGuide;
use App\Models\Student;
use App\Models\User;
+use App\Observers\AuditionObserver;
use App\Observers\BonusScoreObserver;
use App\Observers\EntryFlagObserver;
use App\Observers\EntryObserver;
@@ -61,6 +63,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
+ Audition::observe(AuditionObserver::class);
BonusScore::observe(BonusScoreObserver::class);
Entry::observe(EntryObserver::class);
EntryFlag::observe(EntryFlagObserver::class);
diff --git a/tests/Feature/app/Actions/Draw/RunDrawTest.php b/tests/Feature/app/Actions/Draw/RunDrawTest.php
new file mode 100644
index 0000000..2a7c05a
--- /dev/null
+++ b/tests/Feature/app/Actions/Draw/RunDrawTest.php
@@ -0,0 +1,99 @@
+create();
+
+ // Create entries: some flagged as no_show, some not
+ $entries = Entry::factory()
+ ->count(5)
+ ->for($audition)
+ ->create();
+
+ // Flag two entries as no_show
+ $noShowEntries = $entries->take(2);
+ foreach ($noShowEntries as $entry) {
+ $entry->addFlag('no_show');
+ $entry->save();
+ }
+
+ // Optionally, assign some existing draw numbers to test clearing
+ // foreach ($entries as $entry) {
+ // $entry->draw_number = 99;
+ // $entry->save();
+ // }
+
+ // Run the draw
+ app(RunDraw::class)($audition);
+
+ // Reload entries from DB to get fresh data
+ $entries = $audition->entries()->orderBy('draw_number')->get();
+
+ // Assert all draw_numbers are sequential starting at 1
+ $drawNumbers = $entries->pluck('draw_number')->all();
+ expect($drawNumbers)->toEqual(range(1, $entries->count()));
+
+ // Assert entries without no_show flag come first
+ $entriesWithoutNoShow = $entries->filter(fn ($e) => ! $e->hasFlag('no_show'));
+ $entriesWithNoShow = $entries->filter(fn ($e) => $e->hasFlag('no_show'));
+
+ // The max draw_number of entries without no_show should be less than min draw_number of no_show entries
+ if ($entriesWithNoShow->isNotEmpty()) {
+ expect($entriesWithoutNoShow->max('draw_number'))->toBeLessThan($entriesWithNoShow->min('draw_number'));
+ }
+
+ // Assert the audition has the 'drawn' flag
+ expect($audition->hasFlag('drawn'))->toBeTrue();
+});
+
+it('runs draw on multiple auditions correctly', function () {
+ // Create multiple auditions
+ $auditions = Audition::factory()->count(3)->create();
+
+ // For each audition, create entries with some flagged as no_show
+ foreach ($auditions as $audition) {
+ $entries = Entry::factory()->count(4)->for($audition)->create();
+
+ // Flag one entry as no_show per audition
+ $entries->first()->addFlag('no_show');
+ $entries->first()->save();
+
+ // // Assign dummy draw numbers to test clearing
+ // foreach ($entries as $entry) {
+ // $entry->draw_number = 99;
+ // $entry->save();
+ // }
+ }
+
+ // Run the draw on all auditions
+ app(RunDraw::class)($auditions);
+
+ // Reload auditions with entries
+ $auditions = $auditions->load('entries');
+
+ foreach ($auditions as $audition) {
+ $entries = $audition->entries->sortBy('draw_number')->values();
+
+ // Assert draw numbers are sequential starting at 1
+ $drawNumbers = $entries->pluck('draw_number')->all();
+ expect($drawNumbers)->toEqual(range(1, $entries->count()));
+
+ // Separate no_show and other entries
+ $entriesWithoutNoShow = $entries->filter(fn ($e) => ! $e->hasFlag('no_show'));
+ $entriesWithNoShow = $entries->filter(fn ($e) => $e->hasFlag('no_show'));
+
+ if ($entriesWithNoShow->isNotEmpty()) {
+ expect($entriesWithoutNoShow->max('draw_number'))->toBeLessThan($entriesWithNoShow->min('draw_number'));
+ }
+
+ // Assert the audition has the 'drawn' flag
+ expect($audition->hasFlag('drawn'))->toBeTrue();
+ }
+});
diff --git a/tests/Feature/app/Http/Controllers/Admin/DrawControllerTest.php b/tests/Feature/app/Http/Controllers/Admin/DrawControllerTest.php
new file mode 100644
index 0000000..13d0827
--- /dev/null
+++ b/tests/Feature/app/Http/Controllers/Admin/DrawControllerTest.php
@@ -0,0 +1,157 @@
+get(route('admin.draw.index'))->assertRedirect(route('home'));
+ actAsNormal();
+ $this->get(route('admin.draw.index'))->assertRedirect(route('dashboard'));
+ actAsTab();
+ $this->get(route('admin.draw.index'))->assertRedirect(route('dashboard'));
+ });
+ it('returns a page to select auditions to draw, including a section for each event that has entries', function () {
+ $events = Event::factory()->count(3)->create();
+ foreach ($events as $event) {
+ Audition::factory()->forEvent($event)->count(3)->create();
+ }
+ actAsAdmin();
+ $response = $this->get(route('admin.draw.index'));
+ $response->assertOk()
+ ->assertViewIs('admin.draw.index')
+ ->assertSee('events');
+ foreach ($events as $event) {
+ $response->assertSee($event->name, false);
+ }
+ });
+ it('tells the view if any auditions are drawn', function () {
+ $events = Event::factory()->count(3)->create();
+ foreach ($events as $event) {
+ Audition::factory()->forEvent($event)->count(3)->create();
+ }
+ actAsAdmin();
+ $response = $this->get(route('admin.draw.index'));
+ $response->assertOk();
+ expect($response->viewData('drawnAuditionsExist'))->toBeFalse();
+ foreach (Audition::all() as $audition) {
+ $response->assertSee($audition->name, false);
+ }
+ $testCase = Audition::first();
+ $testCase->addFlag('drawn');
+ $response = $this->get(route('admin.draw.index'));
+ $response->assertOk();
+ expect($response->viewData('drawnAuditionsExist'))->toBeTrue();
+ $response->assertDontSee($testCase->name, false);
+
+ });
+});
+
+describe('DrawController::store', function () {
+ it('denies access to a non-admin user', function () {
+ $this->post(route('admin.draw.store'))->assertRedirect(route('home'));
+ actAsNormal();
+ $this->post(route('admin.draw.store'))->assertRedirect(route('dashboard'));
+ actAsTab();
+ $this->post(route('admin.draw.store'))->assertRedirect(route('dashboard'));
+ });
+ it('draws selected auditions', function () {
+ $auditions = Audition::factory()->count(5)->create();
+ $testCase1 = $auditions[0];
+ $testCase2 = $auditions[1];
+ $input = ['audition' => [$testCase1->id => 1, $testCase2->id => 1]];
+ actAsAdmin();
+ $this->post(route('admin.draw.store'), $input)
+ ->assertRedirect(route('admin.draw.index'))
+ ->assertSessionHas('success');
+ $testCase1->refresh();
+ $testCase2->refresh();
+ expect($testCase1->hasFlag('drawn'))->toBeTrue();
+ expect($testCase2->hasFlag('drawn'))->toBeTrue();
+ });
+ it('will not draw an audition if it is already drawn', function () {
+ $auditions = Audition::factory()->count(5)->create();
+ $testCase = $auditions[0];
+ $testCase->addFlag('drawn');
+ $input = ['audition' => [$testCase->id => 1]];
+ actAsAdmin();
+ $this->post(route('admin.draw.store'), $input)
+ ->assertRedirect(route('admin.draw.index'))
+ ->assertSessionHas('error', 'Cannot run draw. Some auditions have already been drawn.');
+ $testCase->refresh();
+ });
+
+});
+
+describe('DrawController::edit', function () {
+ it('denies access to a non-admin user', function () {
+ $this->get(route('admin.draw.edit'))->assertRedirect(route('home'));
+ actAsNormal();
+ $this->get(route('admin.draw.edit'))->assertRedirect(route('dashboard'));
+ actAsTab();
+ $this->get(route('admin.draw.edit'))->assertRedirect(route('dashboard'));
+ });
+ it('returns a page to select drawn auditions to be cleared. Includes all drawn auditions', function () {
+ $auditions = Audition::factory()->count(5)->create();
+ $testCase1 = $auditions[0];
+ $testCase2 = $auditions[1];
+ $testCase1->addFlag('drawn');
+ $testCase2->addFlag('drawn');
+ actAsAdmin();
+ $response = $this->get(route('admin.draw.edit'), ['audition' => [$testCase1->id => 1, $testCase2->id => 1]]);
+ $response->assertOk()
+ ->assertViewIs('admin.draw.edit');
+ expect($response->viewData('drawnAuditions')->contains('id', $testCase1->id))->toBeTrue();
+ expect($response->viewData('drawnAuditions')->contains('id', $testCase2->id))->toBeTrue();
+ expect($response->viewData('drawnAuditions')->contains('id', $auditions[2]->id))->toBeFalse();
+ });
+});
+
+describe('DrawController::destroy', function () {
+ it('denies access to a non-admin user', function () {
+ $this->delete(route('admin.draw.destroy'))->assertRedirect(route('home'));
+ actAsNormal();
+ $this->delete(route('admin.draw.destroy'))->assertRedirect(route('dashboard'));
+ actAsTab();
+ $this->delete(route('admin.draw.destroy'))->assertRedirect(route('dashboard'));
+ });
+ it('clears selected drawn auditions', function () {
+ $auditions = Audition::factory()->count(5)->create();
+ $testCase1 = $auditions[0];
+ $testCase2 = $auditions[1];
+ $testCase3 = $auditions[2];
+ foreach (Audition::all() as $audition) {
+ Entry::factory()->forAudition($audition)->count(5)->create();
+ }
+ actAsAdmin();
+ expect(Entry::where('audition_id', $testCase1->id)->first()->draw_number)->toBeNull();
+ // First off, get the draws run
+ $this->post(route('admin.draw.store'), [
+ 'audition' => [
+ $testCase1->id => 1,
+ $testCase2->id => 1,
+ $testCase3->id => 1,
+ ],
+ ]);
+ $testCase1->refresh();
+ $testCase2->refresh();
+ $testCase3->refresh();
+ expect($testCase1->hasFlag('drawn'))->toBeTrue()
+ ->and(Entry::where('audition_id', $testCase1->id)->first()->draw_number)->not()->toBeNull();
+
+ $this->delete(route('admin.draw.destroy'), ['audition' => [$testCase1->id => 1, $testCase2->id => 1]])
+ ->assertRedirect(route('admin.draw.index'))
+ ->assertSessionHas('success');
+ $testCase1->refresh();
+ $testCase2->refresh();
+ $testCase3->refresh();
+ expect($testCase1->hasFlag('drawn'))->toBeFalse()
+ ->and($testCase2->hasFlag('drawn'))->toBeFalse()
+ ->and($testCase3->hasFlag('drawn'))->toBeTrue();
+ expect(Entry::where('audition_id', $testCase1->id)->first()->draw_number)->toBeNull();
+ });
+});