From 5f9d5ffc4fba905737acf37d29ad31a67c408371 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Thu, 23 Jul 2026 12:52:27 -0300 Subject: [PATCH] Add instructor group-class roster view under My Lessons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instructors can now see their own group classes under My Lessons → My Group Classes (view_own_lessons): each class shows its active enrolment count against capacity plus a roster of enrolled students with enrolment and payment status. GroupClassController gains renderInstructorPage(), backed by the existing per-instructor enrolment query and a newly injected PaymentRepository for payment status. Wired as a submenu under the existing My Lessons menu, inside the same !view_all_lessons guard so owner-operators don't get a duplicate item. Closes #71 Co-Authored-By: Claude Opus 4.8 --- docs/features/group-classes.md | 14 +- src/AdminMenu.php | 12 +- src/GroupClass/GroupClassController.php | 53 ++++++ templates/admin/my-group-classes.php | 64 +++++++ .../GroupClass/GroupClassControllerTest.php | 160 ++++++++++++++++++ 5 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 templates/admin/my-group-classes.php create mode 100644 tests/Unit/GroupClass/GroupClassControllerTest.php diff --git a/docs/features/group-classes.md b/docs/features/group-classes.md index e1365ba..95ff985 100644 --- a/docs/features/group-classes.md +++ b/docs/features/group-classes.md @@ -54,13 +54,15 @@ payment step). instructor's group classes if the caller has `view_own_lessons` on those offerings. ## Admin Interface -- **Group Classes** (`manage_options` / studio admin): all enrolments across instructors -- Instructors see enrolments for their own group classes under **My Lessons** +- **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors +- **My Lessons → My Group Classes** (`view_own_lessons` / instructor): the instructor's + own group classes, each showing its active-enrolment count against capacity and a + per-class roster of enrolled students with enrolment and payment status ## Implementation - Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates) - Model: `Unsupervised\Schedular\GroupClass\Enrollment` -- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` (gated on `view_all_lessons`) +- Admin controller: `Unsupervised\Schedular\GroupClass\GroupClassController` — `renderPage` (studio admin, `view_all_lessons`) and `renderInstructorPage` (instructor, `view_own_lessons`) - REST endpoint: `Unsupervised\Schedular\GroupClass\EnrollmentEndpoint` - Frontend: `Unsupervised\Schedular\GroupClass\GroupClassPage` (`[us_group_classes]` shortcode; `offering="…"` restricts it to a single class for embedding on a dedicated page — the block equivalent is the `offeringId` attribute) - Reuses `Registration\RegistrationGate` (intake answers + booking-scoped policy acceptance, type `enrollment`) @@ -68,12 +70,10 @@ instructor's group classes if the caller has `view_own_lessons` on those offerin > **Payment:** a priced enrolment creates a payment via `Payment\PaymentService` > (`registration_type = enrollment`) and links it as `payment_id`; unpriced > enrolments return `payment: null` and skip the payment step. See `payments.md` -> for the card/e-transfer/comp flows. Instructor-specific enrolment views (the -> spec's "under My Lessons") are a follow-up (#71) — this iteration ships the -> studio-admin **Group Classes** page (`view_all_lessons`) plus -> per-student/per-instructor REST queries. +> for the card/e-transfer/comp flows. ## Tests +- `tests/Unit/GroupClass/GroupClassControllerTest.php` - `tests/Unit/GroupClass/EnrollmentTest.php` - `tests/Unit/GroupClass/EnrollmentRepositoryTest.php` - `tests/Unit/GroupClass/GroupClassPageTest.php` diff --git a/src/AdminMenu.php b/src/AdminMenu.php index dbfcd76..6fb5e01 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -61,7 +61,7 @@ class AdminMenu { $this->policyController = new PolicyController( $policies, $policyVersions, $policyService ); $this->registrationController = new RegistrationController( $invites ); $this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() ); - $this->groupClassController = new GroupClassController( $enrollments, $offerings ); + $this->groupClassController = new GroupClassController( $enrollments, $offerings, $payments ); $this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments, $resolver, new StudentHistory( $acceptances, $policies, $policyVersions, $answers, $questions, $payments ), new StudentActions( $bookings, $availability, $enrollments, $paymentService ) ); $this->instructorController = new InstructorController(); $this->settings = $settings; @@ -247,6 +247,16 @@ class AdminMenu { 'dashicons-welcome-learn-more', 42 ); + + // Instructor: their own group classes with per-class rosters. + add_submenu_page( + 'us-my-lessons', + __( 'My Group Classes', 'unsupervised-schedular' ), + __( 'My Group Classes', 'unsupervised-schedular' ), + RoleManager::CAP_VIEW_LESSONS, + 'us-my-group-classes', + [ $this->groupClassController, 'renderInstructorPage' ] + ); } } diff --git a/src/GroupClass/GroupClassController.php b/src/GroupClass/GroupClassController.php index 25d10fd..00deca1 100644 --- a/src/GroupClass/GroupClassController.php +++ b/src/GroupClass/GroupClassController.php @@ -4,13 +4,16 @@ declare(strict_types=1); namespace Unsupervised\Schedular\GroupClass; use Unsupervised\Schedular\Auth\RoleManager; +use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\OfferingRepository; +use Unsupervised\Schedular\Payment\PaymentRepository; class GroupClassController { public function __construct( private EnrollmentRepository $enrollments, private OfferingRepository $offerings, + private PaymentRepository $payments, ) {} public function renderPage(): void { @@ -34,4 +37,54 @@ class GroupClassController { include USC_PLUGIN_DIR . 'templates/admin/group-classes.php'; } + + /** + * Instructor view: their own group classes with per-class rosters. Each class + * shows its enrolment count against capacity plus a roster of enrolled + * students with enrolment and payment status. + */ + public function renderInstructorPage(): void { + if ( ! current_user_can( RoleManager::CAP_VIEW_LESSONS ) ) { + wp_die( esc_html__( 'You do not have permission to view group classes.', 'unsupervised-schedular' ) ); + } + + $instructorId = get_current_user_id(); + $enrollments = $this->enrollments->findByInstructor( $instructorId ); + + $classes = array_map( + function ( Offering $offering ) use ( $enrollments ): array { + $roster = []; + $enrolled = 0; + + foreach ( $enrollments as $enrollment ) { + if ( $enrollment->offeringId !== $offering->id ) { + continue; + } + + if ( Enrollment::STATUS_ACTIVE === $enrollment->status ) { + ++$enrolled; + } + + $student = get_userdata( $enrollment->studentId ); + $payment = null !== $enrollment->paymentId ? $this->payments->findById( $enrollment->paymentId ) : null; + + $roster[] = [ + 'student' => $student ? $student->display_name : (string) $enrollment->studentId, + 'status' => $enrollment->status, + 'payment' => $payment?->status, + ]; + } + + return [ + 'title' => $offering->title, + 'capacity' => $offering->capacity, + 'enrolled' => $enrolled, + 'roster' => $roster, + ]; + }, + $this->offerings->findAll( $instructorId, Offering::KIND_GROUP_CLASS ) + ); + + include USC_PLUGIN_DIR . 'templates/admin/my-group-classes.php'; + } } diff --git a/templates/admin/my-group-classes.php b/templates/admin/my-group-classes.php new file mode 100644 index 0000000..b0775e9 --- /dev/null +++ b/templates/admin/my-group-classes.php @@ -0,0 +1,64 @@ +}> $classes */ +?> +
+

+

+ + +

+ + +

+ + + + +

+ + +

+ + + + + + + + + + + + + + + + + + +
+ + + +
diff --git a/tests/Unit/GroupClass/GroupClassControllerTest.php b/tests/Unit/GroupClass/GroupClassControllerTest.php new file mode 100644 index 0000000..0c1156d --- /dev/null +++ b/tests/Unit/GroupClass/GroupClassControllerTest.php @@ -0,0 +1,160 @@ +enrollments = Mockery::mock(EnrollmentRepository::class); + $this->offerings = Mockery::mock(OfferingRepository::class); + $this->payments = Mockery::mock(PaymentRepository::class); + $this->controller = new GroupClassController($this->enrollments, $this->offerings, $this->payments); + + Functions\when('current_user_can')->justReturn(true); + Functions\when('get_current_user_id')->justReturn(3); + } + + private function offering(int $id, string $title, ?int $capacity): Offering + { + return new Offering( + instructorId: 3, + kind: Offering::KIND_GROUP_CLASS, + title: $title, + capacity: $capacity, + id: $id, + ); + } + + private function renderInstructor(): string + { + ob_start(); + $this->controller->renderInstructorPage(); + + return (string) ob_get_clean(); + } + + public function testInstructorPageListsClassWithCapacityAndRoster(): void + { + Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); + + $offering = $this->offering(8, 'Choir', 10); + $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: 42, id: 1); + + $this->offerings->shouldReceive('findAll')->once() + ->with(3, Offering::KIND_GROUP_CLASS)->andReturn([$offering]); + $this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([$enrollment]); + $this->payments->shouldReceive('findById')->once()->with(42)->andReturn( + new Payment(studentId: 5, instructorId: 3, registrationType: Payment::REG_ENROLLMENT, registrationId: 1, amount: 100.0, status: Payment::STATUS_PAID, id: 42) + ); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('Choir', $html); + self::assertStringContainsString('(1 / 10 enrolled)', $html); + self::assertStringContainsString('Ada Lovelace', $html); + self::assertStringContainsString('paid', $html); + } + + public function testEnrolmentCountExcludesCancelledButRosterKeepsThem(): void + { + Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Grace Hopper']); + + $offering = $this->offering(8, 'Band', null); + $active = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1); + $cancelled = new Enrollment(offeringId: 8, studentId: 6, instructorId: 3, status: Enrollment::STATUS_CANCELLED, id: 2); + + $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); + $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$active, $cancelled]); + + $html = $this->renderInstructor(); + + // Unlimited capacity offering counts only the active enrolment. + self::assertStringContainsString('(1 enrolled)', $html); + // But the roster still shows the cancelled row. + self::assertStringContainsString('cancelled', $html); + } + + public function testFreeEnrolmentShowsDashForPayment(): void + { + Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Alan Turing']); + + $offering = $this->offering(8, 'Theory', 5); + $enrollment = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, paymentId: null, id: 1); + + $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); + $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$enrollment]); + $this->payments->shouldReceive('findById')->never(); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('—', $html); + } + + public function testEnrolmentsForOtherClassesAreNotMixedIn(): void + { + Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Katherine Johnson']); + + $offering = $this->offering(8, 'Choir', 5); + $mine = new Enrollment(offeringId: 8, studentId: 5, instructorId: 3, id: 1); + $other = new Enrollment(offeringId: 9, studentId: 6, instructorId: 3, id: 2); + + $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); + $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([$mine, $other]); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('(1 / 5 enrolled)', $html); + } + + public function testClassWithNoEnrolmentsShowsEmptyMessage(): void + { + $offering = $this->offering(8, 'Jazz', 5); + + $this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]); + $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([]); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('No enrolments yet.', $html); + } + + public function testInstructorWithNoClassesShowsEmptyMessage(): void + { + $this->offerings->shouldReceive('findAll')->once()->andReturn([]); + $this->enrollments->shouldReceive('findByInstructor')->once()->andReturn([]); + + $html = $this->renderInstructor(); + + self::assertStringContainsString('You have no group classes.', $html); + } + + public function testDeniesUsersWithoutViewLessonsCapability(): void + { + Functions\when('current_user_can')->justReturn(false); + Functions\expect('wp_die')->once()->andThrow(new \RuntimeException('denied')); + + $this->expectException(\RuntimeException::class); + + $this->controller->renderInstructorPage(); + } +}