Add instructor group-class roster view under My Lessons #95

Merged
thatguygriff merged 1 commits from feature/instructor-group-classes into main 2026-07-23 15:58:10 +00:00
5 changed files with 295 additions and 8 deletions
+7 -7
View File
@@ -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`
+11 -1
View File
@@ -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' ]
);
}
}
+53
View File
@@ -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';
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
if (! defined('ABSPATH')) {
exit;
}
/** @var list<array{title: string, capacity: int|null, enrolled: int, roster: list<array{student: string, status: string, payment: string|null}>}> $classes */
?>
<div class="wrap">
<h1><?php esc_html_e('My Group Classes', 'unsupervised-schedular'); ?></h1>
<p class="description"><?php esc_html_e('Your group classes and their rosters.', 'unsupervised-schedular'); ?></p>
<?php if (empty($classes)) : ?>
<p><?php esc_html_e('You have no group classes.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<?php foreach ($classes as $class) : ?>
<h2>
<?php echo esc_html($class['title']); ?>
<span class="count">
<?php
if (null === $class['capacity']) {
printf(
/* translators: %d: number of enrolled students. */
esc_html__('(%d enrolled)', 'unsupervised-schedular'),
(int) $class['enrolled']
);
} else {
printf(
/* translators: 1: number of enrolled students, 2: class capacity. */
esc_html__('(%1$d / %2$d enrolled)', 'unsupervised-schedular'),
(int) $class['enrolled'],
(int) $class['capacity']
);
}
?>
</span>
</h2>
<?php if (empty($class['roster'])) : ?>
<p><?php esc_html_e('No enrolments yet.', 'unsupervised-schedular'); ?></p>
<?php else : ?>
<table class="wp-list-table widefat fixed striped">
<thead>
<tr>
<th><?php esc_html_e('Student', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Enrolment', 'unsupervised-schedular'); ?></th>
<th><?php esc_html_e('Payment', 'unsupervised-schedular'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($class['roster'] as $entry) : ?>
<tr>
<td><?php echo esc_html($entry['student']); ?></td>
<td><?php echo esc_html($entry['status']); ?></td>
<td><?php echo esc_html($entry['payment'] ?? '—'); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\GroupClass;
use Brain\Monkey\Functions;
use Mockery;
use Unsupervised\Schedular\GroupClass\Enrollment;
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
use Unsupervised\Schedular\GroupClass\GroupClassController;
use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment;
use Unsupervised\Schedular\Payment\PaymentRepository;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class GroupClassControllerTest extends TestCase
{
private EnrollmentRepository&Mockery\MockInterface $enrollments;
private OfferingRepository&Mockery\MockInterface $offerings;
private PaymentRepository&Mockery\MockInterface $payments;
private GroupClassController $controller;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}