Merge pull request 'Group-class scheduling, instructor assignment, and details/invite management' (#99) from feature/group-class-scheduling into main
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 49s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Coding Standards (push) Successful in 2m54s
CI / PHPStan (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m46s
Release / Build and Publish Release (push) Successful in 2m47s
Release / Open next-version bump PR (push) Successful in 4s
CI / No Debug Code (push) Successful in 3s
CI / Tests (PHP 8.1) (push) Successful in 49s
CI / Tests (PHP 8.2) (push) Successful in 46s
CI / Coding Standards (push) Successful in 2m54s
CI / PHPStan (push) Successful in 2m54s
CI / Tests (PHP 8.3) (push) Successful in 2m39s
CI / Build Plugin Zip (push) Successful in 2m46s
Release / Build and Publish Release (push) Successful in 2m47s
Release / Open next-version bump PR (push) Successful in 4s
Reviewed-on: #99
This commit was merged in pull request #99.
This commit is contained in:
+1
-1
@@ -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.
|
||||
- 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.
|
||||
- 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
|
||||
- Plugin metadata links now point at Unsupervised and the Gitea repository.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
plus the start time) and **who** teaches it (the assigned instructor's display
|
||||
name, surfaced as `instructor_name` on the `GET /offerings` response).
|
||||
plus the start time) and **who** teaches it (the assigned instructor's name,
|
||||
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
|
||||
booking slots at the class time and flags any already-booked lesson that clashes;
|
||||
|
||||
@@ -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 : '';
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RegistrationMailer;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\Offering\Offering;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
use Unsupervised\Schedular\Payment\Payment;
|
||||
@@ -73,12 +74,10 @@ class GroupClassController {
|
||||
|
||||
$rows = array_map(
|
||||
function ( Offering $offering ): array {
|
||||
$instructor = get_userdata( $offering->instructorId );
|
||||
|
||||
return [
|
||||
'id' => $offering->id,
|
||||
'title' => $offering->title,
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId,
|
||||
'instructor' => $this->instructorName( $offering ),
|
||||
'when' => $this->whenLabel( $offering ),
|
||||
'capacity' => $offering->capacity,
|
||||
'enrolled' => $this->enrollments->countActiveForOffering( (int) $offering->id ),
|
||||
@@ -196,10 +195,8 @@ class GroupClassController {
|
||||
];
|
||||
}
|
||||
|
||||
$instructor = get_userdata( $offering->instructorId );
|
||||
|
||||
return $this->classSummary( $offering, $enrollments ) + [
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $offering->instructorId,
|
||||
'instructor' => $this->instructorName( $offering ),
|
||||
'price' => $offering->price,
|
||||
'currency' => $offering->currency,
|
||||
'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
|
||||
* range) and, when set, the start time. Empty when the class has no date.
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace Unsupervised\Schedular\Offering;
|
||||
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\UserName;
|
||||
use Unsupervised\Schedular\GroupClass\GroupAccessRepository;
|
||||
use Unsupervised\Schedular\Val;
|
||||
|
||||
@@ -84,8 +85,9 @@ class OfferingEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* A public-facing offering array with the assigned instructor's display name
|
||||
* added (empty when the instructor account no longer exists).
|
||||
* A public-facing offering array with the assigned instructor's name added —
|
||||
* their real name or nickname, never the login (empty when the instructor
|
||||
* account no longer exists). See {@see UserName::format()}.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -93,7 +95,7 @@ class OfferingEndpoint {
|
||||
$out = $offering->toArray( includeEtransferEmail: false );
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return new Offering(
|
||||
@@ -109,7 +127,7 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
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'];
|
||||
|
||||
$offering = $this->offering(8, 'Choir', 10);
|
||||
@@ -131,7 +149,7 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
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'];
|
||||
|
||||
$offering = new Offering(
|
||||
@@ -167,7 +185,7 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
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'];
|
||||
|
||||
$offering = $this->offering(8, 'Band', null);
|
||||
@@ -187,7 +205,7 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
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'];
|
||||
|
||||
$offering = $this->offering(8, 'Theory', 5);
|
||||
@@ -218,7 +236,7 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
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'];
|
||||
|
||||
$offering = $this->offering(8, 'Jazz', 5);
|
||||
@@ -243,7 +261,7 @@ class GroupClassControllerTest extends TestCase
|
||||
|
||||
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(
|
||||
instructorId: 3,
|
||||
@@ -272,7 +290,7 @@ class GroupClassControllerTest extends TestCase
|
||||
public function testStudioAdminCanOpenClassDetailWithInviteControls(): void
|
||||
{
|
||||
// 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'];
|
||||
|
||||
$offering = new Offering(
|
||||
|
||||
@@ -92,7 +92,10 @@ class OfferingEndpointTest extends TestCase
|
||||
public function testIndexIncludesInstructorNameForEachOffering(): void
|
||||
{
|
||||
$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);
|
||||
|
||||
$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();
|
||||
|
||||
// Real name is shown, not the login-style display name.
|
||||
self::assertSame('Ada Lovelace', $data[0]['instructor_name']);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user