PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.2
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.2
4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 All 139 releases
learnpress / inc / MCP / Domain / QuizTools.php

QuizTools.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.2, at inc/MCP/Domain/QuizTools.php

428 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace LearnPress\MCP\Domain;
4
5 use LearnPress\MCP\Mappers\ResponseMapper;
6 use LearnPress\MCP\Support\Curriculum;
7 use LearnPress\MCP\Support\Errors;
8 use LearnPress\MCP\Support\Pagination;
9 use LearnPress\MCP\Support\Permissions;
10 use LearnPress\MCP\Support\Sanitizer;
11 use LearnPress\MCP\Support\Validator;
12 use LearnPress\Models\CourseModel;
13 use LearnPress\Models\CoursePostModel;
14 use LearnPress\Models\Question\QuestionPostModel;
15 use LearnPress\Models\QuizPostModel;
16 use Throwable;
17 use WP_Error;
18
19 defined( 'ABSPATH' ) || exit;
20
21 /**
22 * Quiz create/update/delete executors.
23 *
24 * Creates quizzes through the LearnPress section curriculum API and persists
25 * quiz settings. Delete is reversible (trash + relationship removal with
26 * recovery metadata) and preserves quiz-question relationships/posts.
27 */
28 class QuizTools {
29
30 /**
31 * Execute `learnpress/create-quiz`.
32 *
33 * @param mixed $input Ability input.
34 *
35 * @return array|WP_Error
36 */
37 public static function create_quiz( $input ) {
38 $args = is_array( $input ) ? $input : array();
39 $course_id = Validator::require_id( $args, 'course_id' );
40 if ( is_wp_error( $course_id ) ) {
41 return $course_id;
42 }
43 $section_id = Validator::require_id( $args, 'section_id' );
44 if ( is_wp_error( $section_id ) ) {
45 return $section_id;
46 }
47
48 $title = Sanitizer::text( $args['title'] ?? '' );
49 if ( '' === $title ) {
50 return Errors::invalid( __( 'title is required.', 'learnpress' ) );
51 }
52
53 $course = Validator::find_course( $course_id );
54 if ( is_wp_error( $course ) ) {
55 return $course;
56 }
57 $section = Validator::find_section( $section_id, $course_id );
58 if ( is_wp_error( $section ) ) {
59 return $section;
60 }
61
62 if ( ! Permissions::can_edit_post( $course_id ) ) {
63 return Errors::forbidden();
64 }
65
66 $status = Validator::status( $args['status'] ?? '' );
67 if ( is_wp_error( $status ) ) {
68 return $status;
69 }
70
71 try {
72 $section_item = $section->create_item_and_add(
73 array(
74 'item_type' => LP_QUIZ_CPT,
75 'item_title' => $title,
76 'item_content' => Sanitizer::html( $args['content'] ?? '' ),
77 )
78 );
79
80 $quiz_id = absint( $section_item->item_id );
81 $quiz = QuizPostModel::find( $quiz_id, true );
82 if ( ! $quiz instanceof QuizPostModel ) {
83 return Errors::internal();
84 }
85
86 // Default to draft on create (create_item_and_add publishes by default).
87 self::apply_fields( $quiz, $args, '' !== $status ? $status : 'draft' );
88
89 if ( array_key_exists( 'order', $args ) ) {
90 $course_post = CoursePostModel::find( $course_id, true );
91 if ( $course_post instanceof CoursePostModel ) {
92 Curriculum::place_item( $course_post, $course_id, $quiz_id, $section_id, $section_id, absint( $args['order'] ) );
93 }
94 }
95
96 $quiz = QuizPostModel::find( $quiz_id, true );
97
98 return array(
99 'quiz' => ResponseMapper::quiz_write_result(
100 $quiz,
101 array(
102 'course_id' => $course_id,
103 'section_id' => $section_id,
104 )
105 ),
106 );
107 } catch ( Throwable $e ) {
108 return Errors::from_throwable( $e );
109 }
110 }
111
112 /**
113 * Execute `learnpress/update-quiz`.
114 *
115 * @param mixed $input Ability input.
116 *
117 * @return array|WP_Error
118 */
119 public static function update_quiz( $input ) {
120 $args = is_array( $input ) ? $input : array();
121 $quiz_id = Validator::require_id( $args, 'quiz_id' );
122 if ( is_wp_error( $quiz_id ) ) {
123 return $quiz_id;
124 }
125
126 $quiz = Validator::find_quiz( $quiz_id );
127 if ( is_wp_error( $quiz ) ) {
128 return $quiz;
129 }
130
131 if ( ! Permissions::can_edit_post( $quiz_id ) ) {
132 return Errors::forbidden();
133 }
134
135 $status = Validator::status( $args['status'] ?? '' );
136 if ( is_wp_error( $status ) ) {
137 return $status;
138 }
139
140 $location = Curriculum::resolve_location( $quiz_id, LP_QUIZ_CPT );
141
142 try {
143 if ( array_key_exists( 'title', $args ) ) {
144 $quiz->post_title = Sanitizer::text( $args['title'] );
145 }
146 self::apply_fields( $quiz, $args, $status );
147
148 $move = self::move_within_curriculum( $quiz_id, $location, $args );
149 if ( is_wp_error( $move ) ) {
150 return $move;
151 }
152 $location = $move;
153
154 $quiz = QuizPostModel::find( $quiz_id, true );
155
156 return array( 'quiz' => ResponseMapper::quiz( $quiz, $location ) );
157 } catch ( Throwable $e ) {
158 return Errors::from_throwable( $e );
159 }
160 }
161
162 /**
163 * Execute `learnpress/delete-quiz` (reversible trash).
164 *
165 * @param mixed $input Ability input.
166 *
167 * @return array|WP_Error
168 */
169 public static function delete_quiz( $input ) {
170 $args = is_array( $input ) ? $input : array();
171 $quiz_id = Validator::require_id( $args, 'quiz_id' );
172 if ( is_wp_error( $quiz_id ) ) {
173 return $quiz_id;
174 }
175
176 $quiz = Validator::find_quiz( $quiz_id );
177 if ( is_wp_error( $quiz ) ) {
178 return $quiz;
179 }
180
181 if ( ! Permissions::can_delete_post( $quiz_id ) ) {
182 return Errors::forbidden();
183 }
184
185 $hint_course = absint( $args['course_id'] ?? 0 );
186 $location = Curriculum::resolve_location( $quiz_id, LP_QUIZ_CPT, $hint_course );
187
188 if ( $hint_course > 0 && $location['course_id'] !== $hint_course ) {
189 return Errors::not_found( __( 'Quiz does not belong to the provided course.', 'learnpress' ) );
190 }
191 if ( ! empty( $args['section_id'] ) && absint( $args['section_id'] ) !== $location['section_id'] ) {
192 return Errors::not_found( __( 'Quiz does not belong to the provided section.', 'learnpress' ) );
193 }
194
195 try {
196 $removed_from_curriculum = false;
197 if ( $location['section_id'] > 0 ) {
198 $removed_from_curriculum = Curriculum::remove_item_from_section( $location['section_id'], $quiz_id, $location['course_id'] );
199 }
200
201 $trashed = wp_trash_post( $quiz_id );
202 if ( ! $trashed ) {
203 return Errors::internal();
204 }
205
206 return array(
207 'trashed' => true,
208 'quiz_id' => $quiz_id,
209 'removed_from_curriculum' => $removed_from_curriculum,
210 'recovery' => array(
211 'course_id' => $location['course_id'],
212 'section_id' => $location['section_id'],
213 'previous_order' => $location['item_order'],
214 'item_type' => LP_QUIZ_CPT,
215 'note' => __( 'Quiz moved to trash. Question relationships and posts are preserved. Restore from WP admin and re-add to the section to recover.', 'learnpress' ),
216 ),
217 );
218 } catch ( Throwable $e ) {
219 return Errors::from_throwable( $e );
220 }
221 }
222
223 /**
224 * Execute `learnpress/list-quizzes`.
225 *
226 * @param mixed $input Ability input.
227 *
228 * @return array|WP_Error
229 */
230 public static function list_quizzes( $input ) {
231 $args = Sanitizer::input_array( $input, 'learnpress/list-quizzes' );
232 if ( is_wp_error( $args ) ) {
233 return $args;
234 }
235
236 $course_id = Validator::require_id( $args, 'course_id' );
237 if ( is_wp_error( $course_id ) ) {
238 return $course_id;
239 }
240
241 $course = CourseModel::find( $course_id, true );
242 if ( ! $course instanceof CourseModel ) {
243 return Errors::not_found( __( 'Course not found.', 'learnpress' ) );
244 }
245
246 $page = Pagination::page( $args['page'] ?? 1 );
247 $per_page = Pagination::per_page( $args['per_page'] ?? 10 );
248 $refs = Curriculum::collect_items( $course, LP_QUIZ_CPT );
249
250 $all = array();
251 foreach ( $refs as $ref ) {
252 $quiz = QuizPostModel::find( (int) $ref['item_id'], true );
253 if ( $quiz instanceof QuizPostModel ) {
254 $all[] = ResponseMapper::quiz_summary( $quiz, $ref );
255 }
256 }
257
258 $total = count( $all );
259 $items = array_slice( $all, ( $page - 1 ) * $per_page, $per_page );
260
261 return array(
262 'items' => array_values( $items ),
263 'pagination' => array(
264 'page' => $page,
265 'per_page' => $per_page,
266 'total_items' => $total,
267 'total_pages' => Pagination::total_pages( $total, $per_page ),
268 ),
269 );
270 }
271
272 /**
273 * Execute `learnpress/get-quiz-details`.
274 *
275 * Adds the `quiz.questions` payload while protecting privileged answer data
276 * (correct answers, hints, explanations). Phase 1 overview fields stay
277 * backward compatible.
278 *
279 * @param mixed $input Ability input.
280 *
281 * @return array|WP_Error
282 */
283 public static function get_quiz_details( $input ) {
284 $args = is_array( $input ) ? $input : array();
285 $quiz_id = Validator::require_id( $args, 'quiz_id' );
286 if ( is_wp_error( $quiz_id ) ) {
287 return $quiz_id;
288 }
289
290 $quiz = Validator::find_quiz( $quiz_id );
291 if ( is_wp_error( $quiz ) ) {
292 return $quiz;
293 }
294
295 try {
296 $privileged = Permissions::can_read_sensitive_quiz( $quiz_id );
297 $questions = self::map_questions( $quiz, $privileged );
298
299 return array(
300 'quiz' => array(
301 'quiz_id' => $quiz->get_id(),
302 'title' => (string) $quiz->get_the_title(),
303 'excerpt' => (string) $quiz->get_the_excerpt(),
304 'status' => (string) $quiz->post_status,
305 'permalink' => (string) $quiz->get_permalink(),
306 'duration' => (string) $quiz->get_duration(),
307 'passing_grade' => (float) $quiz->get_passing_grade(),
308 'retake_count' => (int) $quiz->get_retake_count(),
309 'questions_count' => count( $questions ),
310 'mark' => (float) $quiz->get_mark(),
311 'instant_check' => (bool) $quiz->has_instant_check(),
312 'negative_marking' => (bool) $quiz->has_negative_marking(),
313 'minus_skip_questions' => (bool) $quiz->has_minus_skip_questions(),
314 'show_correct_review' => (bool) $quiz->has_show_correct_review(),
315 'questions' => $questions,
316 ),
317 );
318 } catch ( Throwable $e ) {
319 return Errors::from_throwable( $e );
320 }
321 }
322
323 /**
324 * Map quiz questions in quiz order (privileged gates sensitive fields).
325 *
326 * @param QuizPostModel $quiz Quiz model.
327 * @param bool $privileged Whether sensitive data may be exposed.
328 *
329 * @return array
330 */
331 protected static function map_questions( QuizPostModel $quiz, bool $privileged ): array {
332 $questions = array();
333 $order = 0;
334 // Privileged editors see drafts/pending questions; students see published only.
335 $statuses = $privileged ? array( 'publish', 'pending', 'draft' ) : array( 'publish' );
336
337 foreach ( $quiz->get_question_ids( $statuses ) as $question_id ) {
338 $question = QuestionPostModel::find( (int) $question_id, true );
339 if ( ! $question instanceof QuestionPostModel ) {
340 continue;
341 }
342 ++$order;
343 $questions[] = ResponseMapper::question( $question, $privileged, $order );
344 }
345
346 return $questions;
347 }
348
349 /**
350 * Apply quiz post fields and settings metadata.
351 *
352 * @param QuizPostModel $quiz Quiz model.
353 * @param array $args Input args.
354 * @param string $status Validated status ('' when not provided).
355 *
356 * @return void
357 */
358 protected static function apply_fields( QuizPostModel $quiz, array $args, string $status ): void {
359 if ( array_key_exists( 'content', $args ) ) {
360 $quiz->post_content = Sanitizer::html( $args['content'] );
361 }
362 if ( '' !== $status ) {
363 $quiz->post_status = $status;
364 }
365
366 $quiz->save();
367
368 if ( array_key_exists( 'duration', $args ) ) {
369 $quiz->save_meta_value_by_key( QuizPostModel::META_KEY_DURATION, Sanitizer::text( $args['duration'] ) );
370 }
371 if ( array_key_exists( 'passing_grade', $args ) ) {
372 $quiz->save_meta_value_by_key( QuizPostModel::META_KEY_PASSING_GRADE, (float) $args['passing_grade'] );
373 }
374 if ( array_key_exists( 'retake_count', $args ) ) {
375 $quiz->save_meta_value_by_key( QuizPostModel::META_KEY_RETAKE_COUNT, absint( $args['retake_count'] ) );
376 }
377 if ( array_key_exists( 'instant_check', $args ) ) {
378 $quiz->save_meta_value_by_key( QuizPostModel::META_KEY_INSTANT_CHECK, Sanitizer::boolean( $args['instant_check'] ) ? 'yes' : 'no' );
379 }
380 if ( array_key_exists( 'negative_marking', $args ) ) {
381 $quiz->save_meta_value_by_key( QuizPostModel::META_KEY_NEGATIVE_MARKING, Sanitizer::boolean( $args['negative_marking'] ) ? 'yes' : 'no' );
382 }
383 if ( array_key_exists( 'show_correct_review', $args ) ) {
384 $quiz->save_meta_value_by_key( QuizPostModel::META_KEY_SHOW_CORRECT_REVIEW, Sanitizer::boolean( $args['show_correct_review'] ) ? 'yes' : 'no' );
385 }
386 }
387
388 /**
389 * Move/reorder the quiz within the curriculum when requested.
390 *
391 * @param int $quiz_id Quiz ID.
392 * @param array $location Current location.
393 * @param array $args Input args.
394 *
395 * @return array|WP_Error Updated location.
396 */
397 protected static function move_within_curriculum( int $quiz_id, array $location, array $args ) {
398 $has_section = array_key_exists( 'section_id', $args );
399 $has_order = array_key_exists( 'order', $args );
400 if ( ! $has_section && ! $has_order ) {
401 return $location;
402 }
403
404 $course_id = $location['course_id'];
405 if ( $course_id <= 0 ) {
406 return Errors::invalid( __( 'Quiz is not assigned to a course; cannot move or reorder.', 'learnpress' ) );
407 }
408
409 $old_section_id = $location['section_id'];
410 $new_section_id = $has_section ? absint( $args['section_id'] ) : $old_section_id;
411
412 if ( $has_section ) {
413 $section = Validator::find_section( $new_section_id, $course_id );
414 if ( is_wp_error( $section ) ) {
415 return $section;
416 }
417 }
418
419 $position = $has_order ? absint( $args['order'] ) : 0;
420 $course_post = CoursePostModel::find( $course_id, true );
421 if ( $course_post instanceof CoursePostModel ) {
422 Curriculum::place_item( $course_post, $course_id, $quiz_id, $new_section_id, $old_section_id, $position );
423 }
424
425 return Curriculum::resolve_location( $quiz_id, LP_QUIZ_CPT, $course_id );
426 }
427 }
428