Rewrite draw #12

Merged
okorpheus merged 3 commits from rewrite-draw into master 2024-07-07 21:00:20 +00:00
14 changed files with 462 additions and 6 deletions
Showing only changes of commit a28380e2fe - Show all commits

View File

@ -3,14 +3,54 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\RunDrawRequest;
use App\Models\Audition;
use App\Models\Event;
use App\Services\DrawService;
use Illuminate\Http\Request;
use function array_keys;
use function to_route;
class DrawController extends Controller
{
public function index()
protected $drawService;
public function __construct(DrawService $drawService)
{
$this->drawService = $drawService;
}
public function index(Request $request)
{
$events = Event::with('auditions')->get();
// $drawnAuditionsExist is true if any audition->hasFlag('drawn') is true
$drawnAuditionsExist = Audition::whereHas('flags', function ($query) {
$query->where('flag_name', 'drawn');
})->exists();
return view('admin.draw.index', compact('events'));
return view('admin.draw.index', compact('events', 'drawnAuditionsExist'));
}
public function store(RunDrawRequest $request)
{
$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');
}
$this->drawService->runDrawsOnCollection($auditions);
return to_route('admin.draw.index')->with('status', 'Draw completed successfully');
}
public function edit(Request $request)
{
$drawnAuditions = Audition::whereHas('flags', function ($query) {
$query->where('flag_name', 'drawn');
})->get();
return view('admin.draw.edit', compact('drawnAuditions'));
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests;
use App\Models\Audition;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use function to_route;
class RunDrawRequest extends FormRequest
{
public function authorize(): true
{
// Return true if the user is authorized to make this request.
// You might want to check if the user is an admin, for example.
return true;
}
public function rules()
{
return [
'audition' => ['required', 'array'],
];
}
public function messages(): array
{
return [
'audition.required' => 'No auditions were selected',
'audition.array' => 'Invalid request format',
];
}
public function withValidator($validator): void
{
$validator->after(function ($validator) {
foreach ($this->input('audition', []) as $auditionId => $value) {
if (! is_numeric($auditionId) || ! Audition::where('id', $auditionId)->exists()) {
$validator->errors()->add('audition', 'One or more invalid auditions were selected');
}
}
});
}
protected function failedValidation(Validator $validator)
{
$msg = $validator->errors()->get('audition')[0];
return to_route('admin.draw.index')->with('error', $msg);
}
}

View File

@ -36,6 +36,7 @@ use App\Observers\SubscoreDefinitionObserver;
use App\Observers\UserObserver;
use App\Services\AuditionService;
use App\Services\DoublerService;
use App\Services\DrawService;
use App\Services\EntryService;
use App\Services\ScoreService;
use App\Services\SeatingService;
@ -43,6 +44,7 @@ use App\Services\TabulationService;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
@ -50,6 +52,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->singleton(DrawService::class, function () {
return new DrawService();
});
$this->app->singleton(AuditionService::class, function () {
return new AuditionService();
});

View File

@ -0,0 +1,43 @@
<?php
namespace App\Services;
use App\Models\Audition;
use Illuminate\Support\Facades\DB;
class DrawService
{
/**
* Create a new class instance.
*/
public function __construct()
{
//
}
public function runOneDraw(Audition $audition): void
{
// set draw number null on each entry before beginning
DB::table('entries')->where('audition_id', $audition->id)->update(['draw_number' => null]);
$randomizedEntries = $audition->entries->shuffle();
foreach ($randomizedEntries as $index => $entry) {
$entry->draw_number = $index + 1;
$entry->save();
}
$audition->addFlag('drawn');
}
public function runDrawsOnCollection($auditions): void
{
$auditions->each(fn ($audition) => $this->runOneDraw($audition));
}
public function checkCollectionForDrawnAuditions($auditions): bool
{
$auditions->loadMissing('flags');
return $auditions->contains(fn ($audition) => $audition->hasFlag('drawn'));
}
}

View File

@ -0,0 +1,117 @@
@php
/**
* @var int $size=20 Size of the icon
*/
@endphp
@props(['size' => 20])
<div
@keydown.escape="showModal = false"
>
<!-- Trigger for Modal -->
<!-- Modal -->
<div class="relative z-10"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
x-show="showModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak>
<!--
Background backdrop, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0"
To: "opacity-100"
Leaving: "ease-in duration-200"
From: "opacity-100"
To: "opacity-0"
-->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<div class="fixed inset-0 z-10 w-screen overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-cloak
x-show="showModal">
<!--
Modal panel, show/hide based on modal state.
Entering: "ease-out duration-300"
From: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
To: "opacity-100 translate-y-0 sm:scale-100"
Leaving: "ease-in duration-200"
From: "opacity-100 translate-y-0 sm:scale-100"
To: "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
-->
<div
class="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-cloak
x-show="showModal"
@click.away="showModal = false">
<div class="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button type="button"
@click="showModal = false"
class="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<span class="sr-only">Close</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
</svg>
</div>
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">
Really Clear the draw??
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Click confirm below if you're sure. After doing so, be sure to destroy any materials you may have printed for those
auditions to avoid any chance of confusion on audition day.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="submit"
class="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto">
Confirm Clear Draw
</button>
<button type="button"
@click="showModal = false"
class="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,21 @@
<div class="rounded-md bg-red-100 p-4 mb-7">
<div class="flex">
<div class="flex-shrink-0 text-red-400">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Caution!!!</h3>
<div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc space-y-1 pl-5">
<li>This will clear any existing draw numbers</li>
<li>Any cards, sign in sheets or other materials you've printed will be invalid</li>
<li>This action cannot be undone</li>
</ul>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,32 @@
<div class="rounded-md bg-blue-100 p-4 mb-5 max-w-2xl mx-auto" x-data="{drawnAuditions: true}" x-show="drawnAuditions">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3 flex-1 md:flex md:justify-between">
<div class="text-sm text-blue-700">
<p>Some auditions have already been drawn.
<a href="{{ route('admin.draw.edit') }}" class="text-sm font-medium text-blue-800 underline hover:text-blue-600">Click here</a>
to clear previous draws.</p>
</div>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button"
x-on:click="drawnAuditions = false"
class="inline-flex rounded-md bg-blue-100 p-1.5 text-blue-500 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 focus:ring-offset-blue-50">
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
</svg>
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,37 @@
@php
/**
* @var \App\Models\Audition[] $drawnAuditions A collection of all auditions that have been drawn
*/
@endphp
<x-layout.app>
@include('admin.draw.clear-draw-warning')
<x-form.form method="DELETE" action="{{ route('admin.draw.destroy') }}">
<x-card.card class="mb-5 mx-auto max-w-3xl" id="drawn-auditions-card" x-data="{ checked: false, showModal: false }">
<x-card.heading>
Previously Drawn Auditions
<x-slot:right_side>
<button @click="checked = true" class="rounded bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-600 shadow-sm hover:bg-indigo-100 mr-3" type="button">Check All</button>
<button @click="checked = false" class="rounded bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-600 shadow-sm hover:bg-indigo-100" type="button">Uncheck All</button>
</x-slot:right_side>
</x-card.heading>
<div class="grid gap-y-3 md:grid-cols-2 lg:grid-cols-3 px-5 my-3 pb-3 border-b border-gray-100">
@foreach($drawnAuditions as $audition)
<div id="auditiongroup-{{$audition->id}}" class="flex align-middle">
<x-form.checkbox id="auditionCheckbox-{{$audition->id}}" name="audition[{{$audition->id}}]" x-bind:checked="checked"/>
{{$audition->name}}
</div>
@endforeach
</div>
<div class="flex w-full justify-between ">
<div></div>
<div class="mb-5 mr-10 ">
<x-form.button type="button" class="ml-auto" @click="showModal=true">Clear Draw</x-form.button>
</div>
</div>
@include('admin.draw.clear-draw-modal-confirm')
</x-card.card>
</x-form.form>
</x-layout.app>

View File

@ -1,10 +1,14 @@
@php
/**
* @var \App\Models\Event[] $events A collection of all events with auditions
* @var bool $drawnAuditionsExist A boolean value indicating if there are any drawn auditions
*/
@endphp
<x-layout.app>
@if($drawnAuditionsExist)
@include('admin.draw.drawn-auditions-exist-notification')
@endif
<x-form.form action="{{ route('admin.draw.store') }}" method="POST" id="draw-form">
@foreach($events as $event)
@continue($event->auditions->isEmpty())
@ -18,6 +22,7 @@
</x-card.heading>
<div class="grid gap-y-3 md:grid-cols-2 lg:grid-cols-3 px-5 my-3 pb-3 border-b border-gray-100">
@foreach($event->auditions as $audition)
@continue($audition->hasFlag('drawn'))
<div id="auditiongroup-{{$audition->id}}" class="flex align-middle">
<x-form.checkbox id="auditionCheckbox-{{$audition->id}}" name="audition[{{$audition->id}}]" x-bind:checked="checked{{$event->id}}"/>
{{$audition->name}}

View File

@ -130,5 +130,4 @@
</div>
</div>
</div>
</div>

View File

@ -13,10 +13,11 @@
@inject('auditionService','App\Services\AuditionService');
@inject('entryService','App\Services\EntryService')
@inject('seatingService','App\Services\SeatingService')
@inject('drawService', 'App\Services\DrawService')
<x-layout.app>
<x-slot:page_title>Test Page</x-slot:page_title>
@php
dump(Audition::open()->get());
$drawService->hello();
@endphp

View File

@ -76,7 +76,8 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')->
Route::prefix('draw')->controller(\App\Http\Controllers\Admin\DrawController::class)->group(function () {
Route::get('/', 'index')->name('admin.draw.index');
Route::post('/', 'store')->name('admin.draw.store');
Route::delete('/', 'destroy')->name('admin.draw.destroy');
Route::get('/clear', 'edit')->name('admin.draw.edit'); // Select auditions for which the user would like to clear the draw
Route::delete('/', 'destroy')->name('admin.draw.destroy'); // Clear the draw for the selected auditions
});
// Admin Entries Routes

View File

@ -39,7 +39,7 @@ it('has a section for each event that has auditions', function () {
}
}
});
it('lists auditions in each section', function () {
it('lists auditions that have not been drawn in each section', function () {
// Arrange
$events = Event::factory()->count(2)->create();
foreach ($events as $event) {
@ -52,12 +52,26 @@ it('lists auditions in each section', function () {
foreach ($events as $event) {
$response->assertElementExists('#event-section-'.$event->id, function (AssertElement $element) use ($event) {
foreach ($event->auditions as $audition) {
if ($audition->hasFlag('drawn')) {
continue;
}
$element->contains('#auditiongroup-'.$audition->id);
$element->containsText($audition->name);
}
});
}
});
it('does not list auditions that are already drawn', function () {
// Arrange
$audition = Audition::factory()->create();
$audition->addFlag('drawn');
actAsAdmin();
// Act & Assert
$response = $this->get(route('admin.draw.index'));
$response
->assertOk()
->assertDontSee($audition->name);
});
it('each audition has a checkbox with its name', function () {
// Arrange
$events = Event::factory()->count(2)->create();
@ -151,3 +165,15 @@ it('submits to the route admin.draw.store and has CSRF protection', function ()
$form->hasCSRF();
});
});
it('displays a warning if some auditions are already drawn with a link to undo draws', function () {
// Arrange
$audition = Audition::factory()->create();
$audition->addFlag('drawn');
actAsAdmin();
// Act & Assert
$response = $this->get(route('admin.draw.index'));
$response
->assertOk()
->assertSee('Some auditions have already been drawn.', false)
->assertSee(route('admin.draw.edit'), false);
});

View File

@ -0,0 +1,75 @@
<?php
use App\Models\Audition;
use App\Models\Entry;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('allows admin to seat entries and returns to the index with a status message', function () {
$audition = Audition::factory()->create();
Entry::factory()->count(10)->create(['audition_id' => $audition->id]);
actAsAdmin();
/** @noinspection PhpUnhandledExceptionInspection */
$this->post(route('admin.draw.store'), ['audition' => [$audition->id => 'on']])
->assertSessionHasNoErrors()
->assertSessionHas('status', 'Draw completed successfully')
->assertRedirect(route('admin.draw.index'));
});
it('returns an error message if no auditions were selected', function () {
// Arrange
actAsAdmin();
// Act & Assert
$response = $this->post(route('admin.draw.store'));
$response
->assertSessionHas('error', 'No auditions were selected')
->assertRedirect(route('admin.draw.index'));
});
it('only allows admin to run a draw', function () {
// Act & Assert
$this->post(route('admin.draw.store'))->assertRedirect(route('home'));
actAsNormal();
$this->post(route('admin.draw.store'))
->assertSessionHas('error', 'You are not authorized to perform this action')
->assertRedirect(route('dashboard'));
});
it('returns with error if a draw is requested for a non-extant audition', function () {
// Arrange
actAsAdmin();
// Act & Assert
$response = $this->post(route('admin.draw.store', ['audition[999]' => 'on']));
$response
->assertSessionHas('error', 'One or more invalid auditions were selected')
->assertRedirect(route('admin.draw.index'));
});
it('sets a drawn flag on the audition once a draw is run', function () {
// Arrange
$audition = Audition::factory()->create();
actAsAdmin();
// Act & Assert
$response = $this->post(route('admin.draw.store', ['audition['.$audition->id.']' => 'on']));
expect($audition->hasFlag('drawn'))->toBeTrue();
});
it('refuses to draw an audition that has an existing draw', function () {
// Arrange
$audition = Audition::factory()->create();
$audition->addFlag('drawn');
actAsAdmin();
// Act & Assert
$this->post(route('admin.draw.store', ['audition['.$audition->id.']' => 'on']))
->assertSessionHas('error', 'Invalid attempt to draw an audition that has already been drawn')
->assertRedirect(route('admin.draw.index'));
});
it('randomizes the order of the entries in the audition', function () {
// Arrange
$audition = Audition::factory()->hasEntries(10)->create();
$entries = Entry::factory()->count(30)->create(['audition_id' => $audition->id]);
actAsAdmin();
// Act & Assert
$this->post(route('admin.draw.store'), ['audition['.$audition->id.']' => 'on']);
// assert that entries sorted by draw_number are in a random order
expect($audition->entries->sortBy('draw_number')->pluck('id'))
->not()->toBe($entries->pluck('id'));
});
// sets the draw_number column on each entry in the audition based on the randomized entry order