diff --git a/app/Models/Atc/PositionGroup.php b/app/Models/Atc/PositionGroup.php index e0a45df5fe..cbbffc45f2 100644 --- a/app/Models/Atc/PositionGroup.php +++ b/app/Models/Atc/PositionGroup.php @@ -27,7 +27,7 @@ public function conditions() public function positions() { - return $this->belongsToMany(Position::class, 'position_group_positions', 'position_group_id', 'position_id'); + return $this->belongsToMany(Position::class, 'position_group_positions', 'position_group_id', 'position_id')->using(PositionGroupPosition::class); } public function endorsement() diff --git a/app/Models/Atc/PositionGroupPosition.php b/app/Models/Atc/PositionGroupPosition.php index 9ac55ae95e..3b9444a5b1 100644 --- a/app/Models/Atc/PositionGroupPosition.php +++ b/app/Models/Atc/PositionGroupPosition.php @@ -2,9 +2,9 @@ namespace App\Models\Atc; -use App\Models\Model; +use Illuminate\Database\Eloquent\Relations\Pivot; -class PositionGroupPosition extends Model +class PositionGroupPosition extends Pivot { public function positionGroup() { diff --git a/app/Models/Roster.php b/app/Models/Roster.php index 22189a8ebf..e298f7dd78 100644 --- a/app/Models/Roster.php +++ b/app/Models/Roster.php @@ -6,6 +6,7 @@ use App\Models\Atc\PositionGroup; use App\Models\Atc\PositionGroupPosition; use App\Models\Mship\Account; +use App\Models\Mship\Account\Endorsement; use App\Models\Mship\Qualification; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -67,11 +68,20 @@ public function accountCanControl(Position $position) return false; } - // If the position is part of a group, - // a) are they a home member with a rating above the position's maximum? - // b) are they a visiting or transferring member with an endorsement up to a rating above the position group's maximum? - // c) are they endorsed on this specific position group? - if ($positionGroupPosition = PositionGroupPosition::where('position_id', $position->id)->first()) { + $assignedPositionGroupsWithPosition = Endorsement::where('account_id', $this->account->id) + ->whereHasMorph('endorsable', PositionGroup::class, fn ($query) => $query->whereHas('positions', fn ($query) => $query->where('positions.id', $position->id))) + ->get() + ->map(fn ($endorsement) => $endorsement->endorsable); + + $unassignedPositionGroupsWithPosition = PositionGroup::whereHas('positions', fn ($query) => $query->where('positions.id', $position->id)) + ->whereDoesntHave('membershipEndorsement', fn ($query) => $query->where('account_id', $this->account->id)) + ->get(); + + $checkPositionForPositionGroup = function (PositionGroupPosition $positionGroupPosition) { + // If the position is part of a group, + // a) are they a home member with a rating above the position's maximum? + // b) are they a visiting or transferring member with an endorsement up to a rating above the position group's maximum? + // c) are they endorsed on this specific position group? $isEntitledByHomeMemberRating = isset($positionGroupPosition->positionGroup?->maximumAtcQualification) && $this->account->hasState('DIVISION') && $this->account->qualification_atc->vatsim > $positionGroupPosition->positionGroup?->maximumAtcQualification?->vatsim; @@ -98,6 +108,46 @@ public function accountCanControl(Position $position) ->exists(); return $isEntitledByHomeMemberRating || $isEndorsedToRating || $hasEndorsementForPositionGroup; + }; + + /** If there are a PositionGroup(s) which contain the specified position + * perform a series of checks to determine if the account is entitled to + * control the position */ + if ($assignedPositionGroupsWithPosition->count() > 0) { + return $assignedPositionGroupsWithPosition->some(fn ($positionGroup) => $checkPositionForPositionGroup($positionGroup->positions->where('id', $position->id)->first()->pivot)); + } + + /** Check any unassigned position groups have a maximum atc qualification + * if so, check if the account has a rating above the maximum specified + * qualification and if so, they are entitled to control even if the + * position group hasn't been endorsed to that member. */ + $unassignedPositionGroupsWithPositionWithMaxRating = $unassignedPositionGroupsWithPosition->filter(fn ($positionGroup) => isset($positionGroup->maximumAtcQualification)); + if ($unassignedPositionGroupsWithPositionWithMaxRating->count() > 0) { + return $unassignedPositionGroupsWithPosition->some( + function (PositionGroup $positionGroup) use ($position) { + $positionGroupPosition = $positionGroup->positions->where('id', $position->id)->first()->pivot; + + return $this->account->qualification_atc->vatsim > $positionGroupPosition->positionGroup->maximumAtcQualification->vatsim; + } + ); + } + + $unassignedPositionGroupsWithoutMaxRating = $unassignedPositionGroupsWithPosition->filter(fn ($positionGroup) => ! isset($positionGroup->maximumAtcQualification)); + if ($unassignedPositionGroupsWithoutMaxRating->count() > 0) { + return $unassignedPositionGroupsWithoutMaxRating->some( + function (PositionGroup $positionGroup) use ($position) { + $positionGroupPosition = $positionGroup->positions->where('id', $position->id)->first()->pivot; + + return $this->account + ->endorsements() + ->active() + ->whereHasMorph('endorsable', + PositionGroup::class, + fn ($query) => $query->where('id', $positionGroupPosition->position_group_id) + ) + ->exists(); + } + ); } // If the position is above their rating, do they diff --git a/tests/Unit/Roster/AccountCanControlTest.php b/tests/Unit/Roster/AccountCanControlTest.php new file mode 100644 index 0000000000..50adecd339 --- /dev/null +++ b/tests/Unit/Roster/AccountCanControlTest.php @@ -0,0 +1,404 @@ +first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('DIVISION')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_cannot_control_without_rating_when_home_member() + { + $account = Account::factory()->create(); + $account->addState(State::findByCode('DIVISION')); + $account->addQualification(Qualification::code('S1')->first()); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_with_rating_when_visiting_without_endorsement() + { + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('VISITING')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_with_rating_when_transferring_without_endorsement() + { + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('TRANSFERRING')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_can_control_with_rating_when_visiting_with_endorsement() + { + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('VISITING')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + Endorsement::create([ + 'account_id' => $account->id, + 'endorsable_type' => Qualification::class, + 'endorsable_id' => $qualification->id, + 'created_by' => $this->privacc->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_detects_can_control_with_rating_when_transferring_with_endorsement() + { + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('TRANSFERRING')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + Endorsement::create([ + 'account_id' => $account->id, + 'endorsable_type' => Qualification::class, + 'endorsable_id' => $qualification->id, + 'created_by' => $this->privacc->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_if_region_member() + { + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('REGION')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_if_international_member() + { + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('INTERNATIONAL')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_handles_position_being_part_of_multiple_position_groups_when_endorsed() + { + $position = Position::factory()->create(); + + $positionGroup1 = PositionGroup::factory()->create(); + $positionGroup2 = PositionGroup::factory()->create(['id' => $positionGroup1->id + 1]); + $positionGroup1->positions()->attach($position); + $positionGroup2->positions()->attach($position); + + Endorsement::create([ + 'account_id' => $this->user->id, + 'endorsable_type' => PositionGroup::class, + 'endorsable_id' => $positionGroup2->id, + 'created_by' => $this->privacc->id, + ]); + + $roster = Roster::create([ + 'account_id' => $this->user->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_detects_can_control_with_position_group_assigned() + { + $position = Position::factory()->create(); + $positionGroup = PositionGroup::factory()->create(); + $positionGroup->positions()->attach($position); + + Endorsement::create([ + 'account_id' => $this->user->id, + 'endorsable_type' => PositionGroup::class, + 'endorsable_id' => $positionGroup->id, + 'created_by' => $this->privacc->id, + ]); + + $roster = Roster::create([ + 'account_id' => $this->user->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_with_position_group_not_assigned() + { + $position = Position::factory()->create(); + $positionGroup = PositionGroup::factory()->create(); + $positionGroup->positions()->attach($position); + + $account = Account::factory()->create(); + $account->addQualification(Qualification::code('S2')->first()); + $account->addState(State::findByCode('DIVISION')); + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_with_position_group_not_assigned_if_rated() + { + $position = Position::factory()->create(['type' => Position::TYPE_TOWER]); + $positionGroup = PositionGroup::factory()->create(); + $positionGroup->positions()->attach($position); + + $account = Account::factory()->create(); + $account->addState(State::findByCode('DIVISION')); + $account->addQualification(Qualification::code('S2')->first()); + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_position_when_not_grated_to_user_as_visitor_even_if_rated() + { + $position = Position::factory(['type' => Position::TYPE_TOWER])->create(); + $positionGroup = PositionGroup::factory()->create(); + $positionGroup->positions()->attach($position); + + $visitorAccount = Account::factory()->create(); + $visitorAccount->addQualification(Qualification::code('S2')->first()); + $visitorAccount->addState(State::findByCode('VISITING')); + + $roster = Roster::create([ + 'account_id' => $visitorAccount->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_cannot_control_position_when_not_grated_to_user_as_transferring_even_if_rated() + { + $position = Position::factory(['type' => Position::TYPE_TOWER])->create(); + $positionGroup = PositionGroup::factory()->create(); + $positionGroup->positions()->attach($position); + + $transferringAccount = Account::factory()->create(); + $transferringAccount->addQualification(Qualification::code('S2')->first()); + $transferringAccount->addState(State::findByCode('TRANSFERRING')); + + $roster = Roster::create([ + 'account_id' => $transferringAccount->id, + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_solo_endorsement_when_position_above_rating() + { + $qualification = Qualification::code('S2')->first(); + Qualification::code('S3')->first(); + + $account = Account::factory()->create(); + $account->addQualification($qualification); + + // approach exceeds the 'S2' requirements' VATSIM attribute. + $position = Position::factory()->create([ + 'type' => Position::TYPE_APPROACH, + 'temporarily_endorsable' => true, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + Endorsement::create([ + 'account_id' => $account->id, + 'endorsable_type' => Position::class, + 'endorsable_id' => $position->id, + 'created_by' => $this->privacc->id, + 'expires_at' => now()->addDays(1), + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_detects_when_solo_endorsement_expired() + { + $qualification = Qualification::code('S2')->first(); + + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('DIVISION')); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + // approach exceeds the 'S2' requirements' VATSIM attribute. + $position = Position::factory()->create([ + 'type' => Position::TYPE_APPROACH, + 'temporarily_endorsable' => true, + ]); + + Endorsement::create([ + 'account_id' => $account->id, + 'endorsable_type' => Position::class, + 'endorsable_id' => $position->id, + 'created_by' => $this->privacc->id, + 'expires_at' => now()->subDays(1), + ]); + + $this->assertFalse($roster->accountCanControl($position)); + } + + public function test_detects_can_control_with_direct_endorsement_without_expiry() + { + $position = Position::factory()->create(); + + $roster = Roster::create([ + 'account_id' => $this->user->id, + ]); + + Endorsement::create([ + 'account_id' => $this->user->id, + 'endorsable_type' => Position::class, + 'endorsable_id' => $position->id, + 'created_by' => $this->privacc->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_can_control_when_endorsement_not_assigned_but_rating_facilitates() + { + $lowerQualification = Qualification::code('S1')->first(); + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('DIVISION')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + $positionGroup = PositionGroup::factory()->create(['maximum_atc_qualification_id' => $lowerQualification->id]); + $positionGroup->positions()->attach($position); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } + + public function test_can_control_when_endorsed_to_qualification() + { + $qualification = Qualification::code('S2')->first(); + $account = Account::factory()->create(); + $account->addQualification($qualification); + $account->addState(State::findByCode('VISITING')); + + $position = Position::factory()->create([ + 'type' => Position::TYPE_TOWER, + ]); + + Endorsement::create([ + 'account_id' => $account->id, + 'endorsable_type' => Qualification::class, + 'endorsable_id' => $qualification->id, + 'created_by' => $this->privacc->id, + ]); + + $roster = Roster::create([ + 'account_id' => $account->id, + ]); + + $this->assertTrue($roster->accountCanControl($position)); + } +}