From 8fb5ff8270355c97b06ae9a6483f2fd6c517625c Mon Sep 17 00:00:00 2001 From: James Griffin Date: Mon, 8 Jun 2026 09:28:28 -0300 Subject: [PATCH] Add student administration view (studio-admin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 2 +- docs/features/student-administration.md | 7 +- src/AdminMenu.php | 14 +++ src/Auth/StudentController.php | 103 ++++++++++++++++++ src/Auth/StudentSchedule.php | 40 +++++++ src/Booking/BookingRepository.php | 20 ++++ src/GroupClass/EnrollmentRepository.php | 13 +++ templates/admin/student-detail.php | 84 ++++++++++++++ templates/admin/students.php | 43 ++++++++ tests/Unit/Auth/StudentScheduleTest.php | 67 ++++++++++++ tests/Unit/Booking/BookingRepositoryTest.php | 14 +++ .../GroupClass/EnrollmentRepositoryTest.php | 12 ++ 12 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 src/Auth/StudentController.php create mode 100644 src/Auth/StudentSchedule.php create mode 100644 templates/admin/student-detail.php create mode 100644 templates/admin/students.php create mode 100644 tests/Unit/Auth/StudentScheduleTest.php diff --git a/README.md b/README.md index 81ce1e2..aab3f7f 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ model, REST API, classes, and tests. For contributor/architecture guidance see | Account registration (invite-only, signup policy acceptance) | [account-registration.md](docs/features/account-registration.md) | ✅ Implemented | | Lesson booking (offering → questions → policies) | [lesson-booking.md](docs/features/lesson-booking.md) | ✅ Implemented | | Group classes (capacity-enforced enrolment) | [group-classes.md](docs/features/group-classes.md) | ✅ Implemented | -| Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | 🟡 Planned | +| Student administration (studio-admin view) | [student-administration.md](docs/features/student-administration.md) | ✅ Implemented | | Payments (Stripe, e-transfer/comp, receipts) | [payments.md](docs/features/payments.md) | 🟡 Planned | | Payment reporting (monthly per-instructor + CSV) | [payment-reporting.md](docs/features/payment-reporting.md) | 🟡 Planned | diff --git a/docs/features/student-administration.md b/docs/features/student-administration.md index 19ad5f5..05d3a10 100644 --- a/docs/features/student-administration.md +++ b/docs/features/student-administration.md @@ -3,7 +3,7 @@ ## Overview A read-only studio-admin area to browse students and drill into one student's history and upcoming activity — lessons and group-class enrolments — without -digging through individual records. (Status: planned — issue #22.) +digging through individual records. ## Data Model No new tables. The views are composed from existing data: @@ -34,10 +34,11 @@ Read-only in this iteration; cancel/edit actions are a possible follow-up. ## Implementation - Admin controller: `Unsupervised\Schedular\Auth\StudentController` (list + detail) - Templates: `templates/admin/students.php`, `templates/admin/student-detail.php` -- Reuses `Booking\BookingRepository::findByStudent`, +- Reuses `Booking\BookingRepository::findByStudent` + `countUpcomingForStudent`, `Availability\AvailabilityRepository::findById`, `Offering\OfferingRepository::findById`, - `GroupClass\EnrollmentRepository::findByStudent` + `GroupClass\EnrollmentRepository::findByStudent` + `countActiveForStudent` +- Upcoming/past split: `Auth\StudentSchedule::partition()` (pure, unit-tested) - The upcoming/past split is extracted into a small pure helper so it is unit-testable (the controller itself follows the repo convention of not being unit-tested). diff --git a/src/AdminMenu.php b/src/AdminMenu.php index 5b011fd..8f96c5b 100644 --- a/src/AdminMenu.php +++ b/src/AdminMenu.php @@ -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' ), diff --git a/src/Auth/StudentController.php b/src/Auth/StudentController.php new file mode 100644 index 0000000..d484904 --- /dev/null +++ b/src/Auth/StudentController.php @@ -0,0 +1,103 @@ + 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 + */ + 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, + ]; + } +} diff --git a/src/Auth/StudentSchedule.php b/src/Auth/StudentSchedule.php new file mode 100644 index 0000000..280bf60 --- /dev/null +++ b/src/Auth/StudentSchedule.php @@ -0,0 +1,40 @@ +> $rows + * @return array{upcoming: list>, past: list>} + */ + 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 ), + ]; + } +} diff --git a/src/Booking/BookingRepository.php b/src/Booking/BookingRepository.php index 2ccb713..c3718a1 100644 --- a/src/Booking/BookingRepository.php +++ b/src/Booking/BookingRepository.php @@ -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. * diff --git a/src/GroupClass/EnrollmentRepository.php b/src/GroupClass/EnrollmentRepository.php index b74fa40..bde6c47 100644 --- a/src/GroupClass/EnrollmentRepository.php +++ b/src/GroupClass/EnrollmentRepository.php @@ -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. */ diff --git a/templates/admin/student-detail.php b/templates/admin/student-detail.php new file mode 100644 index 0000000..ddfa3dc --- /dev/null +++ b/templates/admin/student-detail.php @@ -0,0 +1,84 @@ + $upcoming + * @var list $past + * @var list $enrolments + * @var string $backUrl + */ + +$renderLessons = static function (array $rows): void { + if (empty($rows)) { + echo '

' . esc_html__('None.', 'unsupervised-schedular') . '

'; + return; + } + ?> + + + + + + + + + + + + + + + + + + + +
+ +
+

+ display_name); ?> + +

+ +

+ + + +
user_email); ?>
user_registered); ?>
+ +

+ + +

+ + +

+ +

+ + + + + + + + + + + + + + + + +
+ +
diff --git a/templates/admin/students.php b/templates/admin/students.php new file mode 100644 index 0000000..7b403dc --- /dev/null +++ b/templates/admin/students.php @@ -0,0 +1,43 @@ + $students + * @var string $pageSlug + */ +?> +
+

+ + +

+ + + + + + + + + + + + + + $pageSlug, 'student_id' => $student['id']], admin_url('admin.php')); ?> + + + + + + + + + +
+ +
diff --git a/tests/Unit/Auth/StudentScheduleTest.php b/tests/Unit/Auth/StudentScheduleTest.php new file mode 100644 index 0000000..9da12a0 --- /dev/null +++ b/tests/Unit/Auth/StudentScheduleTest.php @@ -0,0 +1,67 @@ + '2026-06-10 09:00:00', 'label' => 'future-a'], + ['start_dt' => '2026-06-01 09:00:00', 'label' => 'past-a'], + ['start_dt' => '2026-06-20 09:00:00', 'label' => 'future-b'], + ['start_dt' => '2026-05-15 09:00:00', 'label' => 'past-b'], + ]; + + $result = StudentSchedule::partition($rows, '2026-06-08 12:00:00'); + + self::assertSame(['future-a', 'future-b'], array_column($result['upcoming'], 'label')); + self::assertSame(['past-a', 'past-b'], array_column($result['past'], 'label')); + } + + public function testUpcomingSortedAscendingAndPastDescending(): void + { + $rows = [ + ['start_dt' => '2026-06-20 09:00:00'], + ['start_dt' => '2026-06-10 09:00:00'], + ['start_dt' => '2026-05-01 09:00:00'], + ['start_dt' => '2026-05-30 09:00:00'], + ]; + + $result = StudentSchedule::partition($rows, '2026-06-08 00:00:00'); + + self::assertSame( + ['2026-06-10 09:00:00', '2026-06-20 09:00:00'], + array_column($result['upcoming'], 'start_dt') + ); + self::assertSame( + ['2026-05-30 09:00:00', '2026-05-01 09:00:00'], + array_column($result['past'], 'start_dt') + ); + } + + public function testRowsWithoutStartDateFallIntoPast(): void + { + $rows = [ + ['start_dt' => '', 'label' => 'no-slot'], + ['start_dt' => '2026-06-10 09:00:00', 'label' => 'future'], + ]; + + $result = StudentSchedule::partition($rows, '2026-06-08 00:00:00'); + + self::assertSame(['future'], array_column($result['upcoming'], 'label')); + self::assertSame(['no-slot'], array_column($result['past'], 'label')); + } + + public function testEmptyInput(): void + { + $result = StudentSchedule::partition([], '2026-06-08 00:00:00'); + + self::assertSame([], $result['upcoming']); + self::assertSame([], $result['past']); + } +} diff --git a/tests/Unit/Booking/BookingRepositoryTest.php b/tests/Unit/Booking/BookingRepositoryTest.php index 006f81a..19dd4ca 100644 --- a/tests/Unit/Booking/BookingRepositoryTest.php +++ b/tests/Unit/Booking/BookingRepositoryTest.php @@ -132,6 +132,20 @@ class BookingRepositoryTest extends TestCase self::assertFalse($this->repo->updateStatus(1, Lesson::STATUS_CONFIRMED)); } + public function testCountUpcomingForStudent(): void + { + Functions\when('current_time')->justReturn('2026-06-08 12:00:00'); + + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/COUNT\(\*\).*l.student_id = %d.*a.start_dt >= %s/s'), 5, Lesson::STATUS_CANCELLED, '2026-06-08 12:00:00') + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_var')->andReturn('3'); + + self::assertSame(3, $this->repo->countUpcomingForStudent(5)); + } + public function testFindByStudentReturnsLessons(): void { $row = (object) [ diff --git a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php index b6f8440..bae31c6 100644 --- a/tests/Unit/GroupClass/EnrollmentRepositoryTest.php +++ b/tests/Unit/GroupClass/EnrollmentRepositoryTest.php @@ -56,6 +56,18 @@ class EnrollmentRepositoryTest extends TestCase self::assertSame(4, $this->repo->countActiveForOffering(7)); } + public function testCountActiveForStudent(): void + { + $this->db->shouldReceive('prepare') + ->once() + ->with(Mockery::pattern('/student_id = %d AND status = %s/'), 5, Enrollment::STATUS_ACTIVE) + ->andReturn('SELECT ...'); + + $this->db->shouldReceive('get_var')->andReturn('2'); + + self::assertSame(2, $this->repo->countActiveForStudent(5)); + } + public function testHasActiveEnrollmentTrueWhenCountPositive(): void { $this->db->shouldReceive('prepare') -- 2.52.0