Merge pull request 'Add instructor group-class roster view under My Lessons' (#95) from feature/instructor-group-classes into main
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m53s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m49s
CI / Tests (PHP 8.1) (push) Successful in 44s
CI / Tests (PHP 8.2) (push) Successful in 45s
CI / No Debug Code (push) Successful in 2s
CI / Coding Standards (push) Successful in 2m53s
CI / PHPStan (push) Successful in 2m49s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m49s
Reviewed-on: #95
This commit was merged in pull request #95.
This commit is contained in:
@@ -54,13 +54,15 @@ payment step).
|
|||||||
instructor's group classes if the caller has `view_own_lessons` on those offerings.
|
instructor's group classes if the caller has `view_own_lessons` on those offerings.
|
||||||
|
|
||||||
## Admin Interface
|
## Admin Interface
|
||||||
- **Group Classes** (`manage_options` / studio admin): all enrolments across instructors
|
- **Group Classes** (`view_all_lessons` / studio admin): all active enrolments across instructors
|
||||||
- Instructors see enrolments for their own group classes under **My Lessons**
|
- **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
|
## Implementation
|
||||||
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
|
- Repository: `Unsupervised\Schedular\GroupClass\EnrollmentRepository` (`countActiveForOffering`/`hasActiveEnrollment` enforce capacity and prevent duplicates)
|
||||||
- Model: `Unsupervised\Schedular\GroupClass\Enrollment`
|
- 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`
|
- 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)
|
- 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`)
|
- 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`
|
> **Payment:** a priced enrolment creates a payment via `Payment\PaymentService`
|
||||||
> (`registration_type = enrollment`) and links it as `payment_id`; unpriced
|
> (`registration_type = enrollment`) and links it as `payment_id`; unpriced
|
||||||
> enrolments return `payment: null` and skip the payment step. See `payments.md`
|
> enrolments return `payment: null` and skip the payment step. See `payments.md`
|
||||||
> for the card/e-transfer/comp flows. Instructor-specific enrolment views (the
|
> for the card/e-transfer/comp flows.
|
||||||
> 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.
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
- `tests/Unit/GroupClass/GroupClassControllerTest.php`
|
||||||
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
- `tests/Unit/GroupClass/EnrollmentTest.php`
|
||||||
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
- `tests/Unit/GroupClass/EnrollmentRepositoryTest.php`
|
||||||
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
- `tests/Unit/GroupClass/GroupClassPageTest.php`
|
||||||
|
|||||||
+11
-1
@@ -61,7 +61,7 @@ class AdminMenu {
|
|||||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||||
$this->registrationController = new RegistrationController( $invites );
|
$this->registrationController = new RegistrationController( $invites );
|
||||||
$this->registrationApprovalController = new RegistrationApprovalController( new RegistrationMailer() );
|
$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->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->instructorController = new InstructorController();
|
||||||
$this->settings = $settings;
|
$this->settings = $settings;
|
||||||
@@ -247,6 +247,16 @@ class AdminMenu {
|
|||||||
'dashicons-welcome-learn-more',
|
'dashicons-welcome-learn-more',
|
||||||
42
|
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' ]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ declare(strict_types=1);
|
|||||||
namespace Unsupervised\Schedular\GroupClass;
|
namespace Unsupervised\Schedular\GroupClass;
|
||||||
|
|
||||||
use Unsupervised\Schedular\Auth\RoleManager;
|
use Unsupervised\Schedular\Auth\RoleManager;
|
||||||
|
use Unsupervised\Schedular\Offering\Offering;
|
||||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||||
|
use Unsupervised\Schedular\Payment\PaymentRepository;
|
||||||
|
|
||||||
class GroupClassController {
|
class GroupClassController {
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private EnrollmentRepository $enrollments,
|
private EnrollmentRepository $enrollments,
|
||||||
private OfferingRepository $offerings,
|
private OfferingRepository $offerings,
|
||||||
|
private PaymentRepository $payments,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function renderPage(): void {
|
public function renderPage(): void {
|
||||||
@@ -34,4 +37,54 @@ class GroupClassController {
|
|||||||
|
|
||||||
include USC_PLUGIN_DIR . 'templates/admin/group-classes.php';
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user