Add group-class enrolment deadline with instructor late-enrolment override
CI / Tests (PHP 8.1) (pull_request) Successful in 48s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Coding Standards (pull_request) Successful in 3m2s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m37s
CI / Build Plugin Zip (pull_request) Skipped

Group classes gain an instructor-set enrolment deadline (new
us_offerings.enrollment_deadline column) that defaults to the first day of
the class (term_start). Past the deadline students can no longer self-enrol:
the enrolment endpoint rejects it (403 enrollment_closed) and the front-end
class list shows "Enrolment has closed." in place of the Enrol button.

Instructors keep a manual path: the "Add students directly" control on each
class's details page now renders for public classes too (not just
invite-only) and deliberately bypasses the deadline and capacity, so a
student can be added as a late enrolment after the class has closed. Past
the deadline the details page labels these as late enrolments.

Bumps USC_VERSION to 1.1.3 for the schema change.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-24 10:19:38 -03:00
co-authored by Claude Opus 4.8
parent 991ed2f5ad
commit bf29162587
18 changed files with 338 additions and 54 deletions
+6
View File
@@ -11,6 +11,12 @@ When a `v*` tag is pushed, `.gitea/workflows/release.yml` publishes the matching
the plugin to the next patch version and adds a fresh section here for it. Record
each change under the current top section as you work.
## [1.1.3]
### Added
- Group classes now carry an **enrolment deadline** the instructor sets on the offering. It defaults to the first day of the class, and once it passes students can no longer enrol — the enrolment page shows the class as closed and the API rejects late enrolments.
- Instructors can add students to any group class by hand from its details page (**Add students directly**), which now appears for public classes too, not just invite-only ones. This bypasses the enrolment deadline and capacity, so a student can be enrolled as a **late enrolment** after the class has closed to self-enrolment.
## [1.1.2]
## [1.1.1]
+18 -1
View File
@@ -103,6 +103,21 @@
return [termLabel(o), timeLabel(o)].filter(Boolean).join(' · ');
}
// Today as a Y-m-d string in the visitor's local timezone, for lexicographic
// comparison against the class's Y-m-d enrolment deadline.
function todayYmd() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
// Enrolment closes at the end of the deadline day — the instructor's set
// deadline, or the first class day by default. Mirrors the server-side
// Offering::isEnrollmentOpen() gate.
function isEnrollmentOpen(o) {
const deadline = o.enrollment_deadline || o.term_start || '';
return !deadline || todayYmd() <= deadline;
}
function renderClasses(offerings, enrolledOfferingIds) {
let groups = offerings.filter((o) => o.kind === 'group_class');
if (singleOfferingId) {
@@ -125,7 +140,9 @@
<p>${escHtml(Number(o.price).toFixed(2))} ${escHtml(o.currency)}</p>
${enrolledOfferingIds.has(Number(o.id))
? '<p class="us-enrolled"><strong>You are enrolled in this class.</strong></p>'
: `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`}
: (isEnrollmentOpen(o)
? `<button data-offering-id="${o.id}" class="us-enrol-btn">Enrol</button>`
: '<p class="us-enrol-closed"><strong>Enrolment has closed.</strong></p>')}
</div>
`).join('');
+26 -9
View File
@@ -48,6 +48,19 @@ cancelled enrolment does not block re-enrolling).
Capacity is enforced at enrolment time by counting `active` rows for the offering;
a class at capacity rejects further enrolments.
Enrolment also closes after the class's **enrolment deadline** (the instructor's
`enrollment_deadline`, defaulting to `term_start` — the first class day; see
`offerings.md`). Past the deadline `POST /enrollments` rejects the enrolment with
`403 enrollment_closed`, and the class list shows "Enrolment has closed." in place
of the Enrol button.
The deadline only bounds student **self**-enrolment. An instructor (or studio admin)
can still enrol someone by hand from the class **details page** — the **Add students
directly** control, available for every group class, deliberately bypasses the
deadline (and capacity) so a **late enrolment** can be added after the class has
closed. Past the deadline the details page labels these as late enrolments. See
**Admin Interface** below.
## REST API
| Method | Endpoint | Permission |
|----------|----------------------------------------------|----------------------------------|
@@ -74,13 +87,15 @@ flips their grant from `invited` to `enrolled`.
Access to an invite-only class is recorded in `{prefix}us_group_access` — a grant per
person, separate from the enrolment itself. The instructor manages access from
**My Lessons → My Group Classes**, which renders three controls under each invite-only
class:
**My Lessons → My Group Classes**. **Add students directly** is available on every
class's details page (see **Admin Interface**); invite-only classes add two more
controls beneath it:
1. **Add students directly** — the selected registered students are enrolled immediately
(`status = active`) with a **pending payment** at the class price (comp students are
settled at once by `PaymentService`). No access grant is needed — this writes straight
to `us_group_enrollments` + `us_payments`.
to `us_group_enrollments` + `us_payments`. It bypasses the enrolment deadline and
capacity, so it doubles as the **late-enrolment** path after a class has closed.
2. **Make available** — the selected registered students get an `invited` grant so the
class appears in their own group-class list; they then self-enrol through the normal
paid flow. Each is emailed a "you've been added" notice.
@@ -121,12 +136,14 @@ class becomes enrollable for them — they choose whether to enrol.
against capacity, plus a **View details** link (**View & invite** for invite-only
classes). Selecting a class (`?class_id=<id>`, scoped to the owning instructor) opens its
**details page**: a class-details panel (when, instructor, enrolled/capacity, duration,
price, schedule note, description, status), the roster of enrolled students with enrolment
and payment status, and — for invite-only classes — an **Invite & enrol students** section
listing who has been invited but not yet enrolled alongside the add/make-available/
invite-by-email controls (nonce-checked `usc_action` POSTs, scoped to the owning
instructor). Managing who is in an invite-only class is therefore done entirely from this
page. The summary (`templates/admin/my-group-classes.php`) and the details page
price, schedule note, enrolment deadline, status), the roster of enrolled students with
enrolment and payment status, and an **Add students** section. Every class — public or
invite-only — carries the **Add students directly** control there, which enrols the
selected students immediately (a late enrolment past the deadline; the section says so
when the deadline has passed). Invite-only classes additionally get the
**make-available** and **invite-by-email** controls plus the list of who has been invited
but not yet enrolled. These are nonce-checked `usc_action` POSTs, scoped to the owning
instructor. The summary (`templates/admin/my-group-classes.php`) and the details page
(`templates/admin/my-group-class-detail.php`) are separate templates.
## Implementation
+12 -1
View File
@@ -21,6 +21,7 @@ An offering is anything a student can register for: a private-lesson type (30 or
| `term_start` | DATE | Group / term offerings — first day; NULL otherwise |
| `term_end` | DATE | Group / term offerings — last day; NULL otherwise |
| `class_time` | TIME | Group only — time of day each session starts; NULL otherwise |
| `enrollment_deadline` | DATE | Group only — last day students may enrol; NULL defaults to `term_start` (the first class day) |
| `schedule_note` | VARCHAR(191) | Group only — human-readable schedule, e.g. "Tuesdays 4:00pm"|
| `cancellation_cutoff_hours` | SMALLINT UNSIGNED | Optional per-offering cancellation cutoff in hours; NULL inherits the studio default (see `cancellation-cutoff.md`) |
| `access_mode` | VARCHAR(20) | `public` (listed in the catalog) or `invite_only` (group classes hidden from the catalog — see `group-classes.md`) |
@@ -51,6 +52,16 @@ one-off), and returns an empty list unless date, time, and a positive duration a
all set. These windows drive availability reconciliation (see **Instructor
assignment** below and `group-classes.md`).
## Enrolment deadline
A group class carries an optional `enrollment_deadline` the instructor sets on the
offering form (blank leaves it NULL). `Offering::effectiveEnrollmentDeadline()`
resolves it to the stored date, or to `term_start` (the first class day) when unset,
so a class with no explicit deadline still closes to new enrolments once the first
class arrives. `Offering::isEnrollmentOpen($today)` compares a `Y-m-d` "today"
against that effective deadline (inclusive — the deadline day is still open). The
enrolment endpoint enforces it (`403 enrollment_closed`) and the front-end
group-class list mirrors the same rule; see `group-classes.md`.
## Instructor assignment
Every offering has an owning `instructor_id`. A studio admin
(`manage_instructors`) sees an **Instructor** picker on the offering form and may
@@ -84,7 +95,7 @@ Studio admin and instructors manage offerings under **Offerings** in wp-admin.
## Implementation
- Repository: `Unsupervised\Schedular\Offering\OfferingRepository`
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`)
- Model: `Unsupervised\Schedular\Offering\Offering` (`normalizeTime`, `sessionWindows`, `effectiveEnrollmentDeadline`, `isEnrollmentOpen`)
- Admin controller: `Unsupervised\Schedular\Offering\OfferingController`
- REST endpoint: `Unsupervised\Schedular\Offering\OfferingEndpoint` (public listing includes `instructor_name`)
- Availability reconciliation: `Unsupervised\Schedular\Offering\ClassSlotReconciler` (uses `Availability\AvailabilityRepository::findOverlapping`)
+6
View File
@@ -95,6 +95,12 @@ class EnrollmentEndpoint {
return new \WP_Error( 'invite_required', __( 'This class is by invitation only.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
// Enrolment closes at the end of the deadline day — the instructor's set
// deadline, or the first class day by default.
if ( ! $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ) ) {
return new \WP_Error( 'enrollment_closed', __( 'Enrolment for this class has closed.', 'unsupervised-schedular' ), [ 'status' => 403 ] );
}
if ( null !== $offering->capacity && $this->enrollments->countActiveForOffering( $offeringId ) >= $offering->capacity ) {
return new \WP_Error( 'class_full', __( 'This class is full.', 'unsupervised-schedular' ), [ 'status' => 409 ] );
}
+18 -10
View File
@@ -176,7 +176,7 @@ class GroupClassController {
* invite-only classes — the list of people invited but not yet enrolled.
*
* @param list<Enrollment> $enrollments
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
* @return array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>}
*/
private function classDetail( Offering $offering, array $enrollments ): array {
$roster = [];
@@ -195,16 +195,20 @@ class GroupClassController {
];
}
$deadline = $offering->effectiveEnrollmentDeadline();
return $this->classSummary( $offering, $enrollments ) + [
'instructor' => $this->instructorName( $offering ),
'price' => $offering->price,
'currency' => $offering->currency,
'duration' => $offering->durationMinutes,
'description' => $offering->description,
'schedule_note' => $offering->scheduleNote,
'active' => $offering->isActive,
'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
'instructor' => $this->instructorName( $offering ),
'price' => $offering->price,
'currency' => $offering->currency,
'duration' => $offering->durationMinutes,
'description' => $offering->description,
'schedule_note' => $offering->scheduleNote,
'deadline' => null !== $deadline ? (string) mysql2date( 'M j, Y', $deadline ) : '',
'enrollment_open' => $offering->isEnrollmentOpen( Val::string( current_time( 'Y-m-d' ) ) ),
'active' => $offering->isActive,
'roster' => $roster,
'invited' => $offering->isInviteOnly() ? $this->pendingInvites( (int) $offering->id ) : [],
];
}
@@ -312,6 +316,10 @@ class GroupClassController {
* Directly enrol registered students, each with a pending payment at the
* class price (comp students are settled immediately by the payment service).
*
* This is the instructor's manual enrolment path and deliberately bypasses the
* enrolment deadline and capacity, so a student can be added as a late
* enrolment after the class has closed to self-enrolment.
*
* @param list<int> $studentIds
*/
private function addDirect( Offering $offering, array $studentIds ): string {
+24
View File
@@ -54,6 +54,7 @@ class Offering {
public readonly ?string $termStart = null,
public readonly ?string $termEnd = null,
public readonly ?string $classTime = null,
public readonly ?string $enrollmentDeadline = null,
public readonly ?string $scheduleNote = null,
public readonly ?string $etransferEmail = null,
public readonly ?int $cancellationCutoffHours = null,
@@ -70,6 +71,27 @@ class Offering {
return self::ACCESS_INVITE_ONLY === $this->accessMode;
}
/**
* The last day on which a student may enrol in this group class. Defaults to
* the first day of the class (`term_start`) when the instructor has not set an
* explicit deadline; null only when the class has no dates at all.
*/
public function effectiveEnrollmentDeadline(): ?string {
return $this->enrollmentDeadline ?? $this->termStart;
}
/**
* Whether enrolment is still open on `$today` (a `Y-m-d` date). Enrolment stays
* open through the end of the deadline day, so the first class is still
* enrollable under the default deadline. A class with no deadline at all (no
* dates configured) is always open.
*/
public function isEnrollmentOpen( string $today ): bool {
$deadline = $this->effectiveEnrollmentDeadline();
return null === $deadline || $today <= $deadline;
}
/**
* Normalise a submitted term date to canonical `Y-m-d`, or null when it is
* not a real calendar date. Round-trips through DateTimeImmutable so
@@ -169,6 +191,7 @@ class Offering {
termStart: Val::stringOrNull( $row->term_start ),
termEnd: Val::stringOrNull( $row->term_end ),
classTime: Val::stringOrNull( $row->class_time ?? null ),
enrollmentDeadline: Val::stringOrNull( $row->enrollment_deadline ?? null ),
scheduleNote: Val::stringOrNull( $row->schedule_note ),
etransferEmail: Val::stringOrNull( $row->etransfer_email ),
cancellationCutoffHours: Val::intOrNull( $row->cancellation_cutoff_hours ),
@@ -203,6 +226,7 @@ class Offering {
'term_start' => $this->termStart,
'term_end' => $this->termEnd,
'class_time' => $this->classTime,
'enrollment_deadline' => $this->enrollmentDeadline,
'schedule_note' => $this->scheduleNote,
'cancellation_cutoff_hours' => $this->cancellationCutoffHours,
'access_mode' => $this->accessMode,
+5
View File
@@ -209,6 +209,10 @@ class OfferingController {
$classTime = Offering::normalizeTime( sanitize_text_field( Val::string( wp_unslash( $_POST['class_time'] ?? '' ) ) ) );
// A blank (or invalid) deadline means "use the default" — the first class
// day (term_start), applied by Offering::effectiveEnrollmentDeadline().
$enrollmentDeadline = Offering::normalizeDate( sanitize_text_field( Val::string( wp_unslash( $_POST['enrollment_deadline'] ?? '' ) ) ) );
return new Offering(
instructorId: $this->resolveInstructorId( $instructorId, $manageAll, $existing ),
kind: $kind,
@@ -223,6 +227,7 @@ class OfferingController {
termStart: $termStart,
termEnd: $termEnd,
classTime: $classTime,
enrollmentDeadline: $enrollmentDeadline,
scheduleNote: $this->nullableText( sanitize_text_field( Val::string( wp_unslash( $_POST['schedule_note'] ?? '' ) ) ) ),
etransferEmail: $this->nullableText( sanitize_email( Val::string( wp_unslash( $_POST['etransfer_email'] ?? '' ) ) ) ),
cancellationCutoffHours: $cutoffHours,
+2
View File
@@ -161,6 +161,7 @@ class OfferingEndpoint {
capacity: $this->nullableInt( $request->get_param( 'capacity' ) ),
termStart: $this->nullableText( $request->get_param( 'term_start' ) ),
termEnd: $this->nullableText( $request->get_param( 'term_end' ) ),
enrollmentDeadline: $this->nullableText( $request->get_param( 'enrollment_deadline' ) ),
scheduleNote: $this->nullableText( $request->get_param( 'schedule_note' ) ),
etransferEmail: $this->nullableEmail( $request->get_param( 'etransfer_email' ) ),
cancellationCutoffHours: $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ),
@@ -208,6 +209,7 @@ class OfferingEndpoint {
capacity: $request->has_param( 'capacity' ) ? $this->nullableInt( $request->get_param( 'capacity' ) ) : $existing->capacity,
termStart: $request->has_param( 'term_start' ) ? $this->nullableText( $request->get_param( 'term_start' ) ) : $existing->termStart,
termEnd: $request->has_param( 'term_end' ) ? $this->nullableText( $request->get_param( 'term_end' ) ) : $existing->termEnd,
enrollmentDeadline: $request->has_param( 'enrollment_deadline' ) ? $this->nullableText( $request->get_param( 'enrollment_deadline' ) ) : $existing->enrollmentDeadline,
scheduleNote: $request->has_param( 'schedule_note' ) ? $this->nullableText( $request->get_param( 'schedule_note' ) ) : $existing->scheduleNote,
etransferEmail: $request->has_param( 'etransfer_email' ) ? $this->nullableEmail( $request->get_param( 'etransfer_email' ) ) : $existing->etransferEmail,
cancellationCutoffHours: $request->has_param( 'cancellation_cutoff_hours' ) ? $this->nullableInt( $request->get_param( 'cancellation_cutoff_hours' ) ) : $existing->cancellationCutoffHours,
+5 -3
View File
@@ -14,12 +14,13 @@ class OfferingRepository {
/**
* Column formats aligned to {@see columns()} (instructor_id, kind, title,
* description, duration_minutes, price, currency, billing_mode, allow_weekly,
* capacity, term_start, term_end, class_time, schedule_note, etransfer_email,
* cancellation_cutoff_hours, access_mode, is_active).
* capacity, term_start, term_end, class_time, enrollment_deadline,
* schedule_note, etransfer_email, cancellation_cutoff_hours, access_mode,
* is_active).
*
* @var list<string>
*/
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
private const COLUMN_FORMATS = [ '%d', '%s', '%s', '%s', '%d', '%f', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%s', '%d' ];
public function insert( Offering $offering ): int {
$this->db->insert(
@@ -61,6 +62,7 @@ class OfferingRepository {
'term_start' => $offering->termStart,
'term_end' => $offering->termEnd,
'class_time' => $offering->classTime,
'enrollment_deadline' => $offering->enrollmentDeadline,
'schedule_note' => $offering->scheduleNote,
'etransfer_email' => $offering->etransferEmail,
'cancellation_cutoff_hours' => $offering->cancellationCutoffHours,
+1
View File
@@ -64,6 +64,7 @@ class Schema {
term_start DATE DEFAULT NULL,
term_end DATE DEFAULT NULL,
class_time TIME DEFAULT NULL,
enrollment_deadline DATE DEFAULT NULL,
schedule_note VARCHAR(191) DEFAULT NULL,
etransfer_email VARCHAR(191) DEFAULT NULL,
cancellation_cutoff_hours SMALLINT UNSIGNED DEFAULT NULL,
+50 -28
View File
@@ -6,7 +6,7 @@ if (! defined('ABSPATH')) {
}
/**
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
* @var array{id: int|null, title: string, when: string, capacity: int|null, enrolled: int, invite_only: bool, instructor: string, price: float, currency: string, duration: int|null, description: string|null, schedule_note: string|null, deadline: string, enrollment_open: bool, active: bool, roster: list<array{student: string, status: string, payment: string|null}>, invited: list<array{who: string, kind: string}>} $class
* @var list<array{id: int, name: string}> $students
* @var string $notice
* @var string $baseUrl
@@ -78,6 +78,12 @@ if (! defined('ABSPATH')) {
<th scope="row"><?php esc_html_e('Price', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html(number_format($class['price'], 2) . ' ' . $class['currency']); ?></td>
</tr>
<?php if ('' !== $class['deadline']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></th>
<td><?php echo esc_html($class['deadline']); ?></td>
</tr>
<?php endif; ?>
<?php if (null !== $class['schedule_note'] && '' !== $class['schedule_note']) : ?>
<tr>
<th scope="row"><?php esc_html_e('Schedule note', 'unsupervised-schedular'); ?></th>
@@ -130,35 +136,51 @@ if (! defined('ABSPATH')) {
</table>
<?php endif; ?>
<?php if ($class['invite_only']) : ?>
<h2><?php esc_html_e('Invite &amp; enrol students', 'unsupervised-schedular'); ?></h2>
<h2>
<?php
echo $class['invite_only']
? esc_html__('Invite &amp; enrol students', 'unsupervised-schedular')
: esc_html__('Add students', 'unsupervised-schedular');
?>
</h2>
<?php if (! $class['enrollment_open']) : ?>
<p class="description"><?php esc_html_e('Enrolment has closed for this class. Students you add here are enrolled as late enrolments.', 'unsupervised-schedular'); ?></p>
<?php elseif ($class['invite_only']) : ?>
<p class="description"><?php esc_html_e('This class is invite only, so students join only when you add or invite them here.', 'unsupervised-schedular'); ?></p>
<?php endif; ?>
<?php if (! empty($class['invited'])) : ?>
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
<ul class="ul-disc">
<?php foreach ($class['invited'] as $invitee) : ?>
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
<?php if ($class['invite_only'] && ! empty($class['invited'])) : ?>
<h3><?php esc_html_e('Invited (not yet enrolled)', 'unsupervised-schedular'); ?></h3>
<ul class="ul-disc">
<?php foreach ($class['invited'] as $invitee) : ?>
<li><?php echo esc_html($invitee['who'] . ' — ' . $invitee['kind']); ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
<p class="description">
<?php
echo $class['enrollment_open']
? esc_html__('Enrols them now with a pending payment.', 'unsupervised-schedular')
: esc_html__('Enrols them now with a pending payment, past the enrolment deadline.', 'unsupervised-schedular');
?>
</p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<div class="us-group-invite-controls" style="display:flex; flex-wrap:wrap; gap:2em; margin:1em 0 2em;">
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
<h4><?php esc_html_e('Add students directly', 'unsupervised-schedular'); ?></h4>
<p class="description"><?php esc_html_e('Enrols them now with a pending payment.', 'unsupervised-schedular'); ?></p>
<select name="student_ids[]" multiple size="5" style="min-width:16em;">
<?php foreach ($students as $student) : ?>
<option value="<?php echo esc_attr((string) $student['id']); ?>"><?php echo esc_html($student['name']); ?></option>
<?php endforeach; ?>
</select>
<p>
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
</p>
</form>
</select>
<p>
<button type="submit" name="usc_action" value="add_direct" class="button"><?php esc_html_e('Add to class', 'unsupervised-schedular'); ?></button>
</p>
</form>
<?php if ($class['invite_only']) : ?>
<form method="post">
<?php wp_nonce_field('usc_group_action'); ?>
<input type="hidden" name="offering_id" value="<?php echo esc_attr((string) $class['id']); ?>">
@@ -184,6 +206,6 @@ if (! defined('ABSPATH')) {
<button type="submit" name="usc_action" value="invite_email" class="button"><?php esc_html_e('Send invite', 'unsupervised-schedular'); ?></button>
</p>
</form>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
+7
View File
@@ -112,6 +112,13 @@ if ($editing && null !== $editing->termStart && null !== $editing->termEnd && $e
<span class="description"><?php esc_html_e('Group classes only — the time each session starts. Combined with the duration to block the instructors availability.', 'unsupervised-schedular'); ?></span>
</td>
</tr>
<tr>
<th><label for="enrollment_deadline"><?php esc_html_e('Enrolment deadline', 'unsupervised-schedular'); ?></label></th>
<td>
<input type="date" name="enrollment_deadline" id="enrollment_deadline" value="<?php echo esc_attr($editing->enrollmentDeadline ?? ''); ?>">
<p class="description"><?php esc_html_e('Group classes only — the last day students may enrol. Leave blank to default to the first day of the class.', 'unsupervised-schedular'); ?></p>
</td>
</tr>
<tr>
<th><?php esc_html_e('Sessions', 'unsupervised-schedular'); ?></th>
<td>
@@ -32,6 +32,7 @@ class EnrollmentEndpointTest extends TestCase
Functions\when('wp_unslash')->returnArg();
Functions\when('sanitize_text_field')->returnArg();
Functions\when('get_current_user_id')->justReturn(5);
Functions\when('current_time')->justReturn('2026-07-24');
$this->enrollments = Mockery::mock(EnrollmentRepository::class);
$this->offerings = Mockery::mock(OfferingRepository::class);
@@ -108,6 +109,36 @@ class EnrollmentEndpointTest extends TestCase
);
}
public function testRejectsEnrollmentAfterExplicitDeadline(): void
{
// current_time is stubbed to 2026-07-24, past the 2026-07-10 deadline.
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-01', enrollmentDeadline: '2026-07-10', id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->never();
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('enrollment_closed', $result->get_error_code());
self::assertSame(403, $result->error_data['enrollment_closed']['status']);
}
public function testRejectsEnrollmentAfterDefaultDeadlineOfFirstClassDay(): void
{
// No explicit deadline, so it defaults to term_start (the first class day),
// which is in the past relative to the stubbed 2026-07-24 "today".
$offering = new Offering(instructorId: 3, kind: Offering::KIND_GROUP_CLASS, title: 'Choir', termStart: '2026-07-20', id: 8);
$this->offerings->shouldReceive('findById')->with(8)->andReturn($offering);
$this->enrollments->shouldReceive('hasActiveEnrollment')->with(8, 5)->andReturn(false);
$this->enrollments->shouldReceive('insert')->never();
$result = $this->endpoint->enroll(new \WP_REST_Request(['offering_id' => 8]));
self::assertInstanceOf(\WP_Error::class, $result);
self::assertSame('enrollment_closed', $result->get_error_code());
}
public function testInviteOnlyClassRejectsStudentWithoutGrant(): void
{
$this->offerings->shouldReceive('findById')->with(8)->andReturn($this->inviteOnlyOffering());
@@ -65,6 +65,7 @@ class GroupClassControllerTest extends TestCase
static fn (string $format, string $date) => date($format, (int) strtotime($date))
);
Functions\when('wp_nonce_field')->justReturn('');
Functions\when('current_time')->justReturn('2026-01-01');
$_GET = [];
}
@@ -183,6 +184,49 @@ class GroupClassControllerTest extends TestCase
self::assertStringContainsString('Invite by email', $html);
}
public function testClassDetailOffersDirectAddForPublicClassWithoutInviteControls(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
$_GET = ['class_id' => '8'];
// A plain public group class — the instructor can still add students
// directly (a late enrolment), but the invite-only controls are absent.
$offering = $this->offering(8, 'Choir', 10);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('Add students directly', $html);
self::assertStringContainsString('add_direct', $html);
self::assertStringNotContainsString('Invite by email', $html);
self::assertStringNotContainsString('Make available to students', $html);
}
public function testClassDetailFlagsLateEnrolmentPastTheDeadline(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Ada Lovelace'));
// current_time is stubbed to 2026-01-01, which is past this class's deadline.
$_GET = ['class_id' => '8'];
$offering = new Offering(
instructorId: 3,
kind: Offering::KIND_GROUP_CLASS,
title: 'Choir',
termStart: '2025-09-08',
id: 8,
);
$this->offerings->shouldReceive('findAll')->once()->andReturn([$offering]);
$this->enrollments->shouldReceive('findByInstructor')->once()->with(3)->andReturn([]);
$html = $this->renderInstructor();
self::assertStringContainsString('late enrolments', $html);
self::assertStringContainsString('Add students directly', $html);
}
public function testClassDetailEnrolmentCountExcludesCancelledButRosterKeepsThem(): void
{
Functions\when('get_userdata')->justReturn($this->userNamed('Grace Hopper'));
@@ -102,6 +102,43 @@ class OfferingControllerTest extends TestCase
self::assertStringContainsString('2 open booking slots were removed', $html);
}
public function testAddGroupClassStoresEnrollmentDeadline(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Ballet Beginners',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
'enrollment_deadline' => '2026-08-31',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => '2026-08-31' === $o->enrollmentDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testBlankEnrollmentDeadlineLeavesItNullToDefaultToFirstClass(): void
{
$_POST = [
'usc_action' => 'add',
'title' => 'Choir',
'kind' => Offering::KIND_GROUP_CLASS,
'term_start' => '2026-09-08',
];
$this->repository->shouldReceive('insert')->once()->with(Mockery::on(
static fn (Offering $o) => null === $o->enrollmentDeadline
))->andReturn(1);
$this->repository->shouldReceive('findAll')->andReturn([]);
$this->reconciler->shouldReceive('reconcile')->once()->andReturn(['removed' => 0, 'conflicts' => []]);
$this->render();
}
public function testGarbageClassTimeIsRejected(): void
{
$_POST = [
+44
View File
@@ -277,4 +277,48 @@ class OfferingTest extends TestCase
self::assertContains(Offering::BILLING_ONE_TIME, Offering::VALID_BILLING_MODES);
self::assertContains(Offering::BILLING_FULL_TERM, Offering::VALID_BILLING_MODES);
}
public function testEffectiveEnrollmentDeadlineDefaultsToTermStart(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08');
self::assertSame('2026-09-08', $offering->effectiveEnrollmentDeadline());
}
public function testEffectiveEnrollmentDeadlineUsesExplicitValueWhenSet(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
self::assertSame('2026-08-31', $offering->effectiveEnrollmentDeadline());
}
public function testEffectiveEnrollmentDeadlineIsNullWithoutDates(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
self::assertNull($offering->effectiveEnrollmentDeadline());
}
public function testIsEnrollmentOpenOnAndBeforeTheDeadlineDay(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', termStart: '2026-09-08', enrollmentDeadline: '2026-08-31');
self::assertTrue($offering->isEnrollmentOpen('2026-08-30'));
self::assertTrue($offering->isEnrollmentOpen('2026-08-31'));
self::assertFalse($offering->isEnrollmentOpen('2026-09-01'));
}
public function testIsEnrollmentOpenAlwaysTrueWithoutADeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir');
self::assertTrue($offering->isEnrollmentOpen('2099-01-01'));
}
public function testToArrayIncludesEnrollmentDeadline(): void
{
$offering = new Offering(1, Offering::KIND_GROUP_CLASS, 'Choir', enrollmentDeadline: '2026-08-31', id: 10);
self::assertSame('2026-08-31', $offering->toArray()['enrollment_deadline']);
}
}
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: Unsupervised Scheduler
* Plugin URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
* Description: Instructor/student lesson scheduling for WordPress.
* Version: 1.1.2
* Version: 1.1.3
* Requires at least: 6.2
* Requires PHP: 8.1
* Author: Unsupervised
@@ -21,7 +21,7 @@ if (! defined('ABSPATH')) {
exit;
}
define('USC_VERSION', '1.1.2');
define('USC_VERSION', '1.1.3');
define('USC_PLUGIN_FILE', __FILE__);
define('USC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('USC_PLUGIN_URL', plugin_dir_url(__FILE__));