prepare() or $wpdb->insert/update/delete. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching /** * Course enrolments. */ class Enrollment { /** Columns the admin list may be ordered by. */ const ORDERBY_COLUMNS = array( 'enrolled_at', 'completed_at', 'status', 'id', 'user_id', 'course_id' ); public static function init() { add_action( 'delete_user', array( __CLASS__, 'cleanup_user_data' ) ); add_action( 'before_delete_post', array( __CLASS__, 'cleanup_course_data' ) ); add_action( 'before_delete_post', array( __CLASS__, 'cleanup_lesson_data' ) ); add_action( 'before_delete_post', array( __CLASS__, 'cleanup_quiz_data' ) ); // New or moved lessons reopen completed enrolments (after Lesson_Meta/Unit_Meta save the parent). add_action( 'save_post_slms_lesson', array( __CLASS__, 'on_structure_save' ), 30, 2 ); add_action( 'save_post_slms_unit', array( __CLASS__, 'on_structure_save' ), 30, 2 ); // Catch-all for changes made without a save (import, cloning, direct meta edits). add_action( 'template_redirect', array( __CLASS__, 'reopen_on_view' ), 20 ); } /** * Reopen a completed enrolment when its course has gained lessons, or a lesson was invalidated, * so that progress is below 100% again. * * The certificate, points and history are kept. The enrolment is completed again when * progress is back at 100% (Ajax::handle_mark_complete()). * * @param object $enrollment Enrolment row. * @return bool Whether it was reopened. */ public static function reopen_if_incomplete( $enrollment ) { if ( ! $enrollment || 'completed' !== $enrollment->status ) { return false; } if ( Progress::get_course_progress( $enrollment->user_id, $enrollment->course_id, $enrollment->id ) >= 100 ) { return false; } // A re-enrolment already holds the one active run for this course. if ( self::get_active_enrollment( $enrollment->user_id, $enrollment->course_id ) ) { return false; } global $wpdb; $reopened = (bool) $wpdb->update( $wpdb->prefix . 'slms_enrollments', array( 'status' => 'active', 'completed_at' => null, ), array( 'id' => $enrollment->id, 'status' => 'completed', ), array( '%s', '%s' ), array( '%d', '%s' ) ); if ( $reopened ) { /** * A completed enrolment went back to active because its course changed. * * @since 1.0.0 * * @param int $user_id User ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. */ do_action( 'accessnow_slms_enrollment_reopened', (int) $enrollment->user_id, (int) $enrollment->course_id, (int) $enrollment->id ); } return $reopened; } /** * Reopen every completed enrolment in a course that is no longer at 100%. * * @param int $course_id Course ID. * @return int Number reopened. */ public static function reopen_course( $course_id ) { global $wpdb; $course_id = (int) $course_id; $total = Progress::count_course_lessons( $course_id ); if ( ! $course_id || ! $total ) { return 0; } // Only the completed enrolments with fewer completed lessons than the course has. $candidates = $wpdb->get_results( $wpdb->prepare( "SELECT e.* FROM {$wpdb->prefix}slms_enrollments e WHERE e.course_id = %d AND e.status = 'completed' AND ( SELECT COUNT(*) FROM {$wpdb->prefix}slms_progress p WHERE p.enrollment_id = e.id AND p.course_id = %d AND p.status = 'complete' ) < %d", $course_id, $course_id, $total ) ); $reopened = 0; foreach ( $candidates as $enrollment ) { if ( self::reopen_if_incomplete( $enrollment ) ) { ++$reopened; } } return $reopened; } /** * A published lesson or unit was saved: its course may have gained lessons. * * @param int $post_id Post ID. * @param WP_Post $post Post. */ public static function on_structure_save( $post_id, $post ) { if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) || 'publish' !== $post->post_status ) { return; } $course_id = 'slms_unit' === $post->post_type ? get_post_meta( $post_id, '_slms_parent_course', true ) : Access_Control::get_course_id_from_content( $post_id, 'slms_lesson' ); if ( $course_id ) { self::reopen_course( $course_id ); } } /** * When a completed student opens the course, a lesson or a quiz, reopen their enrolment * if the course has changed since they finished it. */ public static function reopen_on_view() { if ( is_admin() || ! is_user_logged_in() || ! is_singular( array( 'slms_course', 'slms_lesson', 'slms_quiz' ) ) ) { return; } $course_id = (int) Access_Control::get_course_id_from_content( get_the_ID() ); if ( $course_id ) { self::reopen_if_incomplete( self::get_enrollment( get_current_user_id(), $course_id ) ); } } public static function enroll( $user_id, $course_id, $enrolled_by = 0 ) { global $wpdb; // Atomic check-and-insert: only insert if no active enrollment exists $now = current_time( 'mysql', true ); $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}slms_enrollments (user_id, course_id, enrolled_by, status, enrolled_at) SELECT %d, %d, %d, 'active', %s FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM {$wpdb->prefix}slms_enrollments WHERE user_id = %d AND course_id = %d AND status = 'active' )", $user_id, $course_id, $enrolled_by, $now, $user_id, $course_id ) ); if ( $wpdb->insert_id ) { $enrollment_id = (int) $wpdb->insert_id; /** * A student was enrolled in a course (a new enrolment row, not an existing one). * * Self-enrolment passes 0 as $enrolled_by; an administrator's enrolment passes their ID. * * @since 1.0.0 * * @param int $user_id User ID. * @param int $course_id Course ID. * @param int $enrollment_id New enrolment ID. * @param int $enrolled_by User ID of whoever enrolled them, or 0 for self-enrolment. */ do_action( 'accessnow_slms_enrolled', (int) $user_id, (int) $course_id, $enrollment_id, (int) $enrolled_by ); return $enrollment_id; } // Already enrolled — return existing enrollment ID $active = self::get_active_enrollment( $user_id, $course_id ); return $active ? $active->id : 0; } /** * Whether a student may enrol themselves in a course. * * @param int $user_id User ID. * @param int $course_id Course ID. * @return true|\WP_Error */ public static function can_self_enroll( $user_id, $course_id ) { $mode = get_post_meta( $course_id, '_slms_enrollment_mode', true ) ?: 'both'; $current = self::get_enrollment( $user_id, $course_id ); if ( $current && self::is_expired( $current ) ) { // Enrolling again would start a run with no end date; extending access is for an administrator. $allowed = new \WP_Error( 'access_ended', /* translators: %s: date. */ sprintf( __( 'Your access to this course ended on %s.', 'accessnow-lms' ), self::access_end_date( $current ) ) ); } else { $allowed = 'admin' === $mode ? new \WP_Error( 'admin_only', __( 'Self-enrollment is not available for this course.', 'accessnow-lms' ) ) : true; } /** * Whether a student may enrol themselves in a course: the hook for payments, * prerequisites and capacity limits. * * Return false, or a WP_Error whose message is shown to the student. Enrolments made by an * administrator are not filtered. * * @since 1.0.0 * * @param true|WP_Error $allowed True, or a WP_Error when the course is admin-enrolment only or * the student's access to it has ended. * @param int $user_id User ID. * @param int $course_id Course ID. */ $allowed = apply_filters( 'accessnow_slms_can_enroll', $allowed, (int) $user_id, (int) $course_id ); if ( false === $allowed ) { return new \WP_Error( 'not_allowed', __( 'You cannot enroll in this course.', 'accessnow-lms' ) ); } return is_wp_error( $allowed ) ? $allowed : true; } /** * Drop a student's active enrolment in a course. * * @param int $user_id User ID. * @param int $course_id Course ID. * @return int Rows changed. */ public static function unenroll( $user_id, $course_id ) { $active = self::get_active_enrollment( $user_id, $course_id ); return $active ? (int) self::drop( $active->id ) : 0; } /** * Drop an enrolment (active or completed). Its history is kept. * * @param int $enrollment_id Enrolment ID. * @param int $dropped_by User ID of whoever dropped it, or 0. * @return bool Whether it changed. */ public static function drop( $enrollment_id, $dropped_by = 0 ) { global $wpdb; $enrollment = self::get_enrollment_by_id( $enrollment_id ); if ( ! $enrollment || 'dropped' === $enrollment->status ) { return false; } $changed = (bool) $wpdb->update( $wpdb->prefix . 'slms_enrollments', array( 'status' => 'dropped' ), array( 'id' => $enrollment->id, 'status' => $enrollment->status, ), array( '%s' ), array( '%d', '%s' ) ); if ( $changed ) { /** * An enrolment was dropped. * * @since 1.0.0 * * @param int $user_id User ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. * @param int $dropped_by User ID of whoever dropped it, or 0. * @param string $old_status 'active' or 'completed'. */ do_action( 'accessnow_slms_unenrolled', (int) $enrollment->user_id, (int) $enrollment->course_id, (int) $enrollment->id, (int) $dropped_by, $enrollment->status ); } return $changed; } /** * Mark a student's active enrolment completed. * * Only an active enrolment changes, so this returns 1 once per completion. Most code wants * maybe_complete(), which also issues the certificate and fires accessnow_slms_course_completed. * * @param int $user_id User ID. * @param int $course_id Course ID. * @return int|false Rows changed. */ public static function complete_enrollment( $user_id, $course_id ) { global $wpdb; return $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}slms_enrollments SET status = 'completed', completed_at = %s WHERE user_id = %d AND course_id = %d AND status = 'active'", current_time( 'mysql', true ), $user_id, $course_id ) ); } /** * Complete the enrolment if every lesson is done: mark it completed, issue the certificate * (or keep the one a reopened enrolment already has) and fire accessnow_slms_course_completed. * * @param int $user_id User ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. * @return bool Whether the course was completed by this call. */ public static function maybe_complete( $user_id, $course_id, $enrollment_id ) { if ( Progress::get_course_progress( $user_id, $course_id, $enrollment_id ) < 100 || ! self::complete_enrollment( $user_id, $course_id ) ) { return false; } // A reopened enrolment keeps its certificate. $first_completion = ! Certificates::get_for_enrollment( $enrollment_id, $course_id ); $certificate = Certificates::issue( $user_id, $course_id, $enrollment_id ); /** * A student completed a course. Fires once per completion, including when an enrolment that * was reopened (because the course changed) is completed again; $first_completion is false then. * * The plugin's own completion email and course points are listeners on this action. * * @since 1.0.0 * * @param int $user_id User ID. * @param int $course_id Course ID. * @param int $enrollment_id Enrolment ID. * @param bool $first_completion False when an enrolment is completed again after reopening. * @param object|null $certificate Certificate row. */ do_action( 'accessnow_slms_course_completed', (int) $user_id, (int) $course_id, (int) $enrollment_id, $first_completion, $certificate ); return true; } public static function is_enrolled( $user_id, $course_id ) { $enrollment = self::get_enrollment( $user_id, $course_id ); return $enrollment && in_array( $enrollment->status, array( 'active', 'completed' ), true ); } /** * Whether a student may use a course's content now: enrolled (active or completed) and their access * hasn't ended. is_enrolled() still answers "enrolled at all", for history and certificates. * * @param int $user_id User ID. * @param int $course_id Course ID. * @return bool */ public static function has_access( $user_id, $course_id ) { $enrollment = self::get_enrollment( $user_id, $course_id ); $access = $enrollment && ! self::is_expired( $enrollment ); /** * Whether a student may use a course's lessons and quizzes now. * * Every content check goes through this: the lesson and quiz pages, the REST API, marking a * lesson complete, submitting a quiz and saving notes. * * @since 1.1.0 * * @param bool $access Enrolled (active or completed) and not past the access end date. * @param int $user_id User ID. * @param int $course_id Course ID. * @param object|null $enrollment The enrolment row, or null. */ return (bool) apply_filters( 'accessnow_slms_has_course_access', $access, (int) $user_id, (int) $course_id, $enrollment ); } /** * The enrolment, when the student has access to the course now; otherwise null. * * @param int $user_id User ID. * @param int $course_id Course ID. * @return object|null */ public static function get_accessible_enrollment( $user_id, $course_id ) { $enrollment = self::get_enrollment( $user_id, $course_id ); return ( $enrollment && self::has_access( $user_id, $course_id ) ) ? $enrollment : null; } /** * Whether an enrolment's access has ended (expires_at is in the past). * * @param object $enrollment Enrolment row. * @return bool */ public static function is_expired( $enrollment ) { return ! empty( $enrollment->expires_at ) && strtotime( $enrollment->expires_at . ' UTC' ) <= time(); } /** * Set or clear when an enrolment's access ends. * * @param int $enrollment_id Enrolment ID. * @param string|null $expires_at UTC 'Y-m-d H:i:s' (the moment access ends), or null for no end. * @return bool False when the enrolment doesn't exist or the date is not valid. */ public static function set_expiry( $enrollment_id, $expires_at ) { global $wpdb; $enrollment = self::get_enrollment_by_id( $enrollment_id ); if ( ! $enrollment ) { return false; } if ( null !== $expires_at ) { $parsed = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', (string) $expires_at, new \DateTimeZone( 'UTC' ) ); if ( ! $parsed || $parsed->format( 'Y-m-d H:i:s' ) !== $expires_at ) { return false; } } $old = $enrollment->expires_at ? $enrollment->expires_at : null; if ( $old === $expires_at ) { return true; } $wpdb->update( "{$wpdb->prefix}slms_enrollments", array( 'expires_at' => $expires_at ), array( 'id' => (int) $enrollment_id ), array( '%s' ), array( '%d' ) ); /** * An enrolment's access end date was set, changed or cleared. * * @since 1.1.0 * * @param int $enrollment_id Enrolment ID. * @param string|null $old Previous UTC end, or null. * @param string|null $new New UTC end, or null. */ do_action( 'accessnow_slms_enrollment_expiry_changed', (int) $enrollment_id, $old, $expires_at ); return true; } /** * The UTC moment access ends for an "Access until" date: the end of that day in the site's time zone. * * @param string $date 'Y-m-d'. * @return string|null UTC 'Y-m-d H:i:s', or null when the date is not valid. */ public static function expiry_from_date( $date ) { $day = \DateTimeImmutable::createFromFormat( '!Y-m-d', (string) $date, wp_timezone() ); if ( ! $day || $day->format( 'Y-m-d' ) !== $date ) { return null; } return $day->modify( '+1 day' )->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' ); } /** * The last day of access, as the site shows dates, or '' when access doesn't end. * * @param object $enrollment Enrolment row. * @param string $format Date format; the site's by default ('Y-m-d' for form fields). * @return string */ public static function access_end_date( $enrollment, $format = '' ) { if ( empty( $enrollment->expires_at ) ) { return ''; } return wp_date( $format ? $format : get_option( 'date_format' ), strtotime( $enrollment->expires_at . ' UTC' ) - 1 ); } public static function get_enrollment( $user_id, $course_id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_enrollments WHERE user_id = %d AND course_id = %d AND status IN ('active','completed') ORDER BY FIELD(status,'active','completed') LIMIT 1", $user_id, $course_id ) ); } public static function get_active_enrollment( $user_id, $course_id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_enrollments WHERE user_id = %d AND course_id = %d AND status = 'active' LIMIT 1", $user_id, $course_id ) ); } public static function get_enrollment_by_id( $enrollment_id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_enrollments WHERE id = %d", $enrollment_id ) ); } /** * A user's enrolments, newest first. * * @param int $user_id User ID. * @param string|array $status One status or a list of statuses. * @return array */ public static function get_user_enrollments( $user_id, $status = 'active' ) { global $wpdb; $statuses = array_values( array_map( 'strval', (array) $status ) ); $placeholders = implode( ',', array_fill( 0, count( $statuses ), '%s' ) ); return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $placeholders is a list of %s placeholders. "SELECT * FROM {$wpdb->prefix}slms_enrollments WHERE user_id = %d AND status IN ({$placeholders}) ORDER BY enrolled_at DESC", array_merge( array( $user_id ), $statuses ) ) ); } public static function get_course_enrollments( $course_id, $status = '' ) { global $wpdb; if ( $status ) { return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_enrollments WHERE course_id = %d AND status = %s ORDER BY enrolled_at DESC", $course_id, $status ) ); } return $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}slms_enrollments WHERE course_id = %d ORDER BY enrolled_at DESC", $course_id ) ); } public static function get_all_enrollments( $args = array() ) { global $wpdb; $defaults = array( 'status' => '', 'course_id' => 0, 'per_page' => 20, 'offset' => 0, 'orderby' => 'enrolled_at', 'order' => 'DESC', ); $args = wp_parse_args( $args, $defaults ); // ORDER BY can't be a placeholder: only whitelisted columns and directions are used. $orderby = in_array( $args['orderby'], self::ORDERBY_COLUMNS, true ) ? $args['orderby'] : 'enrolled_at'; $order = 'ASC' === strtoupper( (string) $args['order'] ) ? 'ASC' : 'DESC'; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $orderby/$order are whitelisted above. return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orderby/$order are whitelisted above. "SELECT * FROM {$wpdb->prefix}slms_enrollments WHERE ( %s = '' OR status = %s ) AND ( 0 = %d OR course_id = %d ) ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d", (string) $args['status'], (string) $args['status'], (int) $args['course_id'], (int) $args['course_id'], (int) $args['per_page'], (int) $args['offset'] ) ); } public static function count_enrollments( $args = array() ) { global $wpdb; $status = ! empty( $args['status'] ) ? (string) $args['status'] : ''; $course_id = ! empty( $args['course_id'] ) ? (int) $args['course_id'] : 0; return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}slms_enrollments WHERE ( %s = '' OR status = %s ) AND ( 0 = %d OR course_id = %d )", $status, $status, $course_id, $course_id ) ); } public static function cleanup_user_data( $user_id ) { global $wpdb; $prefix = $wpdb->prefix; $wpdb->delete( $prefix . 'slms_enrollments', array( 'user_id' => $user_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_progress', array( 'user_id' => $user_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_quiz_attempts', array( 'user_id' => $user_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_lesson_notes', array( 'user_id' => $user_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_points', array( 'user_id' => $user_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_badges', array( 'user_id' => $user_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_certificates', array( 'user_id' => $user_id ), array( '%d' ) ); } public static function cleanup_course_data( $post_id ) { if ( 'slms_course' !== get_post_type( $post_id ) ) { return; } global $wpdb; $prefix = $wpdb->prefix; $wpdb->delete( $prefix . 'slms_enrollments', array( 'course_id' => $post_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_progress', array( 'course_id' => $post_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_quiz_attempts', array( 'course_id' => $post_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_certificates', array( 'course_id' => $post_id ), array( '%d' ) ); } public static function cleanup_lesson_data( $post_id ) { if ( 'slms_lesson' !== get_post_type( $post_id ) ) { return; } global $wpdb; $prefix = $wpdb->prefix; $wpdb->delete( $prefix . 'slms_progress', array( 'lesson_id' => $post_id ), array( '%d' ) ); $wpdb->delete( $prefix . 'slms_lesson_notes', array( 'lesson_id' => $post_id ), array( '%d' ) ); } public static function cleanup_quiz_data( $post_id ) { if ( 'slms_quiz' !== get_post_type( $post_id ) ) { return; } global $wpdb; $wpdb->delete( $wpdb->prefix . 'slms_quiz_attempts', array( 'quiz_id' => $post_id ), array( '%d' ) ); } }