PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.7
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 4.2.1 All 138 releases
learnpress / inc / MCP / Domain / QuestionTools.php

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

424 lines 12.7 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 Exception;
6 use LearnPress\MCP\Mappers\ResponseMapper;
7 use LearnPress\MCP\Support\Errors;
8 use LearnPress\MCP\Support\Permissions;
9 use LearnPress\MCP\Support\Sanitizer;
10 use LearnPress\MCP\Support\Validator;
11 use LearnPress\Models\Question\QuestionAnswerModel;
12 use LearnPress\Models\Question\QuestionPostModel;
13 use LearnPress\Models\QuizPostModel;
14 use Throwable;
15 use WP_Error;
16
17 defined( 'ABSPATH' ) || exit;
18
19 /**
20 * Quiz question add/update/delete executors.
21 *
22 * Manages questions, answers, and quiz-question relationships through the
23 * LearnPress models. Delete is relationship-only (never removes the shared
24 * question post) and recoverable through returned metadata.
25 */
26 class QuestionTools {
27
28 /**
29 * Execute `learnpress/add-quiz-question`.
30 *
31 * @param mixed $input Ability input.
32 *
33 * @return array|WP_Error
34 */
35 public static function add_quiz_question( $input ) {
36 $args = is_array( $input ) ? $input : array();
37 $quiz_id = Validator::require_id( $args, 'quiz_id' );
38 if ( is_wp_error( $quiz_id ) ) {
39 return $quiz_id;
40 }
41
42 $title = Sanitizer::text( $args['title'] ?? '' );
43 if ( '' === $title ) {
44 return Errors::invalid( __( 'title is required.', 'learnpress' ) );
45 }
46
47 $type = Validator::question_type( $args['type'] ?? '' );
48 if ( is_wp_error( $type ) ) {
49 return $type;
50 }
51
52 $quiz = Validator::find_quiz( $quiz_id );
53 if ( is_wp_error( $quiz ) ) {
54 return $quiz;
55 }
56
57 if ( ! Permissions::can_edit_post( $quiz_id ) ) {
58 return Errors::forbidden();
59 }
60
61 $has_answers = array_key_exists( 'answers', $args ) && is_array( $args['answers'] );
62 if ( $has_answers ) {
63 $valid = self::validate_answers( $type, $args['answers'] );
64 if ( is_wp_error( $valid ) ) {
65 return $valid;
66 }
67 }
68
69 try {
70 $options = array();
71 if ( $has_answers ) {
72 foreach ( $args['answers'] as $answer ) {
73 $options[] = array(
74 'title' => Sanitizer::text( $answer['title'] ?? '' ),
75 'is_true' => Sanitizer::boolean( $answer['is_correct'] ?? false ) ? 'yes' : '',
76 );
77 }
78 }
79
80 $relation = $quiz->create_question_and_add(
81 array(
82 'question_title' => $title,
83 'question_type' => $type,
84 'question_content' => Sanitizer::html( $args['content'] ?? '' ),
85 'question_options' => $options,
86 )
87 );
88 $question_id = absint( $relation->question_id );
89
90 $question = QuestionPostModel::find( $question_id, true );
91 if ( ! $question instanceof QuestionPostModel ) {
92 return Errors::internal();
93 }
94
95 self::apply_meta( $question, $args );
96
97 if ( array_key_exists( 'order', $args ) ) {
98 self::reorder_question( $quiz, $question_id, absint( $args['order'] ) );
99 }
100
101 // Reload without cache so the answer count reflects newly created answers.
102 $question = QuestionPostModel::find( $question_id, false );
103
104 return array(
105 'question_id' => $question_id,
106 'quiz_id' => $quiz_id,
107 'type' => (string) $question->get_type(),
108 'mark' => (float) $question->get_mark(),
109 'answers_count' => count( $question->get_answer_option() ),
110 );
111 } catch ( Throwable $e ) {
112 return Errors::from_throwable( $e );
113 }
114 }
115
116 /**
117 * Execute `learnpress/update-quiz-question`.
118 *
119 * @param mixed $input Ability input.
120 *
121 * @return array|WP_Error
122 */
123 public static function update_quiz_question( $input ) {
124 $args = is_array( $input ) ? $input : array();
125 $quiz_id = Validator::require_id( $args, 'quiz_id' );
126 if ( is_wp_error( $quiz_id ) ) {
127 return $quiz_id;
128 }
129 $question_id = Validator::require_id( $args, 'question_id' );
130 if ( is_wp_error( $question_id ) ) {
131 return $question_id;
132 }
133
134 $quiz = Validator::find_quiz( $quiz_id );
135 if ( is_wp_error( $quiz ) ) {
136 return $quiz;
137 }
138 $relation = Validator::find_quiz_question( $quiz_id, $question_id );
139 if ( is_wp_error( $relation ) ) {
140 return $relation;
141 }
142 $question = Validator::find_question( $question_id );
143 if ( is_wp_error( $question ) ) {
144 return $question;
145 }
146
147 if ( ! Permissions::can_edit_post( $quiz_id ) ) {
148 return Errors::forbidden();
149 }
150
151 $type = null;
152 if ( array_key_exists( 'type', $args ) ) {
153 $type = Validator::question_type( $args['type'] );
154 if ( is_wp_error( $type ) ) {
155 return $type;
156 }
157 }
158
159 $effective_type = is_string( $type ) ? $type : (string) $question->get_type();
160 if ( array_key_exists( 'answers', $args ) && is_array( $args['answers'] ) ) {
161 $valid = self::validate_answers( $effective_type, $args['answers'] );
162 if ( is_wp_error( $valid ) ) {
163 return $valid;
164 }
165 }
166
167 try {
168 if ( array_key_exists( 'title', $args ) ) {
169 $question->post_title = Sanitizer::text( $args['title'] );
170 }
171 if ( array_key_exists( 'content', $args ) ) {
172 $question->post_content = Sanitizer::html( $args['content'] );
173 }
174 $question->save();
175
176 if ( is_string( $type ) ) {
177 $question->save_meta_value_by_key( QuestionPostModel::META_KEY_TYPE, $type );
178 }
179 self::apply_meta( $question, $args );
180
181 if ( array_key_exists( 'answers', $args ) && is_array( $args['answers'] ) ) {
182 self::sync_answers( $question, $args['answers'] );
183 }
184
185 if ( array_key_exists( 'order', $args ) ) {
186 self::reorder_question( $quiz, $question_id, absint( $args['order'] ) );
187 }
188
189 // Reload without cache so the answer set reflects the sync above.
190 $question = QuestionPostModel::find( $question_id, false );
191 $order = self::current_order( $quiz, $question_id );
192
193 return array( 'question' => ResponseMapper::question( $question, true, $order ) );
194 } catch ( Throwable $e ) {
195 return Errors::from_throwable( $e );
196 }
197 }
198
199 /**
200 * Execute `learnpress/delete-quiz-question` (relationship-only).
201 *
202 * @param mixed $input Ability input.
203 *
204 * @return array|WP_Error
205 */
206 public static function delete_quiz_question( $input ) {
207 $args = is_array( $input ) ? $input : array();
208 $quiz_id = Validator::require_id( $args, 'quiz_id' );
209 if ( is_wp_error( $quiz_id ) ) {
210 return $quiz_id;
211 }
212 $question_id = Validator::require_id( $args, 'question_id' );
213 if ( is_wp_error( $question_id ) ) {
214 return $question_id;
215 }
216
217 $quiz = Validator::find_quiz( $quiz_id );
218 if ( is_wp_error( $quiz ) ) {
219 return $quiz;
220 }
221 $relation = Validator::find_quiz_question( $quiz_id, $question_id );
222 if ( is_wp_error( $relation ) ) {
223 return $relation;
224 }
225
226 if ( ! Permissions::can_edit_post( $quiz_id ) ) {
227 return Errors::forbidden();
228 }
229
230 $previous_order = (int) $relation->question_order;
231
232 try {
233 $quiz->remove_question_from_quiz( $question_id );
234
235 return array(
236 'removed' => true,
237 'removed_from_quiz' => true,
238 'question_id' => $question_id,
239 'recovery' => array(
240 'quiz_id' => $quiz_id,
241 'question_id' => $question_id,
242 'previous_order' => $previous_order,
243 'note' => __( 'Question removed from quiz only. The question post is preserved and can be re-added.', 'learnpress' ),
244 ),
245 );
246 } catch ( Throwable $e ) {
247 return Errors::from_throwable( $e );
248 }
249 }
250
251 /**
252 * Persist optional mark/hint/explanation metadata.
253 *
254 * @param QuestionPostModel $question Question model.
255 * @param array $args Input args.
256 *
257 * @return void
258 */
259 protected static function apply_meta( QuestionPostModel $question, array $args ): void {
260 if ( array_key_exists( 'mark', $args ) ) {
261 $question->save_meta_value_by_key( QuestionPostModel::META_KEY_MARK, (float) $args['mark'] );
262 }
263 if ( array_key_exists( 'hint', $args ) ) {
264 $question->save_meta_value_by_key( QuestionPostModel::META_KEY_HINT, Sanitizer::html( $args['hint'] ) );
265 }
266 if ( array_key_exists( 'explanation', $args ) ) {
267 $question->save_meta_value_by_key( QuestionPostModel::META_KEY_EXPLANATION, Sanitizer::html( $args['explanation'] ) );
268 }
269 }
270
271 /**
272 * Validate an answer payload for the selected question type.
273 *
274 * @param string $type Question type.
275 * @param array $answers Answers payload.
276 *
277 * @return true|WP_Error
278 */
279 protected static function validate_answers( string $type, array $answers ) {
280 foreach ( $answers as $answer ) {
281 if ( ! is_array( $answer ) || '' === Sanitizer::text( $answer['title'] ?? '' ) ) {
282 return Errors::invalid( __( 'Each answer requires a non-empty title.', 'learnpress' ) );
283 }
284 }
285
286 $correct = 0;
287 foreach ( $answers as $answer ) {
288 if ( Sanitizer::boolean( $answer['is_correct'] ?? false ) ) {
289 ++$correct;
290 }
291 }
292
293 switch ( $type ) {
294 case 'single_choice':
295 if ( count( $answers ) < 2 ) {
296 return Errors::invalid( __( 'Choice questions require at least two answers.', 'learnpress' ) );
297 }
298 if ( 1 !== $correct ) {
299 return Errors::invalid( __( 'Single choice questions require exactly one correct answer.', 'learnpress' ) );
300 }
301 break;
302 case 'multi_choice':
303 if ( count( $answers ) < 2 ) {
304 return Errors::invalid( __( 'Choice questions require at least two answers.', 'learnpress' ) );
305 }
306 if ( $correct < 1 ) {
307 return Errors::invalid( __( 'Choice questions require at least one correct answer.', 'learnpress' ) );
308 }
309 break;
310 case 'true_or_false':
311 if ( 2 !== count( $answers ) ) {
312 return Errors::invalid( __( 'True/false questions require exactly two answers.', 'learnpress' ) );
313 }
314 if ( 1 !== $correct ) {
315 return Errors::invalid( __( 'True/false questions require exactly one correct answer.', 'learnpress' ) );
316 }
317 break;
318 }
319
320 return true;
321 }
322
323 /**
324 * Synchronize the question answer set to the desired payload.
325 *
326 * @param QuestionPostModel $question Question model.
327 * @param array $answers Desired answers.
328 *
329 * @return void
330 * @throws Exception When answer persistence fails.
331 */
332 protected static function sync_answers( QuestionPostModel $question, array $answers ): void {
333 $existing = array();
334 foreach ( $question->get_answer_option() as $model ) {
335 if ( $model instanceof QuestionAnswerModel ) {
336 $existing[ (int) $model->question_answer_id ] = $model;
337 }
338 }
339
340 $kept = array();
341 foreach ( array_values( $answers ) as $index => $answer ) {
342 $answer_id = absint( $answer['answer_id'] ?? 0 );
343 $title = Sanitizer::text( $answer['title'] ?? '' );
344 $is_true = Sanitizer::boolean( $answer['is_correct'] ?? false ) ? 'yes' : '';
345 $value = isset( $answer['value'] ) ? Sanitizer::text( $answer['value'] ) : '';
346
347 if ( $answer_id > 0 && isset( $existing[ $answer_id ] ) ) {
348 $model = $existing[ $answer_id ];
349 $model->title = $title;
350 $model->is_true = $is_true;
351 $model->order = $index + 1;
352 if ( '' !== $value ) {
353 $model->value = $value;
354 }
355 $model->save();
356 $kept[ $answer_id ] = true;
357 } else {
358 $model = new QuestionAnswerModel(
359 array(
360 'question_id' => $question->get_id(),
361 'title' => $title,
362 'value' => '' !== $value ? $value : $question->random_value(),
363 'is_true' => $is_true,
364 'order' => $index + 1,
365 )
366 );
367 $model->save();
368 $kept[ (int) $model->question_answer_id ] = true;
369 }
370 }
371
372 // Remove answers no longer in the payload. validate_answers guarantees the
373 // kept set still satisfies the type's minimum, so the model's
374 // minimum-answer guard never legitimately fires here; any failure
375 // (capability, DB) must surface rather than be swallowed.
376 foreach ( $existing as $answer_id => $model ) {
377 if ( isset( $kept[ $answer_id ] ) ) {
378 continue;
379 }
380 $model->delete();
381 }
382 }
383
384 /**
385 * Reorder a question to a 1-based position within the quiz.
386 *
387 * @param QuizPostModel $quiz Quiz model.
388 * @param int $question_id Question ID.
389 * @param int $position Desired 1-based position; <= 0 appends.
390 *
391 * @return void
392 */
393 protected static function reorder_question( QuizPostModel $quiz, int $question_id, int $position ): void {
394 // Editor context: reorder across all question statuses, not just published.
395 $ids = array_map( 'absint', $quiz->get_question_ids( array( 'publish', 'pending', 'draft' ) ) );
396 $ids = array_values( array_diff( $ids, array( $question_id ) ) );
397
398 if ( $position > 0 ) {
399 $index = max( 0, min( count( $ids ), $position - 1 ) );
400 array_splice( $ids, $index, 0, array( $question_id ) );
401 } else {
402 $ids[] = $question_id;
403 }
404
405 $quiz->update_question_position( array( 'question_ids' => $ids ) );
406 }
407
408 /**
409 * Resolve a question's 1-based order within a quiz.
410 *
411 * @param QuizPostModel $quiz Quiz model.
412 * @param int $question_id Question ID.
413 *
414 * @return int
415 */
416 protected static function current_order( QuizPostModel $quiz, int $question_id ): int {
417 // Editor context: include all statuses so non-published questions report a real order.
418 $ids = array_values( array_map( 'absint', $quiz->get_question_ids( array( 'publish', 'pending', 'draft' ) ) ) );
419 $pos = array_search( $question_id, $ids, true );
420
421 return false === $pos ? 0 : ( (int) $pos + 1 );
422 }
423 }
424