Progress on tab pages

This commit is contained in:
Matt Young 2024-06-10 01:32:18 -05:00
parent 1f4f919c48
commit 2d00473b2c
24 changed files with 403 additions and 43 deletions

View File

@ -12,7 +12,6 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use function mb_substr;
use function sendMessage;
class CreateNewUser implements CreatesNewUsers
{

View File

@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
use App\Exceptions\TabulationException;
class Handler extends ExceptionHandler
{
public function render($request, Throwable $e)
{
if ($e instanceof TabulationException) {
dd('here');
return redirect('/tabulation/status')->with('warning', $e->getMessage());
}
return parent::render($request, $e);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
use function dd;
use function redirect;
class TabulationException extends Exception
{
public function report(): void
{
//
}
public function render($request)
{
// if ($e instanceof TabulationException) {
return redirect('/tabulation/status')->with('error', $this->getMessage());
// }
// return parent::render($request, $e);
}
}

View File

@ -16,7 +16,7 @@ class EntryController extends Controller
public function index()
{
if(! Auth::user()->is_admin) abort(403);
$filters = session('adminEntryFilters');
$filters = session('adminEntryFilters') ?? null;
$minGrade = Audition::min('minimum_grade');
$maxGrade = Audition::max('maximum_grade');
$auditions = Audition::orderBy('score_order')->get();
@ -26,6 +26,10 @@ class EntryController extends Controller
$entries = Entry::with(['student.school','audition']);
$entries->orderBy('updated_at','DESC');
if($filters) {
if($filters['id']) {
$entries->where('id', $filters['id']);
}
if($filters['audition']) {
$entries->where('audition_id', $filters['audition']);
}
@ -91,7 +95,9 @@ class EntryController extends Controller
if(! Auth::user()->is_admin) abort(403);
$students = Student::with('school')->orderBy('last_name')->orderBy('first_name')->get();
$auditions = Audition::orderBy('score_order')->get();
return view('admin.entries.edit', ['entry' => $entry, 'students' => $students, 'auditions' => $auditions]);
$scores = $entry->scoreSheets()->get();
// return view('admin.entries.edit', ['entry' => $entry, 'students' => $students, 'auditions' => $auditions]);
return view('admin.entries.edit', compact('entry', 'students', 'auditions','scores'));
}
public function update(Entry $entry)

View File

@ -12,7 +12,6 @@ use function abort;
use function dd;
use function request;
use function response;
use function sendMessage;
class ScoringGuideController extends Controller
{

View File

@ -8,7 +8,6 @@ use App\Models\School;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use function abort;
use function sendMessage;
class EntryController extends Controller
{
@ -43,7 +42,7 @@ class EntryController extends Controller
public function destroy(Request $request, Entry $entry)
{
$entry->delete();
sendMessage('The ' . $entry->audition->name . 'entry for ' . $entry->student->full_name(). 'has been deleted.','success');
return redirect('/entries');
return redirect('/entries')->with('success','The ' . $entry->audition->name . 'entry for ' . $entry->student->full_name(). 'has been deleted.');
}
}

View File

@ -9,6 +9,7 @@ class FilterController extends Controller
public function adminEntryFilter(Request $request)
{
$filters = array();
$filters['id'] = request('id_filter') ?? null;
$filters['audition'] = request('audition_filter') ? request('audition_filter') : null;
$filters['school'] = request('school_filter') ? request('school_filter') : null;
$filters['grade'] = request('grade_filter') ? request('grade_filter') : null;

View File

@ -8,6 +8,7 @@ use App\Models\Entry;
use App\Models\ScoreSheet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
use function compact;
use function dump;
use function redirect;
@ -18,6 +19,11 @@ class TabulationController extends Controller
return view('tabulation.choose_entry');
}
public function destroyScore(ScoreSheet $score) {
$score->delete();
return redirect()->back()->with('success','Score Deleted');
}
public function entryScoreSheet(Request $request)
{
$existing_sheets = [];
@ -74,5 +80,22 @@ class TabulationController extends Controller
return redirect()->route('tabulation.chooseEntry')->with('success',count($preparedScoreSheets) . " Scores created");
}
public function status()
{
$auditions = Audition::with('entries.scoreSheets')->with('room.judges')->orderBy('score_order')->get();
return view('tabulation.status',compact('auditions'));
}
public function auditionSeating(Audition $audition)
{
// $entries = $audition->entries()->with(['student','scoreSheets.audition.scoringGuide','audition.room.judges'])->get();
// $entries = $entries->sortByDesc(function ($entry) {
// return $entry->totalScore();
// });
$entries = $audition->rankedEntries()->load('student','scoreSheets.audition.scoringGuide.subscores');
$judges = $audition->judges();
return view('tabulation.auditionSeating',compact('audition','entries','judges'));
}
}

View File

@ -2,11 +2,13 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use phpDocumentor\Reflection\Types\Boolean;
use PhpParser\Node\Scalar\String_;
use function now;
@ -30,6 +32,7 @@ class Audition extends Model
return $this->hasMany(Entry::class);
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
@ -119,4 +122,49 @@ class Audition extends Model
}
return null;
}
// public function judges()
// {
// // Very inefficient, need a better way
// return User::join('room_user', 'users.id', '=', 'room_user.user_id')
// ->join('rooms', 'room_user.room_id', '=', 'rooms.id')
// ->join('auditions', 'rooms.id', '=', 'auditions.room_id')
// ->where('auditions.id', $this->id)
// ->select('users.*') // avoid getting other tables' columns
// ->get();
// }
/**
* @return Collection
*/
public function judges()
{
return $this->room->judges;
}
public function scoredEntries()
{
return $this->entries->filter(function($entry) {
return $entry->scoreSheets->count() >= $this->judges()->count();
});
}
public function rankedEntries()
{
$entries = $this->entries()->with(['audition.scoringGuide.subscores','scoreSheets.judge'])->get();
$entries = $entries->all();
usort($entries, function($a,$b) {
$aScores = $a->finalScoresArray();
$bScores = $b->finalScoresArray();
$length = min(count($aScores), count($bScores));
for ($i=0; $i<$length; $i++) {
if ($aScores[$i] !== $bScores[$i]) {
return $bScores[$i] - $aScores[$i];
}
}
return 0;
});
$collection = new \Illuminate\Database\Eloquent\Collection($entries);
return $collection;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Exceptions\TabulationException;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -13,6 +14,7 @@ class Entry extends Model
{
use HasFactory;
protected $guarded = [];
protected $hasCheckedScoreSheets = false;
public function student(): BelongsTo
{
@ -38,10 +40,70 @@ class Entry extends Model
public function scoreSheets(): HasMany
{
return $this->hasMany(ScoreSheet::class);
}
function verifyScoreSheets()
{
if ($this->hasCheckedScoreSheets) return true;
$judges = $this->audition->room->judges;
foreach ($this->scoreSheets as $sheet) {
if (! $judges->contains($sheet->user_id)) {
$invalidJudge = User::find($sheet->user_id);
// redirect ('/tabulation')->with('warning','Invalid scores for entry ' . $this->id . ' exist from ' . $invalidJudge->full_name());
// Abort execution, and redirect to /tabulation with a warning message
throw new TabulationException('Invalid scores for entry ' . $this->id . ' exist from ' . $invalidJudge->full_name());
}
}
return true;
}
public function scoreFromJudge($user): ScoreSheet|null
{
return $this->scoreSheets()->where('user_id','=',$user)->first() ?? null;
// return $this->scoreSheets()->where('user_id','=',$user)->first() ?? null;
return $this->scoreSheets->firstWhere('user_id', $user) ?? null;
}
public function totalScore()
{
$this->verifyScoreSheets();
$totalScore = 0;
foreach ($this->scoreSheets as $sheet)
{
$totalScore += $sheet->totalScore();
}
return $totalScore;
}
/**
* @throws TabulationException
*/
public function finalScoresArray()
{
$this->verifyScoreSheets();
$finalScoresArray = [];
$subscoresTiebreakOrder = $this->audition->scoringGuide->subscores->sortBy('tiebreak_order');
// initialize the return array
foreach ($subscoresTiebreakOrder as $subscore) {
$finalScoresArray[$subscore->id] = 0;
}
// add the subscores from each score sheet
foreach($this->scoreSheets as $sheet) {
foreach($sheet->subscores as $ss) {
$finalScoresArray[$ss['subscore_id']] += $ss['score'];
}
}
// calculate weighted final score
$totalScore = 0;
$totalWeight = 0;
foreach ($subscoresTiebreakOrder as $subscore) {
$totalScore += ($finalScoresArray[$subscore->id] * $subscore->weight);
$totalWeight += $subscore->weight;
}
$totalScore = ($totalScore / $totalWeight);
array_unshift($finalScoresArray,$totalScore);
return $finalScoresArray;
}
}

25
app/Models/RoomUser.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RoomUser extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function judge(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
class ScoreSheet extends Model
{
@ -15,6 +16,7 @@ class ScoreSheet extends Model
];
protected $casts = ['subscores' => 'json'];
protected $with = ['entry','judge','audition.scoringGuide'];
public function entry(): BelongsTo
{
@ -26,8 +28,37 @@ class ScoreSheet extends Model
return $this->belongsTo(User::class, 'user_id');
}
public function audition(): HasOneThrough
{
return $this->hasOneThrough(
Audition::class, // The final model you want to access
Entry::class, // The intermediate model
'id', // Foreign key on the intermediate model (Entry)
'id', // Foreign key on the final model (Audition)
'entry_id', // Local key on the current model (ScoreSheet)
'audition_id' // Local key on the intermediate model (Entry)
);
}
public function getSubscore($id)
{
return $this->subscores[$id]['score'] ?? false;
}
public function totalScore() {
$totalScore = 0;
$totalWeights = 0;
foreach ( $this->audition->scoringGuide->subscores as $subscore) {
$totalScore += $this->getSubscore($subscore->id) * $subscore->weight;
$totalWeights += $subscore->weight;
}
return $totalScore / $totalWeights;
}
public function isValid() {
$judges = $this->audition->judges();
return $judges->contains('id', $this->judge->id);
}
}

View File

@ -16,6 +16,7 @@ class ScoringGuide extends Model
{
use HasFactory;
protected $guarded = [];
protected $with = ['subscores'];
public function auditions(): HasMany
{

View File

@ -62,6 +62,12 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->first_name . ' ' . $this->last_name;
}
public function short_name(): String
{
// return the first letter of $this->first_name and the full $this->last_name
return $this->first_name[0] . '. ' . $this->last_name;
}
public function has_school(): bool
{
return $this->school_id !== null;

View File

@ -23,18 +23,3 @@ function tw_max_width_class_array() :Array {
return $return;
}
function getMessages() {
$flash = Session::get('_flash');
$messages = $flash['new'];
$return = [];
foreach ($messages as $message) {
if (substr($message, 0,4) != 'msg|') continue;
$type = Session::get($message);
$return[] = ['message' => substr($message,4), 'type' => $type];
}
return $return;
}
function sendMessage(String $message, String $type = 'success') {
Session::flash('msg|'.$message,$type);
}

View File

@ -0,0 +1,42 @@
<?php
namespace Database\Seeders;
use App\Models\ScoreSheet;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class ScoreAllAuditions extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$judges = User::all();
foreach ($judges as $judge) {
foreach( $judge->rooms as $room) {
foreach ($room->auditions as $audition){
$scoringGuide = $audition->scoringGuide;
$subscores = $scoringGuide->subscores;
foreach ($audition->entries as $entry){
$scoreArray = [];
foreach ($subscores as $subscore) {
$scoreArray[$subscore->id] = [
'score' => mt_rand(0,100),
'subscore_id' => $subscore->id,
'subscore_name' => $subscore->name
];
}
ScoreSheet::create([
'user_id' => $judge->id,
'entry_id' => $entry->id,
'subscores' => $scoreArray
]);
}
}
}
}
}
}

View File

@ -30,6 +30,32 @@
</x-form.footer>
</x-form.form>
</x-card.card>
<x-card.card class="mx-auto max-w-2xl mt-6">
<x-card.heading>Scores</x-card.heading>
<x-card.list.body>
@foreach($scores as $score)
@php($score->isValid())
<x-card.list.row right_link_button_type="button" >
<div>{{ $score->judge->full_name() }}</div>
@foreach($score->subscores as $subscore)
{{-- TODO make this look better--}}
<div>
<p>{{$subscore['subscore_name']}}</p>
<p>{{$subscore['score'] }}</p>
</div>
@endforeach
@if(! $score->isValid())
<form method="POST" action="/admin/scores/{{ $score->id }}">
@csrf
@method('DELETE')
<x-slot:right_link_button class="bg-red-500 text-white">INVALID SCORE - DELETE</x-slot:right_link_button>
@endif
</x-card.list.row>
{{-- // TODO make the invalid prettier--}}
@endforeach
</x-card.list.body>
</x-card.card>
</x-layout.app>
{{--TODO apply javascript to only show appropriate auditions for the students grade--}}

View File

@ -6,7 +6,8 @@
<x-card.heading>Set Filters</x-card.heading>
<x-form.form action="/filters/admin_entry_filter" method="POST">
<x-form.body-grid columns="12">
<x-form.select name="audition_filter" colspan="5">
<x-form.field name="id_filter" label_text="Entry ID" colspan="2" value="{{ $filters['id'] ?? '' }}"/>
<x-form.select name="audition_filter" colspan="4">
<x-slot:label>Audition</x-slot:label>
<option value="">No Audition Filter</option>
@foreach($auditions as $audition)
@ -15,7 +16,7 @@
</option>
@endforeach
</x-form.select>
<x-form.select name="school_filter" colspan="5">
<x-form.select name="school_filter" colspan="4">
<x-slot:label>School</x-slot:label>
<option value="">No School Filter</option>
@foreach($schools as $school)
@ -39,7 +40,7 @@
<x-form.field name="last_name_filter" colspan="6" label_text="Last Name" value="{{ ($filters['last_name'] ?? null) }}"/>
</x-form.body-grid>
<x-form.footer class="pb-4">
<x-form.button-nocolor href="filters/admin_entry_filter/clear">Clear Filters</x-form.button-nocolor>
<x-form.button-nocolor href="/filters/admin_entry_filter/clear">Clear Filters</x-form.button-nocolor>
<x-form.button>Apply Filters</x-form.button>
</x-form.footer>
</x-form.form>

View File

@ -1,6 +1,11 @@
@php
$buttonClasses = "text-sm font-semibold leading-6 text-gray-900";
@endphp
@props(['href' => false])
<div>
<button {{ $attributes->merge(['class' => $buttonClasses, 'type'=>'submit']) }}>{{ $slot }}</button>
@if($href)
<a href="{{ $href }}" {{ $attributes->merge(['class' => $buttonClasses]) }}>{{ $slot }}</a>
@else
<button {{ $attributes->merge(['class' => $buttonClasses, 'type'=>'submit']) }}>{{ $slot }}</button>
@endif
</div>

View File

@ -21,6 +21,7 @@
<div class="absolute left-1/2 z-10 mt-5 flex w-screen max-w-min -translate-x-1/2 px-4" x-show="open" x-cloak>
<div class="w-56 shrink rounded-xl bg-white p-4 text-sm font-semibold leading-6 text-gray-900 shadow-lg ring-1 ring-gray-900/5">
<a href="/tabulation/enter_scores" class="block p-2 hover:text-indigo-600">Enter Scores</a>
<a href="/tabulation/status" class="block p-2 hover:text-indigo-600">Audition Status</a>
</div>
</div>

View File

@ -0,0 +1,34 @@
<x-layout.app>
<x-slot:page_title>Audition Seating - {{ $audition->name }}</x-slot:page_title>
<x-table.table>
<thead>
<tr>
<x-table.th>ID</x-table.th>
<x-table.th>Draw #</x-table.th>
<x-table.th>Student Name</x-table.th>
@foreach($judges as $judge)
<x-table.th>{{ $judge->short_name() }}</x-table.th>
@endforeach
<x-table.th>Total Score</x-table.th>
</tr>
</thead>
<x-table.body>
@foreach($entries as $entry)
<tr>
<x-table.td>{{ $entry->id }}</x-table.td>
<x-table.td>{{ $entry->draw_number }}</x-table.td>
<x-table.td>{{ $entry->student->full_name(true) }}</x-table.td>
@foreach($judges as $judge)
<x-table.td>{{ number_format($entry->scoreFromJudge($judge->id)->totalScore(), 4) }}</x-table.td>
@endforeach
<x-table.td>{{ number_format($entry->totalScore(), 4) }}</x-table.td>
</tr>
@endforeach
</x-table.body>
</x-table.table>
</x-layout.app>

View File

@ -0,0 +1,26 @@
<x-layout.app>
<x-slot:page_title>Audition Status</x-slot:page_title>
<x-card.card>
<x-card.heading>
Auditions
</x-card.heading>
<x-table.table>
<thead>
<tr>
<x-table.th>Audition</x-table.th>
<x-table.th>Scoring Status</x-table.th>
</tr>
</thead>
<x-table.body>
@foreach($auditions as $audition)
<tr>
<x-table.td><a href="/tabulation/auditions/{{ $audition->id }}">
{{ $audition->name }}
</a></x-table.td>
<x-table.td>{{ $audition->scoredEntries()->count() }} / {{ $audition->entries->count() }} Scored</x-table.td>
</tr>
@endforeach
</x-table.body>
</x-table.table>
</x-card.card>
</x-layout.app>

View File

@ -1,10 +1,10 @@
@php use App\Models\Audition;
use App\Models\Entry;use App\Models\School;
use App\Models\Entry;
use App\Models\School;
use App\Models\SchoolEmailDomain;
use App\Models\ScoreSheet;
use App\Models\ScoringGuide;
use App\Models\User;
use App\Settings;
use App\Models\User;use App\Settings;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
@ -13,19 +13,13 @@
<x-slot:page_title>Test Page</x-slot:page_title>
@php
dump(Auth::user()->scoreSheets->where('entry_id','=','997')->first()->subscores[6]['score']);
echo "-----";
dump(Auth::user()->scoresForEntry(997));
echo "-----";
dump(Auth::user()->scoresForEntry(997)[6]['score']);
echo "-----";
dump(Auth::user()->scoresForEntry(997)[7]['score']);
echo "-----";
dump(Auth::user()->scoresForEntry(997)[8]['score']);
echo "-----";
dump(Auth::user()->scoresForEntry(997)[9]['score']);
echo "-----";
dump(Auth::user()->scoresForEntry(997)[10]['score']);
$audition = Audition::find(2);
$ranked = $audition->rankedEntries();
dump($ranked);
echo "<hr>plain entries<hr>";
dump($audition->entries());
@endphp
</x-layout.app>

View File

@ -35,6 +35,8 @@ Route::middleware(['auth','verified',CheckIfCanTab::class])->prefix('tabulation/
Route::get('/record_noshow','chooseEntry');
Route::get('/entries','entryScoreSheet');
Route::post('/entries/{entry}','saveEntryScoreSheet');
Route::get('/status','status');
Route::get('/auditions/{audition}','auditionSeating');
});
});
@ -43,6 +45,7 @@ Route::middleware(['auth','verified',CheckIfCanTab::class])->prefix('tabulation/
// Admin Routes
Route::middleware(['auth','verified',CheckIfAdmin::class])->prefix('admin/')->group(function() {
Route::view('/','admin.dashboard');
Route::delete('/scores/{score}',[TabulationController::class,'destroyScore']);
Route::post('/auditions/roomUpdate',[\App\Http\Controllers\Admin\AuditionController::class,'roomUpdate']); // Endpoint for JS assigning auditions to rooms
Route::post('/scoring/assign_guide_to_audition',[\App\Http\Controllers\Admin\AuditionController::class,'scoringGuideUpdate']); // Endpoint for JS assigning scoring guides to auditions