outline( $course_id ); $unit_ids = array_values( array_unique( array_map( static fn( Item $item ): int => $item->unit_id, $outline->items ) ) ); if ( array() !== $unit_ids ) { Services::unit_repo()->get_many( $unit_ids ); Services::posts()->for_objects( ObjectType::UNIT, $unit_ids, Switcher::current() ); } return $outline; } public static function enrolment( int $user_id, int $course_id ): ?Enrolment { if ( $user_id <= 0 || $course_id <= 0 ) { return null; } return Services::enrolment_repo()->for_user( $user_id, array( $course_id ) )[ $course_id ] ?? null; } public static function is_enrolled( int $user_id, int $course_id ): bool { $enrolment = self::enrolment( $user_id, $course_id ); return null !== $enrolment && $enrolment->is_active(); } public static function snapshot( int $user_id, int $course_id ): ?ProgressSnapshot { if ( $user_id <= 0 || $course_id <= 0 ) { return null; } return Services::progress_repo()->for_user( $user_id, array( $course_id ) )[ $course_id ] ?? null; } public static function item_statuses( int $user_id, int $course_id ): array { $snapshot = self::snapshot( $user_id, $course_id ); if ( null === $snapshot ) { return array(); } if ( null === self::$statuses ) { self::$statuses = new \WeakMap(); } if ( ! isset( self::$statuses[ $snapshot ] ) ) { self::$statuses[ $snapshot ] = Services::progress_repo()->item_statuses( $user_id, $course_id, $snapshot->cycle ); } return self::$statuses[ $snapshot ]; } public static function may_have_feedback( int $user_id, int $course_id ): bool { if ( $user_id <= 0 || ! \RoosterLMS\Feedback\FeedbackRepository::any() ) { return false; } $snapshot = self::snapshot( $user_id, $course_id ); if ( null === $snapshot ) { return false; } foreach ( self::item_statuses( $user_id, $course_id ) as $status ) { if ( ProgressStatus::COMPLETED === $status ) { return true; } } foreach ( Services::progress_repo()->item_flags( $user_id, $course_id, $snapshot->cycle ) as $flags ) { if ( 0 !== ( $flags & \RoosterLMS\Progress\ProgressRepository::AWAITING_RESULTS ) ) { return true; } } return false; } public static function withheld_releases( int $user_id, array $course_ids, int $cycle = 0 ): array { $course_ids = array_values( array_unique( array_filter( array_map( 'intval', $course_ids ), static fn( int $id ): bool => $id > 0 ) ) ); if ( $user_id <= 0 || array() === $course_ids ) { return array(); } $by = $cycle > 0 ? 'item_id' : 'course_id'; $sql = 'SELECT ' . $by . ' AS k, MIN(release_at) AS release_at FROM %i WHERE user_id = %d AND course_id IN (' . implode( ',', array_fill( 0, count( $course_ids ), '%d' ) ) . ') AND status IN (%d, %d) AND release_at IS NOT NULL AND released_at IS NULL' . ( $cycle > 0 ? ' AND cycle = %d' : '' ) . ' GROUP BY ' . $by; $args = array_merge( array( Db::table( 'attempts' ), $user_id ), $course_ids, array( AttemptStatus::GRADED->value, AttemptStatus::NEEDS_REVIEW->value ) ); if ( $cycle > 0 ) { $args[] = $cycle; } $out = array(); foreach ( Db::rows( $sql, $args ) as $row ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- the query is built above from constant SQL (one of two column names) and placeholders; every value is a replacement. $out[ (int) $row['k'] ] = (string) $row['release_at']; } return $out; } public static function neighbours( int $user_id, int $item_id ): ?array { $item = $item_id > 0 ? ( Services::curriculum_repo()->items( array( $item_id ) )[ $item_id ] ?? null ) : null; if ( null === $item ) { return null; } $course_id = $item->course_id; $lang = Switcher::current(); $outline = self::outline( $course_id ); $unit_ids = array_values( array_unique( array_map( static fn( Item $entry ): int => $entry->unit_id, $outline->items ) ) ); $posts = array() === $unit_ids ? array() : Services::posts()->for_objects( ObjectType::UNIT, $unit_ids, $lang ); $listed = array_values( array_filter( $outline->items, static fn( Item $entry ): bool => ( $posts[ $entry->unit_id ] ?? 0 ) > 0 ) ); $index = null; foreach ( $listed as $i => $entry ) { if ( $entry->id === $item_id ) { $index = $i; break; } } if ( null === $index ) { return null; } $section = ''; foreach ( $outline->sections as $candidate ) { if ( $candidate->id === $item->section_id ) { $section = Services::strings()->translate_many( TextObject::SECTION, array( $candidate->id => $candidate->title ), $lang )[ $candidate->id ] ?? $candidate->title; break; } } $neighbour = static function ( ?Item $entry ) use ( $user_id, $course_id, $posts, $lang ): ?array { if ( null === $entry ) { return null; } $decision = Services::gate()->can_open_item( $user_id, $entry->id ); $title = wp_strip_all_tags( Services::posts()->title( (int) $posts[ $entry->unit_id ] ) ); $reason = ''; if ( ! $decision->allowed ) { $reason = Locks::item_reason( $decision->reason, $entry, $user_id ); $reason = '' !== $reason ? $reason : __( 'Locked', 'rooster-lms' ); } return array( 'item_id' => $entry->id, 'title' => '' !== trim( $title ) ? $title : __( '(no title)', 'rooster-lms' ), 'url' => $decision->allowed ? Urls::item_url( $course_id, $entry->id, $lang ) : '', 'open' => $decision->allowed, 'reason' => $reason, ); }; return array( 'previous' => $neighbour( $listed[ $index - 1 ] ?? null ), 'next' => $neighbour( $listed[ $index + 1 ] ?? null ), 'section' => $section, 'index' => $index + 1, 'total' => count( $listed ), ); } }