Add student administration view (studio-admin)
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / Coding Standards (pull_request) Successful in 56s
CI / PHPStan (pull_request) Successful in 57s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.3) (pull_request) Successful in 48s
CI / Build Plugin Zip (pull_request) Has been skipped
CI / Tests (PHP 8.1) (pull_request) Successful in 43s
CI / Coding Standards (pull_request) Successful in 56s
CI / PHPStan (pull_request) Successful in 57s
CI / No Debug Code (pull_request) Successful in 2s
CI / Tests (PHP 8.2) (pull_request) Successful in 44s
CI / Tests (PHP 8.3) (pull_request) Successful in 48s
CI / Build Plugin Zip (pull_request) Has been skipped
Implements #22: a read-only Students area for studio admins. - StudentController (manage_students): a list of us_student users with upcoming-lesson and active-enrolment counts, each linking to a detail page showing account info, upcoming/past lessons (offering, instructor, status), and group-class enrolments. - StudentSchedule::partition() — pure, unit-tested upcoming/past split. - Repo counts: BookingRepository::countUpcomingForStudent and EnrollmentRepository::countActiveForStudent (single-query, tested). - Templates: templates/admin/students.php, student-detail.php. - Students admin menu wired in AdminMenu (no Plugin change — the repos were already available there). - Docs: README status flipped to implemented; feature spec updated. Payment history slots into the detail when Payments (#7) lands. Tests: StudentScheduleTest + the two repo count tests. composer test (127), cs, and PHPStan level 6 all pass. Refs #22 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Auth\InviteRepository;
|
||||
use Unsupervised\Schedular\Auth\RegistrationController;
|
||||
use Unsupervised\Schedular\Auth\RoleManager;
|
||||
use Unsupervised\Schedular\Auth\StudentController;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\LessonController;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
@@ -30,6 +31,7 @@ class AdminMenu {
|
||||
private PolicyController $policyController;
|
||||
private RegistrationController $registrationController;
|
||||
private GroupClassController $groupClassController;
|
||||
private StudentController $studentController;
|
||||
|
||||
public function __construct( AvailabilityRepository $availability, BookingRepository $bookings, OfferingRepository $offerings, QuestionRepository $questions, PolicyRepository $policies, PolicyVersionRepository $policyVersions, PolicyService $policyService, InviteRepository $invites, EnrollmentRepository $enrollments ) {
|
||||
$this->availabilityController = new AvailabilityController( $availability, $offerings );
|
||||
@@ -39,6 +41,7 @@ class AdminMenu {
|
||||
$this->policyController = new PolicyController( $policies, $policyVersions, $policyService );
|
||||
$this->registrationController = new RegistrationController( $invites );
|
||||
$this->groupClassController = new GroupClassController( $enrollments, $offerings );
|
||||
$this->studentController = new StudentController( $bookings, $availability, $offerings, $enrollments );
|
||||
}
|
||||
|
||||
public function register(): void {
|
||||
@@ -122,6 +125,17 @@ class AdminMenu {
|
||||
36
|
||||
);
|
||||
|
||||
// Studio admin: browse students and their activity.
|
||||
add_menu_page(
|
||||
__( 'Students', 'unsupervised-schedular' ),
|
||||
__( 'Students', 'unsupervised-schedular' ),
|
||||
RoleManager::CAP_MANAGE_STUDENTS,
|
||||
'us-students',
|
||||
[ $this->studentController, 'renderPage' ],
|
||||
'dashicons-id',
|
||||
37
|
||||
);
|
||||
|
||||
// Instructor: view their upcoming lessons.
|
||||
add_menu_page(
|
||||
__( 'My Lessons', 'unsupervised-schedular' ),
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
use Unsupervised\Schedular\Availability\AvailabilityRepository;
|
||||
use Unsupervised\Schedular\Booking\BookingRepository;
|
||||
use Unsupervised\Schedular\Booking\Lesson;
|
||||
use Unsupervised\Schedular\GroupClass\Enrollment;
|
||||
use Unsupervised\Schedular\GroupClass\EnrollmentRepository;
|
||||
use Unsupervised\Schedular\Offering\OfferingRepository;
|
||||
|
||||
class StudentController {
|
||||
|
||||
public function __construct(
|
||||
private BookingRepository $bookings,
|
||||
private AvailabilityRepository $availability,
|
||||
private OfferingRepository $offerings,
|
||||
private EnrollmentRepository $enrollments,
|
||||
) {}
|
||||
|
||||
public function renderPage(): void {
|
||||
if ( ! current_user_can( RoleManager::CAP_MANAGE_STUDENTS ) ) {
|
||||
wp_die( esc_html__( 'You do not have permission to view students.', 'unsupervised-schedular' ) );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only student selector.
|
||||
$studentId = absint( $_GET['student_id'] ?? 0 );
|
||||
$student = $studentId > 0 ? get_userdata( $studentId ) : false;
|
||||
|
||||
if ( $student && in_array( RoleManager::STUDENT, (array) $student->roles, true ) ) {
|
||||
$this->renderDetail( $student );
|
||||
return;
|
||||
}
|
||||
|
||||
$students = array_map(
|
||||
fn( \WP_User $user ): array => [
|
||||
'id' => (int) $user->ID,
|
||||
'name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
'registered' => $user->user_registered,
|
||||
'upcoming' => $this->bookings->countUpcomingForStudent( (int) $user->ID ),
|
||||
'enrolments' => $this->enrollments->countActiveForStudent( (int) $user->ID ),
|
||||
],
|
||||
get_users(
|
||||
[
|
||||
'role' => RoleManager::STUDENT,
|
||||
'orderby' => 'display_name',
|
||||
'order' => 'ASC',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$pageSlug = 'us-students';
|
||||
include USC_PLUGIN_DIR . 'templates/admin/students.php';
|
||||
}
|
||||
|
||||
private function renderDetail( \WP_User $student ): void {
|
||||
$now = current_time( 'mysql' );
|
||||
$rows = array_map(
|
||||
fn( Lesson $lesson ): array => $this->lessonRow( $lesson ),
|
||||
$this->bookings->findByStudent( (int) $student->ID )
|
||||
);
|
||||
|
||||
$schedule = StudentSchedule::partition( $rows, $now );
|
||||
$upcoming = $schedule['upcoming'];
|
||||
$past = $schedule['past'];
|
||||
|
||||
$enrolments = array_map(
|
||||
function ( Enrollment $enrollment ): array {
|
||||
$offering = $this->offerings->findById( $enrollment->offeringId );
|
||||
|
||||
return [
|
||||
'offering' => $offering ? $offering->title : (string) $enrollment->offeringId,
|
||||
'status' => $enrollment->status,
|
||||
];
|
||||
},
|
||||
$this->enrollments->findByStudent( (int) $student->ID )
|
||||
);
|
||||
|
||||
$backUrl = admin_url( 'admin.php?page=us-students' );
|
||||
include USC_PLUGIN_DIR . 'templates/admin/student-detail.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a display row for a lesson (slot time, offering, instructor, status).
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function lessonRow( Lesson $lesson ): array {
|
||||
$slot = $this->availability->findById( $lesson->slotId );
|
||||
$offering = null !== $lesson->offeringId ? $this->offerings->findById( $lesson->offeringId ) : null;
|
||||
$instructor = get_userdata( $lesson->instructorId );
|
||||
|
||||
return [
|
||||
'start_dt' => $slot ? $slot->startDt : '',
|
||||
'end_dt' => $slot ? $slot->endDt : '',
|
||||
'offering' => $offering ? $offering->title : '—',
|
||||
'instructor' => $instructor ? $instructor->display_name : (string) $lesson->instructorId,
|
||||
'status' => $lesson->status,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Unsupervised\Schedular\Auth;
|
||||
|
||||
/**
|
||||
* Pure helper for splitting a student's dated rows into upcoming and past.
|
||||
*/
|
||||
class StudentSchedule {
|
||||
|
||||
/**
|
||||
* Partition rows (each with a `start_dt` string in `Y-m-d H:i:s`) relative to
|
||||
* `$now`. Upcoming rows are sorted ascending, past rows descending. Rows
|
||||
* without a usable `start_dt` fall into `past`.
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
* @return array{upcoming: list<array<string, mixed>>, past: list<array<string, mixed>>}
|
||||
*/
|
||||
public static function partition( array $rows, string $now ): array {
|
||||
$upcoming = [];
|
||||
$past = [];
|
||||
|
||||
foreach ( $rows as $row ) {
|
||||
$start = (string) ( $row['start_dt'] ?? '' );
|
||||
if ( '' !== $start && $start >= $now ) {
|
||||
$upcoming[] = $row;
|
||||
} else {
|
||||
$past[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
usort( $upcoming, static fn( array $a, array $b ): int => strcmp( (string) ( $a['start_dt'] ?? '' ), (string) ( $b['start_dt'] ?? '' ) ) );
|
||||
usort( $past, static fn( array $a, array $b ): int => strcmp( (string) ( $b['start_dt'] ?? '' ), (string) ( $a['start_dt'] ?? '' ) ) );
|
||||
|
||||
return [
|
||||
'upcoming' => array_values( $upcoming ),
|
||||
'past' => array_values( $past ),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,26 @@ class BookingRepository {
|
||||
return array_map( Lesson::fromRow( ... ), $rows ?? [] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a student's upcoming, non-cancelled lessons (slot in the future).
|
||||
*/
|
||||
public function countUpcomingForStudent( int $studentId ): int {
|
||||
$avTable = str_replace( 'us_lessons', 'us_availability', $this->table );
|
||||
|
||||
return (int) $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table} l
|
||||
JOIN {$avTable} a ON a.id = l.slot_id
|
||||
WHERE l.student_id = %d
|
||||
AND l.status != %s
|
||||
AND a.start_dt >= %s",
|
||||
$studentId,
|
||||
Lesson::STATUS_CANCELLED,
|
||||
current_time( 'mysql' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All lessons for a student.
|
||||
*
|
||||
|
||||
@@ -49,6 +49,19 @@ class EnrollmentRepository {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a student's active group-class enrolments.
|
||||
*/
|
||||
public function countActiveForStudent( int $studentId ): int {
|
||||
return (int) $this->db->get_var(
|
||||
$this->db->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->table} WHERE student_id = %d AND status = %s",
|
||||
$studentId,
|
||||
Enrollment::STATUS_ACTIVE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a student already holds an active enrolment in an offering.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user