diff --git a/app/Actions/Schools/SetHeadDirector.php b/app/Actions/Schools/SetHeadDirector.php new file mode 100644 index 0000000..75377e7 --- /dev/null +++ b/app/Actions/Schools/SetHeadDirector.php @@ -0,0 +1,40 @@ +setHeadDirector($user, $school); + } + + /** + * @throws AuditionAdminException + */ + public function setHeadDirector(User $user): void + { + if (is_null($user->school_id)) { + throw new AuditionAdminException('User is not associated with a school'); + } + foreach ($user->school->directors as $director) { + $director->removeFlag('head_director'); + } + $user->addFlag('head_director'); + + $logMessage = 'Set '.$user->full_name().' as head director at '.$user->school->name; + $logAffected = ['users' => [$user->id], 'schools' => [$user->school_id]]; + auditionLog($logMessage, $logAffected); + } +} diff --git a/app/Enums/UserFlags.php b/app/Enums/UserFlags.php new file mode 100644 index 0000000..aa174d4 --- /dev/null +++ b/app/Enums/UserFlags.php @@ -0,0 +1,8 @@ +school_id !== $school->id) { + return redirect()->back()->with('error', 'That user is not at that school'); + } + $headSetter->setHeadDirector($user); + + return redirect()->back()->with('success', 'Head director set'); + } } diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php index 82a804e..76c4793 100644 --- a/app/Http/Controllers/Admin/UserController.php +++ b/app/Http/Controllers/Admin/UserController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Admin; +use App\Actions\Schools\SetHeadDirector; use App\Http\Controllers\Controller; use App\Mail\NewUserPassword; use App\Models\AuditLogEntry; @@ -13,6 +14,8 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; +use function auditionLog; + class UserController extends Controller { public function index() @@ -45,7 +48,7 @@ class UserController extends Controller return view('admin.users.create', ['schools' => $schools]); } - public function update(Request $request, User $user) + public function update(Request $request, User $user, SetHeadDirector $headSetter) { if (! Auth::user()->is_admin) { abort(403); @@ -63,6 +66,7 @@ class UserController extends Controller ]); $validData['is_admin'] = $request->get('is_admin') == 'on' ? 1 : 0; $validData['is_tab'] = $request->get('is_tab') == 'on' ? 1 : 0; + $validData['is_head'] = $request->get('is_head') == 'on' ? 1 : 0; $user->update([ 'first_name' => $validData['first_name'], 'last_name' => $validData['last_name'], @@ -76,11 +80,11 @@ class UserController extends Controller $user->refresh(); $logged_school = $user->school_id ? $user->school->name : 'No School'; $message = 'Updated user #'.$user->id.' - '.$oldEmail - .'
Name: '.$user->full_name() - .'
Email: '.$user->email - .'
Cell Phone: '.$user->cell_phone - .'
Judging Pref: '.$user->judging_preference - .'
School: '.$logged_school; + .'
Name: '.$user->full_name() + .'
Email: '.$user->email + .'
Cell Phone: '.$user->cell_phone + .'
Judging Pref: '.$user->judging_preference + .'
School: '.$logged_school; AuditLogEntry::create([ 'user' => auth()->user()->email, @@ -106,6 +110,16 @@ class UserController extends Controller 'affected' => ['users' => [$user->id]], ]); } + if ($user->hasFlag('head_director') != $validData['is_head'] && ! is_null($user->school_id)) { + if ($validData['is_head']) { + $headSetter->setHeadDirector($user); + } else { + $user->removeFlag('head_director'); + $logMessage = 'Removed '.$user->full_name().' as head director at '.$user->school->name; + $logAffected = ['users' => [$user->id], 'schools' => [$user->school_id]]; + auditionLog($logMessage, $logAffected); + } + } return redirect('/admin/users'); } diff --git a/app/Http/Controllers/SchoolController.php b/app/Http/Controllers/SchoolController.php index fa4bf75..5acfddf 100644 --- a/app/Http/Controllers/SchoolController.php +++ b/app/Http/Controllers/SchoolController.php @@ -2,20 +2,29 @@ namespace App\Http\Controllers; +use App\Actions\Schools\SetHeadDirector; +use App\Exceptions\AuditionAdminException; +use App\Mail\NewUserPassword; use App\Models\AuditLogEntry; use App\Models\School; use App\Models\SchoolEmailDomain; +use App\Models\User; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use function abort; +use function auditionLog; use function redirect; use function request; class SchoolController extends Controller { - public function store(Request $request): RedirectResponse + public function store(Request $request, SetHeadDirector $headSetter): RedirectResponse { if ($request->user()->cannot('create', School::class)) { abort(403); @@ -70,6 +79,13 @@ class SchoolController extends Controller 'schools' => [$school->id], ], ]); + auth()->user()->refresh(); + try { + $headSetter->setHeadDirector(auth()->user()); + } catch (AuditionAdminException $e) { + redirect(route('schools.show', $school))->with('error', 'Could not set as head director'); + } + } return redirect('/schools/'.$school->id); @@ -141,4 +157,101 @@ class SchoolController extends Controller return redirect('/schools/create'); } + + public function addDirector(School $school) + { + + if (auth()->user()->school_id !== $school->id) { + return redirect()->back()->with('error', 'No adding directors to another school'); + } + if (! auth()->user()->hasFlag('head_director')) { + return redirect()->back()->with('error', 'Only the head director can add directors to a school'); + } + $validData = request()->validate([ + 'first_name' => ['required'], + 'last_name' => ['required'], + 'email' => ['required', 'email', 'unique:users'], + 'cell_phone' => ['required'], + 'judging_preference' => ['required'], + ]); + // Generate a random password + $randomPassword = Str::random(12); + $newUser = User::create([ + 'first_name' => $validData['first_name'], + 'last_name' => $validData['last_name'], + 'email' => $validData['email'], + 'cell_phone' => $validData['cell_phone'], + 'judging_preference' => $validData['judging_preference'], + 'password' => Hash::make($randomPassword), + 'school_id' => auth()->user()->school_id, + ]); + $logMessage = 'Created user '.$newUser->full_name().' - '.$newUser->email.' as a director at '.$newUser->school->name; + $logAffected = ['users' => [$newUser->id], 'schools' => [$newUser->school_id]]; + auditionLog($logMessage, $logAffected); + Mail::to($newUser->email)->send(new NewUserPassword($newUser, $randomPassword)); + + return redirect()->back()->with('success', 'Director added'); + } + + public function setHeadDirector(School $school, User $user, SetHeadDirector $headSetter) + { + if (auth()->user()->school_id !== $school->id) { + return redirect()->back()->with('error', 'No setting the head director for another school'); + } + if (! auth()->user()->hasFlag('head_director')) { + return redirect()->back()->with('error', 'Only the head director can name a new head director'); + } + if ($school->id !== $user->school_id) { + return redirect()->back()->with('error', 'The proposed head director must be at your school'); + } + try { + $headSetter->setHeadDirector($user); + } catch (AuditionAdminException $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + + return redirect()->back()->with('success', 'New head director set'); + } + + public function addDomain(School $school) + { + if (auth()->user()->school_id !== $school->id) { + return redirect()->back()->with('error', 'No adding domains for another school'); + } + if (! auth()->user()->hasFlag('head_director')) { + return redirect()->back()->with('error', 'Only the head director can add domains'); + } + $verifiedData = request()->validate([ + 'domain' => ['required'], + ]); + try { + SchoolEmailDomain::create([ + 'school_id' => $school->id, + 'domain' => $verifiedData['domain'], + ]); + } catch (UniqueConstraintViolationException $e) { + return redirect()->back()->with('error', 'That domain is already associated with your school'); + } + $logMessage = 'Added domain '.$verifiedData['domain'].' to school '.$school->name; + $logAffected = ['schools' => [$school->id]]; + auditionLog($logMessage, $logAffected); + + return redirect()->back()->with('success', 'Domain added'); + } + + public function deleteDomain(SchoolEmailDomain $domain) + { + if (auth()->user()->school_id !== $domain->school_id) { + return redirect()->back()->with('error', 'No deleting domains for another school'); + } + if (! auth()->user()->hasFlag('head_director')) { + return redirect()->back()->with('error', 'Only the head director can delete domains'); + } + $logMessage = 'Deleted domain '.$domain->domain.' from school '.$domain->school->name; + $logAffected = ['schools' => [$domain->school_id]]; + auditionLog($logMessage, $logAffected); + $domain->delete(); + + return redirect()->back()->with('success', 'Domain deleted'); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 45734f6..21b0722 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\UserFlags; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -158,6 +159,41 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(ScoreSheet::class); } + public function flags(): HasMany + { + return $this->hasMany(UserFlag::class); + } + + public function hasFlag($flag): bool + { + $flags = []; + foreach ($this->flags as $checkFlag) { + $flags[] = $checkFlag->flag_name->value; + } + + return in_array($flag, $flags); + + } + + public function addFlag($flag): void + { + if ($this->hasFlag($flag)) { + return; + } + $enum = match ($flag) { + 'head_director' => UserFlags::HEAD_DIRECTOR, + }; + $this->flags()->create(['flag_name' => $enum]); + $this->load('flags'); + } + + public function removeFlag($flag): void + { + // remove related userFlag where flag_name = $flag + $this->flags()->where('flag_name', $flag)->delete(); + $this->load('flags'); + } + public function scoresForEntry($entry) { return $this->scoreSheets->where('entry_id', '=', $entry)->first()?->subscores; diff --git a/app/Models/UserFlag.php b/app/Models/UserFlag.php new file mode 100644 index 0000000..f2e616d --- /dev/null +++ b/app/Models/UserFlag.php @@ -0,0 +1,21 @@ + UserFlags::class, + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6b048c4..f1e8467 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Actions\Entries\CreateEntry; +use App\Actions\Entries\UpdateEntry; +use App\Actions\Schools\SetHeadDirector; use App\Actions\Tabulation\AllowForOlympicScoring; use App\Actions\Tabulation\CalculateEntryScore; use App\Actions\Tabulation\CalculateScoreSheetTotal; @@ -32,7 +35,6 @@ use App\Services\DoublerService; use App\Services\DrawService; use App\Services\EntryService; use App\Services\ScoreService; -use App\Services\StudentService; use App\Services\UserService; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\ServiceProvider; @@ -52,6 +54,10 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(ScoreService::class, ScoreService::class); $this->app->singleton(UserService::class, UserService::class); $this->app->singleton(DoublerService::class, DoublerService::class); + $this->app->singleton(CreateEntry::class, CreateEntry::class); + $this->app->singleton(UpdateEntry::class, UpdateEntry::class); + $this->app->singleton(SetHeadDirector::class, SetHeadDirector::class); + } /** diff --git a/app/helpers.php b/app/helpers.php index 09e1327..ae6332d 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -2,6 +2,7 @@ use App\Actions\Tabulation\EnterScore; use App\Exceptions\ScoreEntryException; +use App\Models\AuditLogEntry; use App\Models\Entry; use App\Models\User; use App\Settings; @@ -35,11 +36,22 @@ function auditionSetting($key) return Settings::get($key); } +function auditionLog(string $message, array $affected) +{ + AuditLogEntry::create([ + 'user' => auth()->user()->email ?? 'no user', + 'ip_address' => request()->ip(), + 'message' => $message, + 'affected' => $affected, + ]); +} + /** * @throws ScoreEntryException */ function enterScore(User $user, Entry $entry, array $scores): \App\Models\ScoreSheet { $scoreEntry = App::make(EnterScore::class); + return $scoreEntry($user, $entry, $scores); } diff --git a/database/migrations/2024_08_10_214837_create_user_flags_table.php b/database/migrations/2024_08_10_214837_create_user_flags_table.php new file mode 100644 index 0000000..9de75e2 --- /dev/null +++ b/database/migrations/2024_08_10_214837_create_user_flags_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnUpdate()->cascadeOnDelete(); + $table->string('flag_name'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_flags'); + } +}; diff --git a/resources/views/admin/bonus-scores/judge-assignments.blade.php b/resources/views/admin/bonus-scores/judge-assignments.blade.php index 116d14d..8f3f425 100644 --- a/resources/views/admin/bonus-scores/judge-assignments.blade.php +++ b/resources/views/admin/bonus-scores/judge-assignments.blade.php @@ -98,7 +98,7 @@ @if($bonusScore->judges->contains($judge->id)) @continue @endif - @endforeach diff --git a/resources/views/admin/rooms/judge_assignments.blade.php b/resources/views/admin/rooms/judge_assignments.blade.php index eada06f..274c480 100644 --- a/resources/views/admin/rooms/judge_assignments.blade.php +++ b/resources/views/admin/rooms/judge_assignments.blade.php @@ -102,7 +102,7 @@ @foreach($usersWithoutRooms as $judge) {{-- skip judges alrady assigned to this audition --}} - @endforeach @@ -111,7 +111,7 @@ @if($room->judges->contains($judge->id)) @continue @endif - @endforeach diff --git a/resources/views/admin/schools/show.blade.php b/resources/views/admin/schools/show.blade.php index 8f1b7e0..6ae3d6f 100644 --- a/resources/views/admin/schools/show.blade.php +++ b/resources/views/admin/schools/show.blade.php @@ -37,7 +37,16 @@
@foreach($school->directors as $director) - {{ $director->full_name() }} + + {{ $director->full_name() }} + @if($director->hasFlag('head_director')) + Head Director + @else + + [ Make Head Director ] + + @endif +

{{ $director->cell_phone }}

diff --git a/resources/views/admin/users/edit.blade.php b/resources/views/admin/users/edit.blade.php index f0afd81..9ac2c34 100644 --- a/resources/views/admin/users/edit.blade.php +++ b/resources/views/admin/users/edit.blade.php @@ -22,7 +22,7 @@ - + School @foreach ($schools as $school) @@ -31,6 +31,11 @@ @endforeach +

+ + Head Director + +
Administrator diff --git a/resources/views/emails/new_user_password.blade.php b/resources/views/emails/new_user_password.blade.php index a9519d9..990ac82 100644 --- a/resources/views/emails/new_user_password.blade.php +++ b/resources/views/emails/new_user_password.blade.php @@ -5,9 +5,10 @@

Hello, {{ $user->first_name }} {{ $user->last_name }}

-

Your account has been created. Here are your login details:

+

Your AuditionAdmin account for {{ auditionSetting('auditionAbbreviation') }} has been created. Here are your login details:

Email: {{ $user->email }}

Password: {{ $password }}

+

Login at: {{route('login')}}

Please change your password after logging in for the first time.

diff --git a/resources/views/schools/create.blade.php b/resources/views/schools/create.blade.php index 6487e05..b93a776 100644 --- a/resources/views/schools/create.blade.php +++ b/resources/views/schools/create.blade.php @@ -5,7 +5,7 @@ - + diff --git a/resources/views/schools/show.blade.php b/resources/views/schools/show.blade.php index df818e2..742f8d4 100644 --- a/resources/views/schools/show.blade.php +++ b/resources/views/schools/show.blade.php @@ -1,39 +1,117 @@ - - School Info - {{ $school->name }} +
+ + School Info - {{ $school->name }} -
- - - -
-
- {{ $school->name }}
- {{ $school->address }}
- {{ $school->city }}, {{ $school->state }} {{ $school->zip }} +
+ + + +
+
+ {{ $school->name }}
+ {{ $school->address }}
+ {{ $school->city }}, {{ $school->state }} {{ $school->zip }} +
+
-
- [ Edit School ] + + + +
    + @foreach($school->directors as $director) +
  • + {{ $director->full_name() }} + @if($director->hasFlag('head_director')) (head) @endif + - + {{ $director->email }} +
  • + @endforeach +
+ @if(auth()->user()->hasFlag('head_director')) +
+ Add Director + Change Head +
+ @endif +
+ @if(auth()->user()->hasFlag('head_director')) + +

Users with emails in these domains (the part after the @) that don't already have a school + will be able to join your school.

+
    + @foreach($school->emailDomains as $domain) +
  • + + + + {{ $domain->domain }} +
  • + @endforeach +
  • +

    Add Domain

    + +
    + + Add +
    +
    +
  • +
+
+ @endif + + +
+ + + + @if(auth()->user()->hasFlag('head_director')) + + Add Director + + + + + + + + + + + + + + Change Head Director + {{-- Warming Message --}} +
+
+
+ +
+
+

WARNING!!!

+
+

After making another director head, you will no longer have access to head director functions

+

This action cannot be undone

- - - -
    - @foreach($school->directors as $director) -
  • {{ $director->full_name() }} - {{ $director->email }}
  • - @endforeach -
-
- - -
    - @foreach($school->emailDomains as $domain) -
  • {{ $domain->domain }}
  • - @endforeach -
-
- - -
- - +
+
+

Which director should be the head director at {{$school->name}}? (click to set)

+ + @foreach($school->directors as $director) + @continue($director->id === auth()->user()->id) + + + {{ $director->full_name() }} < {{$director->email }} > + + + @endforeach + + + @endif +
diff --git a/routes/admin.php b/routes/admin.php index 11529cb..87de676 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -150,6 +150,7 @@ Route::middleware(['auth', 'verified', CheckIfAdmin::class])->prefix('admin/')-> Route::post('/', 'store')->name('admin.schools.store'); Route::delete('/domain/{domain}', 'destroy_domain')->name('admin.schools.destroy_domain'); Route::delete('/{school}', 'destroy')->name('admin.schools.destroy'); + Route::get('/{school}/set_head_director/{user}', 'setHeadDirector')->name('admin.schools.set_head_director'); }); diff --git a/routes/user.php b/routes/user.php index a210d90..e9a0c71 100644 --- a/routes/user.php +++ b/routes/user.php @@ -52,6 +52,10 @@ Route::middleware(['auth', 'verified'])->controller(SchoolController::class)->gr Route::get('/schools/{school}/edit', 'edit')->name('schools.edit'); Route::get('/schools/{school}', 'show')->name('schools.show'); Route::patch('/schools/{school}', 'update')->name('schools.update'); + Route::post('schools/{school}/add_director', 'addDirector')->name('schools.add_director'); + Route::get('/schools/{school}/set_head_director/{user}', 'setHeadDirector')->name('schools.set_head_director'); + Route::post('/schools/{school}/add_domain', 'addDomain')->name('schools.add_domain'); + Route::get('/schools/delete_domain/{domain}', 'deleteDomain')->name('schools.delete_domain'); }); // Doubler Related Routes diff --git a/tests/Feature/Actions/SetHeadDirectorTest.php b/tests/Feature/Actions/SetHeadDirectorTest.php new file mode 100644 index 0000000..3f5fd97 --- /dev/null +++ b/tests/Feature/Actions/SetHeadDirectorTest.php @@ -0,0 +1,51 @@ +setter = app(SetHeadDirector::class); +}); + +it('sets a head director flag for a user with a school', function () { + // Arrange + $school = School::factory()->create(); + $user = User::factory()->create(['school_id' => $school->id]); + $this->setter->setHeadDirector($user); + $this->assertDatabaseHas('user_flags', [ + 'user_id' => $user->id, + 'flag_name' => 'head_director', + ]); +}); +it('throws an error if the user has no school', function () { + // Arrange + $user = User::factory()->create(); + // Act & Assert + $this->setter->setHeadDirector($user); +})->throws(AuditionAdminException::class, 'User is not associated with a school'); +it('removes the head director flag from any other users as the school', function () { + // Arrange + $school = School::factory()->create(); + $oldHead = User::factory()->create(['school_id' => $school->id]); + $newHead = User::factory()->create(['school_id' => $school->id]); + $oldHead->addFlag('head_director'); + $this->assertDatabaseHas('user_flags', [ + 'user_id' => $oldHead->id, + 'flag_name' => 'head_director', + ]); + // Act + $this->setter->setHeadDirector($newHead); + $this->assertDatabaseHas('user_flags', [ + 'user_id' => $newHead->id, + 'flag_name' => 'head_director', + ]); + $this->assertDatabaseMissing('user_flags', [ + 'user_id' => $oldHead->id, + 'flag_name' => 'head_director', + ]); +}); diff --git a/tests/Feature/Pages/Admin/UsersEditTest.php b/tests/Feature/Pages/Admin/UsersEditTest.php index 260a368..3c36588 100644 --- a/tests/Feature/Pages/Admin/UsersEditTest.php +++ b/tests/Feature/Pages/Admin/UsersEditTest.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs; use function Pest\Laravel\assertDatabaseHas; use function Pest\Laravel\delete; +use function Pest\Laravel\from; use function Pest\Laravel\get; use function Pest\Laravel\patch; @@ -150,7 +151,7 @@ it('allows an administrator to modify a user', function () { 'school_id' => $newSchool->id, ]; // Act - $response = patch(route('admin.users.update', $this->users[0]), $newData); + $response = from(route('admin.users.index'))->patch(route('admin.users.update', $this->users[0]), $newData); /** @noinspection PhpUnhandledExceptionInspection */ $response ->assertSessionHasNoErrors() @@ -186,7 +187,7 @@ it('allows a users school to be set to no school', function () { ]; actAsAdmin(); // Act & Assert - $response = patch(route('admin.users.update', $user), $newData); + $response = from(route('admin.users.index'))->patch(route('admin.users.update', $user), $newData); /** @noinspection PhpUnhandledExceptionInspection */ $response ->assertSessionHasNoErrors()