Group-class scheduling, instructor assignment, and details/invite management #99

Merged
thatguygriff merged 1 commits from feature/group-class-scheduling into main 2026-07-23 20:56:59 +00:00
8 changed files with 136 additions and 20 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ each change under the current top section as you work.
- Cancellation cutoff that limits how close to a lesson a student can cancel. - Cancellation cutoff that limits how close to a lesson a student can cancel.
- Studio-defined account-registration questions collected during student sign-up. - Studio-defined account-registration questions collected during student sign-up.
- Group classes now carry a specific class time (alongside the date and duration), and studio admins can assign the teaching instructor. Assigning an instructor clears their open booking slots at the class time and flags any already-booked lesson that clashes. - Group classes now carry a specific class time (alongside the date and duration), and studio admins can assign the teaching instructor. Assigning an instructor clears their open booking slots at the class time and flags any already-booked lesson that clashes.
- Students see who teaches each group class and when it meets on the enrolment page. - Students see who teaches each group class and when it meets on the enrolment page. Instructor names in the group-class views (front and back end) show the instructor's real name (first + last) or nickname, never their login/username.
### Changed ### Changed
- Plugin metadata links now point at Unsupervised and the Gitea repository. - Plugin metadata links now point at Unsupervised and the Gitea repository.
+4 -2
View File
@@ -22,8 +22,10 @@ A group class offering carries `term_start`/`term_end` plus a `class_time` and a
owning `instructor_id` (see `offerings.md`): one-off classes end the day they owning `instructor_id` (see `offerings.md`): one-off classes end the day they
start; weekly classes run a set number of sessions, all at `class_time`. The class start; weekly classes run a set number of sessions, all at `class_time`. The class
card on the enrolment page shows **when** the class meets (the date or date range card on the enrolment page shows **when** the class meets (the date or date range
plus the start time) and **who** teaches it (the assigned instructor's display plus the start time) and **who** teaches it (the assigned instructor's name,
name, surfaced as `instructor_name` on the `GET /offerings` response). surfaced as `instructor_name` on the `GET /offerings` response). Instructor names
in the group-class views (front and back end) use the instructor's real name
(first + last) or nickname, never their login — see `Auth\UserName::format()`.
Assigning an instructor to a scheduled class removes that instructor's open Assigning an instructor to a scheduled class removes that instructor's open
booking slots at the class time and flags any already-booked lesson that clashes; booking slots at the class time and flags any already-booked lesson that clashes;
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Auth;
/**
* Resolves a person's public-facing name for display. Prefers their real name
* (first + last), then their nickname — deliberately avoiding the account's
* login/username, which `display_name` can otherwise expose.
*/
class UserName {
/**
* The display name for a user: "First Last" when a real name is set,
* otherwise the WordPress nickname. Falls back to the numeric id (or an empty
* string when none is given) when the user cannot be loaded or has no name.
*/
public static function format( ?\WP_User $user, int $fallbackId = 0 ): string {
if ( ! $user instanceof \WP_User ) {
return $fallbackId > 0 ? (string) $fallbackId : '';
}
$full = trim( $user->first_name . ' ' . $user->last_name );
if ( '' !== $full ) {
return $full;
}
$nickname = trim( $user->nickname );
if ( '' !== $nickname ) {
return $nickname;
}
return $fallbackId > 0 ? (string) $fallbackId : '';
}
}
+14 -6
View File
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
use Unsupervised\Schedular\Auth\RegistrationController; use Unsupervised\Schedular\Auth\RegistrationController;
use Unsupervised\Schedular\Auth\RegistrationMailer; use Unsupervised\Schedular\Auth\RegistrationMailer;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Offering\Offering; use Unsupervised\Schedular\Offering\Offering;
use Unsupervised\Schedular\Offering\OfferingRepository; use Unsupervised\Schedular\Offering\OfferingRepository;
use Unsupervised\Schedular\Payment\Payment; use Unsupervised\Schedular\Payment\Payment;
@@ -73,12 +74,10 @@ class GroupClassController {
$rows = array_map( $rows = array_map(
function ( Offering $offering ): array { function ( Offering $offering ): array {
$instructor = get_userdata( $offering->instructorId );
return [ return [
'id' => $offering->id, 'id' => $offering->id,
'title' => $offering->title, 'title' => $offering->title,
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId, 'instructor' => $this->instructorName( $offering ),
'when' => $this->whenLabel( $offering ), 'when' => $this->whenLabel( $offering ),
'capacity' => $offering->capacity, 'capacity' => $offering->capacity,
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ), 'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
@@ -196,10 +195,8 @@ class GroupClassController {
]; ];
} }
$instructor = get_userdata( $offering->instructorId );
return $this->classSummary( $offering, $enrollments ) + [ return $this->classSummary( $offering, $enrollments ) + [
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId, 'instructor' => $this->instructorName( $offering ),
'price' => $offering->price, 'price' => $offering->price,
'currency' => $offering->currency, 'currency' => $offering->currency,
'duration' => $offering->durationMinutes, 'duration' => $offering->durationMinutes,
@@ -211,6 +208,17 @@ class GroupClassController {
]; ];
} }
/**
* The teaching instructor's display name — their real name or nickname, never
* the login. Falls back to the numeric id when the account is gone. See
* {@see UserName::format()}.
*/
private function instructorName( Offering $offering ): string {
$user = get_userdata( $offering->instructorId );
return UserName::format( $user instanceof \WP_User ? $user : null, $offering->instructorId );
}
/** /**
* Human-readable "when" label for a class: the class date (or weekly date * Human-readable "when" label for a class: the class date (or weekly date
* range) and, when set, the start time. Empty when the class has no date. * range) and, when set, the start time. Empty when the class has no date.
+5 -3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Unsupervised\Schedular\Offering; namespace Unsupervised\Schedular\Offering;
use Unsupervised\Schedular\Auth\RoleManager; use Unsupervised\Schedular\Auth\RoleManager;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\GroupClass\GroupAccessRepository; use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
use Unsupervised\Schedular\Val; use Unsupervised\Schedular\Val;
@@ -84,8 +85,9 @@ class OfferingEndpoint {
} }
/** /**
* A public-facing offering array with the assigned instructor's display name * A public-facing offering array with the assigned instructor's name added —
* added (empty when the instructor account no longer exists). * their real name or nickname, never the login (empty when the instructor
* account no longer exists). See {@see UserName::format()}.
* *
* @return array<string, mixed> * @return array<string, mixed>
*/ */
@@ -93,7 +95,7 @@ class OfferingEndpoint {
$out = $offering->toArray( includeEtransferEmail: false ); $out = $offering->toArray( includeEtransferEmail: false );
$user = get_userdata( $offering->instructorId ); $user = get_userdata( $offering->instructorId );
$out['instructor_name'] = $user instanceof \WP_User ? $user->display_name : ''; $out['instructor_name'] = UserName::format( $user instanceof \WP_User ? $user : null );
return $out; return $out;
} }
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Auth;
use Mockery;
use Unsupervised\Schedular\Auth\UserName;
use Unsupervised\Schedular\Tests\Unit\TestCase;
class UserNameTest extends TestCase
{
private function user(string $first, string $last, string $nickname): \WP_User
{
$user = Mockery::mock(\WP_User::class);
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $nickname;
return $user;
}
public function testPrefersFirstAndLastName(): void
{
self::assertSame('Ada Lovelace', UserName::format($this->user('Ada', 'Lovelace', 'ada_login')));
}
public function testUsesFirstNameAloneWhenLastNameMissing(): void
{
self::assertSame('Ada', UserName::format($this->user('Ada', '', 'ada_login')));
}
public function testFallsBackToNicknameWhenNoRealName(): void
{
self::assertSame('Countess', UserName::format($this->user('', '', 'Countess')));
}
public function testFallsBackToIdWhenNothingSet(): void
{
self::assertSame('42', UserName::format($this->user('', '', ''), 42));
}
public function testReturnsFallbackIdWhenUserMissing(): void
{
self::assertSame('42', UserName::format(null, 42));
self::assertSame('', UserName::format(null));
}
}
@@ -69,6 +69,24 @@ class GroupClassControllerTest extends TestCase
$_GET = []; $_GET = [];
} }
/**
* A WP_User whose real name (and display name) is the given full name, so
* both instructor resolution (first + last) and roster display (display name)
* render it.
*/
private function userNamed(string $full): \WP_User
{
[$first, $last] = array_pad(explode(' ', $full, 2), 2, '');
$user = Mockery::mock(\WP_User::class);
$user->first_name = $first;
$user->last_name = $last;
$user->nickname = $full;
$user->display_name = $full;
return $user;
}
private function offering(int $id, string $title, ?int $capacity): Offering private function offering(int $id, string $title, ?int $capacity): Offering
{ {
return new Offering( return new Offering(
@@ -109,7 +127,7 @@ class GroupClassControllerTest extends TestCase
public function testClassDetailListsRosterWithPaymentStatus(): void public function testClassDetailListsRosterWithPaymentStatus(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8']; $_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Choir', 10); $offering = $this->offering(8, 'Choir', 10);
@@ -131,7 +149,7 @@ class GroupClassControllerTest extends TestCase
public function testClassDetailShowsClassSettingsAndInviteControlsForInviteOnly(): void public function testClassDetailShowsClassSettingsAndInviteControlsForInviteOnly(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8']; $_GET = ['class_id' => '8'];
$offering = new Offering( $offering = new Offering(
@@ -167,7 +185,7 @@ class GroupClassControllerTest extends TestCase
public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Grace Hopper']); Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper'));
$_GET = ['class_id' => '8']; $_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Band', null); $offering = $this->offering(8, 'Band', null);
@@ -187,7 +205,7 @@ class GroupClassControllerTest extends TestCase
public function testClassDetailFreeEnrolmentShowsDashForPayment(): void public function testClassDetailFreeEnrolmentShowsDashForPayment(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Alan Turing']); Functions\when('get_userdata')->justReturn($this->userNamed('Alan Turing'));
$_GET = ['class_id' => '8']; $_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Theory', 5); $offering = $this->offering(8, 'Theory', 5);
@@ -218,7 +236,7 @@ class GroupClassControllerTest extends TestCase
public function testClassDetailWithNoEnrolmentsShowsEmptyMessage(): void public function testClassDetailWithNoEnrolmentsShowsEmptyMessage(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8']; $_GET = ['class_id' => '8'];
$offering = $this->offering(8, 'Jazz', 5); $offering = $this->offering(8, 'Jazz', 5);
@@ -243,7 +261,7 @@ class GroupClassControllerTest extends TestCase
public function testStudioAdminPageSummarisesClassesNotStudents(): void public function testStudioAdminPageSummarisesClassesNotStudents(): void
{ {
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$offering = new Offering( $offering = new Offering(
instructorId: 3, instructorId: 3,
@@ -272,7 +290,7 @@ class GroupClassControllerTest extends TestCase
public function testStudioAdminCanOpenClassDetailWithInviteControls(): void public function testStudioAdminCanOpenClassDetailWithInviteControls(): void
{ {
// A studio admin (view_all_lessons) opens a class taught by instructor 7. // A studio admin (view_all_lessons) opens a class taught by instructor 7.
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Ada Lovelace']); Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8']; $_GET = ['class_id' => '8'];
$offering = new Offering( $offering = new Offering(
+5 -1
View File
@@ -92,7 +92,10 @@ class OfferingEndpointTest extends TestCase
public function testIndexIncludesInstructorNameForEachOffering(): void public function testIndexIncludesInstructorNameForEachOffering(): void
{ {
$instructor = Mockery::mock(\WP_User::class); $instructor = Mockery::mock(\WP_User::class);
$instructor->display_name = 'Ada Lovelace'; $instructor->first_name = 'Ada';
$instructor->last_name = 'Lovelace';
$instructor->nickname = 'ada_login';
$instructor->display_name = 'ada_login';
Functions\when('get_userdata')->justReturn($instructor); Functions\when('get_userdata')->justReturn($instructor);
$this->repository->shouldReceive('findAll')->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]); $this->repository->shouldReceive('findAll')->andReturn([$this->group(1, Offering::ACCESS_PUBLIC)]);
@@ -100,6 +103,7 @@ class OfferingEndpointTest extends TestCase
$data = $this->endpoint->index(new \WP_REST_Request())->get_data(); $data = $this->endpoint->index(new \WP_REST_Request())->get_data();
// Real name is shown, not the login-style display name.
self::assertSame('Ada Lovelace', $data[0]['instructor_name']); self::assertSame('Ada Lovelace', $data[0]['instructor_name']);
} }