PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.9.1
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.9.1
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 / Ajax / CourseBuilder / CourseBuilderAjax.php

CourseBuilderAjax.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.3.9.1, at inc/Ajax/CourseBuilder/CourseBuilderAjax.php

2,261 lines 77.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * class CourseBuilderAjax
4 *
5 * @since 4.3
6 * @version 1.0.0
7 */
8
9 namespace LearnPress\Ajax\CourseBuilder;
10
11 use DateTimeImmutable;
12 use DateTimeZone;
13 use Exception;
14 use LearnPress\Ajax\AbstractAjax;
15 use LearnPress\CourseBuilder\CourseBuilder;
16 use LearnPress\CourseBuilder\CourseBuilderAccessPolicy;
17 use LearnPress\Helpers\Template;
18 use LearnPress\Models\CourseModel;
19 use LearnPress\Models\CoursePostModel;
20 use LearnPress\Models\CourseSectionItemModel;
21 use LearnPress\Models\LessonPostModel;
22 use LearnPress\Models\Question\QuestionPostModel;
23 use LearnPress\Models\QuizPostModel;
24 use LearnPress\TemplateHooks\Course\AdminEditCurriculumTemplate;
25 use LearnPress\TemplateHooks\CourseBuilder\Course\BuilderEditCourseTemplate;
26 use LearnPress\TemplateHooks\CourseBuilder\Course\BuilderListCoursesTemplate;
27 use LearnPress\TemplateHooks\CourseBuilder\CourseBuilderTemplate;
28 use LearnPress\TemplateHooks\CourseBuilder\Lesson\BuilderListLessonsTemplate;
29 use LearnPress\TemplateHooks\CourseBuilder\Question\BuilderListQuestionsTemplate;
30 use LearnPress\TemplateHooks\CourseBuilder\Question\BuilderQuestionTemplate;
31 use LearnPress\TemplateHooks\CourseBuilder\Quiz\BuilderListQuizzesTemplate;
32 use LearnPress\TemplateHooks\CourseBuilder\Quiz\BuilderQuizTemplate;
33 use LP_Course_CURD;
34 use LP_Helper;
35 use LP_Lesson_CURD;
36 use LP_Question_CURD;
37 use LP_Quiz_CURD;
38 use LP_REST_Response;
39 use LP_Settings;
40 use stdClass;
41 use Throwable;
42
43 class CourseBuilderAjax extends AbstractAjax {
44 /**
45 * Check permissions and validate parameters.
46 *
47 * @throws Exception
48 *
49 * @since 4.3
50 * @version 1.0.0
51 */
52 public static function check_valid_course() {
53 $params = wp_unslash( $_REQUEST['data'] ?? '' );
54 if ( empty( $params ) ) {
55 throw new Exception( 'Error: params invalid!' );
56 }
57
58 $params = LP_Helper::json_decode( $params, true );
59 $course_id = ! empty( $params['course_id'] ) ? (int) $params['course_id'] : 0;
60 $courseModel = CourseModel::find( $course_id, true );
61 if ( empty( $courseModel ) ) {
62 $params['insert'] = true;
63 $params['course_model'] = '';
64 } else {
65 $params['insert'] = false;
66 $params['course_model'] = $courseModel;
67 }
68
69 return $params;
70 }
71
72 public static function check_valid_lesson() {
73 $params = wp_unslash( $_REQUEST['data'] ?? '' );
74 if ( empty( $params ) ) {
75 throw new Exception( 'Error: params invalid!' );
76 }
77
78 $params = LP_Helper::json_decode( $params, true );
79 $lesson_id = ! empty( $params['lesson_id'] ) ? (int) $params['lesson_id'] : 0;
80 $lesson_model = LessonPostModel::find( $lesson_id, true );
81 if ( empty( $lesson_model ) ) {
82 $params['insert'] = true;
83 $params['lesson_model'] = '';
84 } else {
85 $params['insert'] = false;
86 $params['lesson_model'] = $lesson_model;
87 }
88
89 return $params;
90 }
91
92 public static function check_valid_quiz() {
93 $params = wp_unslash( $_REQUEST['data'] ?? '' );
94 if ( empty( $params ) ) {
95 throw new Exception( 'Error: params invalid!' );
96 }
97
98 $params = LP_Helper::json_decode( $params, true );
99 $quiz_id = ! empty( $params['quiz_id'] ) ? (int) $params['quiz_id'] : 0;
100 $quiz_model = QuizPostModel::find( $quiz_id, true );
101 if ( empty( $quiz_model ) ) {
102 $params['insert'] = true;
103 } else {
104 $params['insert'] = false;
105 $params['quiz_model'] = $quiz_model;
106 }
107
108 return $params;
109 }
110
111 public static function check_valid_question() {
112 $params = wp_unslash( $_REQUEST['data'] ?? '' );
113 if ( empty( $params ) ) {
114 throw new Exception( 'Error: params invalid!' );
115 }
116
117 $params = LP_Helper::json_decode( $params, true );
118 $question_id = ! empty( $params['question_id'] ) ? (int) $params['question_id'] : 0;
119 $question_model = QuestionPostModel::find( $question_id, true );
120 if ( empty( $question_model ) ) {
121 $params['insert'] = true;
122 } else {
123 $params['insert'] = false;
124 $params['question_model'] = $question_model;
125 }
126
127 return $params;
128 }
129
130 /**
131 * Prepare desired slug when restoring a trashed post with a new permalink.
132 *
133 * WordPress stores `_wp_desired_post_slug` on trash and may restore that value
134 * when status changes from `trash` to a non-trash status.
135 *
136 * @param int $post_id
137 * @param string $target_status
138 * @param string $requested_slug
139 *
140 * @return bool
141 */
142 protected function prepare_desired_slug_for_restore( int $post_id, string $target_status, string $requested_slug ): bool {
143 if ( $post_id <= 0 || '' === $requested_slug ) {
144 return false;
145 }
146
147 if ( ! in_array( $target_status, [ 'publish', 'draft' ], true ) ) {
148 return false;
149 }
150
151 if ( 'trash' !== get_post_status( $post_id ) ) {
152 return false;
153 }
154
155 update_post_meta( $post_id, '_wp_desired_post_slug', $requested_slug );
156
157 return true;
158 }
159
160 /**
161 * Ensure restored post keeps requested slug after trash status transition.
162 *
163 * @param int $post_id
164 * @param string $requested_slug
165 *
166 * @return void
167 */
168 protected function sync_slug_after_restore( int $post_id, string $requested_slug ): void {
169 if ( $post_id <= 0 || '' === $requested_slug ) {
170 return;
171 }
172
173 $post = get_post( $post_id );
174 if ( ! $post || 'trash' === $post->post_status ) {
175 return;
176 }
177
178 if ( $post->post_name === $requested_slug ) {
179 return;
180 }
181
182 wp_update_post(
183 [
184 'ID' => $post_id,
185 'post_name' => $requested_slug,
186 ]
187 );
188 }
189
190 /**
191 * Permalink notice when lesson/quiz is not assigned to a course.
192 *
193 * @return string
194 */
195 protected function get_item_permalink_unavailable_message(): string {
196 return __( 'Permalink is only available if the item is already assigned to a course.', 'learnpress' );
197 }
198
199 /**
200 * Check whether a post status is public.
201 *
202 * @param string $status
203 *
204 * @return bool
205 */
206 protected function is_public_post_status( string $status ): bool {
207 $status_object = get_post_status_object( $status );
208
209 return (bool) ( $status_object && ! empty( $status_object->public ) );
210 }
211
212 /**
213 * Normalize target course status based on role/capability and current status.
214 * Mirrors wp-admin behavior for instructors without publish capability.
215 *
216 * @param string $requested_status
217 * @param bool $insert
218 * @param CourseModel|null $courseModel
219 *
220 * @return string
221 */
222 protected function normalize_course_status_for_save( string $requested_status, bool $insert, ?CourseModel $courseModel ): string {
223 $allowed_statuses = [ 'publish', 'future', 'draft', 'pending', 'private' ];
224 if ( ! in_array( $requested_status, $allowed_statuses, true ) ) {
225 $requested_status = 'draft';
226 }
227
228 $is_admin_user = current_user_can( ADMIN_ROLE );
229 if ( $is_admin_user ) {
230 return $requested_status;
231 }
232
233 $is_instructor_user = current_user_can( LP_TEACHER_ROLE );
234 $required_review = LP_Settings::get_option( 'required_review', 'yes' ) === 'yes';
235 $can_publish_course = current_user_can( 'publish_' . LP_COURSE_CPT . 's' );
236
237 $current_status = $insert ? 'auto-draft' : (string) ( $courseModel->post_status ?? 'draft' );
238 $is_public_state = $this->is_public_post_status( $current_status );
239
240 // Require moderation for instructors when review is enabled.
241 if ( $is_instructor_user && $required_review && ! $is_public_state && in_array( $requested_status, [ 'publish', 'private', 'future' ], true ) ) {
242 return 'pending';
243 }
244
245 // Keep wp-admin behavior: users without publish capability submit for review.
246 if ( ! $can_publish_course && ! $is_public_state && in_array( $requested_status, [ 'publish', 'private', 'future' ], true ) ) {
247 return 'pending';
248 }
249
250 return $requested_status;
251 }
252
253 /**
254 * Normalize publish date from Course Builder UI datetime value.
255 *
256 * @param string $datetime_value
257 *
258 * @return array<string, string>|null
259 */
260 protected function normalize_course_post_date_for_save( string $datetime_value ): ?array {
261 $datetime_value = trim( $datetime_value );
262 if ( '' === $datetime_value ) {
263 return null;
264 }
265
266 $timezone = wp_timezone();
267 $formats = [ 'Y-m-d\TH:i', 'Y-m-d\TH:i:s', 'Y-m-d H:i:s' ];
268 $parsed = null;
269
270 foreach ( $formats as $format ) {
271 $date_candidate = DateTimeImmutable::createFromFormat( $format, $datetime_value, $timezone );
272 if ( ! $date_candidate instanceof DateTimeImmutable ) {
273 continue;
274 }
275
276 $errors = DateTimeImmutable::getLastErrors();
277 if ( is_array( $errors ) && ( ! empty( $errors['warning_count'] ) || ! empty( $errors['error_count'] ) ) ) {
278 continue;
279 }
280
281 $parsed = $date_candidate;
282 break;
283 }
284
285 if ( ! $parsed instanceof DateTimeImmutable ) {
286 return null;
287 }
288
289 $gmt_timezone = new DateTimeZone( 'UTC' );
290
291 return [
292 'post_date' => $parsed->format( 'Y-m-d H:i:s' ),
293 'post_date_gmt' => $parsed->setTimezone( $gmt_timezone )->format( 'Y-m-d H:i:s' ),
294 ];
295 }
296
297 /**
298 * Save Course.
299 *
300 * @since 4.3
301 * @version 1.0.1
302 */
303 public function save_courses() {
304 $response = new LP_REST_Response();
305 $response->data = new stdClass();
306
307 try {
308 $data = self::check_valid_course();
309 $course_id = $data['course_id'] ?? 0;
310 $settings = $data['course_settings'] ?? false;
311 $insert = $data['insert'];
312 $course_title = isset( $data['course_title'] ) ? trim( sanitize_text_field( wp_unslash( (string) $data['course_title'] ) ) ) : '';
313 $course_status_requested = ! empty( $data['course_status'] ) ? sanitize_text_field( $data['course_status'] ) : 'publish';
314 $course_visibility = ! empty( $data['course_visibility'] ) ? sanitize_key( $data['course_visibility'] ) : '';
315 $course_password_raw = isset( $data['course_password'] ) ? wp_unslash( (string) $data['course_password'] ) : '';
316 $course_password = sanitize_text_field( $course_password_raw );
317 $course_post_date_raw = ! empty( $data['course_post_date'] ) ? sanitize_text_field( $data['course_post_date'] ) : '';
318 $course_post_date_data = $this->normalize_course_post_date_for_save( $course_post_date_raw );
319 $post_password_to_update = null;
320
321 if ( '' === $course_title ) {
322 throw new Exception( __( 'Course title is required.', 'learnpress' ) );
323 }
324
325 if ( $insert ) {
326 // Check user capability before insert
327 if ( ! current_user_can( 'edit_lp_courses' ) ) {
328 throw new Exception( __( 'You are not allowed to create courses', 'learnpress' ) );
329 }
330
331 $course_status = $this->normalize_course_status_for_save( $course_status_requested, true, null );
332
333 $categories = ! empty( $data['course_categories'] ) ? array_map( 'absint', explode( ',', $data['course_categories'] ) ) : array();
334 $tags = ! empty( $data['course_tags'] ) ? array_map( 'absint', explode( ',', $data['course_tags'] ) ) : array();
335
336 $insert_post_data = array(
337 'post_type' => LP_COURSE_CPT,
338 'post_title' => $course_title,
339 'post_content' => Template::sanitize_html_content( $data['course_description'] ?? '' ),
340 'post_status' => $course_status,
341 'tax_input' => array(
342 'course_category' => $categories,
343 'course_tag' => $tags,
344 ),
345 );
346
347 if ( ! empty( $course_post_date_data['post_date'] ) ) {
348 $insert_post_data['post_date'] = $course_post_date_data['post_date'];
349 $insert_post_data['post_date_gmt'] = $course_post_date_data['post_date_gmt'];
350 }
351
352 if ( in_array( $course_visibility, [ 'public', 'private' ], true ) ) {
353 $insert_post_data['post_password'] = '';
354 } elseif ( 'password' === $course_visibility ) {
355 if ( '' === $course_password ) {
356 throw new Exception( __( 'Password is required for password protected visibility.', 'learnpress' ) );
357 }
358 $insert_post_data['post_password'] = $course_password;
359 }
360
361 $course_id = wp_insert_post( $insert_post_data, true );
362
363 if ( is_wp_error( $course_id ) ) {
364 throw new Exception( $course_id->get_error_message() );
365 }
366
367 // Load the newly created course as CourseModel
368 $courseModel = CourseModel::find( $course_id, true );
369 if ( ! $courseModel ) {
370 throw new Exception( __( 'Failed to load course model', 'learnpress' ) );
371 }
372 } else {
373 $courseModel = $data['course_model'];
374 $course_status = $this->normalize_course_status_for_save( $course_status_requested, false, $courseModel );
375
376 $co_instructor_ids = $courseModel->get_meta_value_by_key( '_lp_co_teacher', [] );
377 if ( absint( $courseModel->post_author ) !== get_current_user_id() &&
378 ! current_user_can( 'manage_options' ) &&
379 ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
380 throw new Exception( __( 'You are not allowed to update this course', 'learnpress' ) );
381 }
382
383 $courseModel->post_status = $course_status;
384 if ( ! empty( $course_post_date_data['post_date'] ) ) {
385 $courseModel->post_date = $course_post_date_data['post_date'];
386 $courseModel->post_date_gmt = $course_post_date_data['post_date_gmt'];
387 }
388
389 if ( in_array( $course_visibility, [ 'public', 'private' ], true ) ) {
390 $post_password_to_update = '';
391 } elseif ( 'password' === $course_visibility ) {
392 if ( '' !== $course_password ) {
393 $post_password_to_update = $course_password;
394 } else {
395 $existing_post = get_post( $courseModel->ID );
396 if ( $existing_post && ! empty( $existing_post->post_password ) ) {
397 $post_password_to_update = (string) $existing_post->post_password;
398 } else {
399 throw new Exception( __( 'Password is required for password protected visibility.', 'learnpress' ) );
400 }
401 }
402 }
403
404 if ( '' !== $course_title ) {
405 $categories = ! empty( $data['course_categories'] ) ? array_map( 'absint', explode( ',', $data['course_categories'] ) ) : array();
406 $tags = ! empty( $data['course_tags'] ) ? array_map( 'absint', explode( ',', $data['course_tags'] ) ) : array();
407
408 $courseModel->post_title = $course_title;
409 $courseModel->post_content = Template::sanitize_html_content( $data['course_description'] ?? '' );
410
411 wp_set_post_terms( $courseModel->ID, $categories, 'course_category' );
412 wp_set_post_terms( $courseModel->ID, $tags, 'course_tag' );
413 }
414
415 // Update permalink/slug if provided
416 if ( ! empty( $data['course_permalink'] ) ) {
417 $new_slug = sanitize_title( $data['course_permalink'] );
418 if ( $new_slug && $new_slug !== $courseModel->post_name ) {
419 wp_update_post(
420 array(
421 'ID' => $courseModel->ID,
422 'post_name' => $new_slug,
423 )
424 );
425 $courseModel->post_name = $new_slug;
426 }
427 }
428
429 $course_id = $courseModel->ID;
430 }
431
432 if ( $settings ) {
433 $this->save_course_settings_to_model( $courseModel, $data );
434 }
435
436 if ( ! empty( $courseModel->meta_data ) ) {
437 $coursePostModel = new CoursePostModel( $courseModel );
438 foreach ( $courseModel->meta_data as $meta_key => $meta_value ) {
439 $coursePostModel->save_meta_value_by_key( $meta_key, $meta_value );
440 }
441 $coursePostModel->save();
442 }
443
444 // Handle course thumbnail - AFTER coursePostModel->save() to avoid being overwritten
445 if ( isset( $data['course_thumbnail_id'] ) ) {
446 $thumbnail_id = absint( $data['course_thumbnail_id'] );
447
448 if ( $thumbnail_id > 0 ) {
449 $result = set_post_thumbnail( $course_id, $thumbnail_id );
450 } else {
451 $result = delete_post_thumbnail( $course_id );
452 }
453 $current_thumbnail = get_post_thumbnail_id( $course_id );
454 }
455
456 if ( null !== $post_password_to_update ) {
457 wp_update_post(
458 array(
459 'ID' => $course_id,
460 'post_password' => $post_password_to_update,
461 )
462 );
463 }
464
465 // Use persisted status after save to avoid UI drift when WP normalizes status by capability.
466 $saved_course_status = get_post_status( $course_id );
467 if ( ! is_string( $saved_course_status ) || '' === $saved_course_status ) {
468 $saved_course_status = $course_status;
469 }
470
471 $response->status = 'success';
472 $response->message = $insert ? __( 'Insert course successfully!', 'learnpress' ) : __( 'Update course successfully!', 'learnpress' );
473 $response->data->status = $saved_course_status;
474 $response->data->button_title = 'publish' === $saved_course_status
475 ? __( 'Update', 'learnpress' )
476 : ( 'pending' === $saved_course_status ? __( 'Submit for Review', 'learnpress' ) : __( 'Publish', 'learnpress' ) );
477 $response->data->course_id_new = $insert ? $course_id : '';
478
479 // Return the actual saved permalink data (important if WordPress auto-generated a unique slug)
480 $saved_post = get_post( $course_id );
481 if ( $saved_post ) {
482 $visibility = 'public';
483 if ( 'private' === $saved_post->post_status ) {
484 $visibility = 'private';
485 } elseif ( ! empty( $saved_post->post_password ) ) {
486 $visibility = 'password';
487 }
488
489 $response->data->visibility = $visibility;
490 $response->data->course_slug = $saved_post->post_name;
491 $response->data->course_permalink = get_permalink( $course_id );
492 }
493
494 // Return full redirect URL for new courses
495 if ( $insert && $course_id ) {
496 $response->data->redirect_url = CourseBuilder::get_link_course_builder( "courses/{$course_id}" );
497 }
498
499 wp_send_json( $response );
500 } catch ( Throwable $th ) {
501 $response->message = $th->getMessage();
502 wp_send_json( $response );
503 }
504 }
505
506 /**
507 * Save Course Settings only (from Settings tab).
508 *
509 * @since 4.3
510 * @version 1.0.0
511 */
512 public function save_course_settings() {
513 $response = new LP_REST_Response();
514 $response->data = new stdClass();
515
516 try {
517 $data = self::check_valid_course();
518 $course_id = $data['course_id'] ?? 0;
519 $insert = $data['insert'];
520 $courseModel = $data['course_model'];
521
522 if ( $insert || empty( $courseModel ) ) {
523 throw new Exception( __( 'Course not found. Please save the course first.', 'learnpress' ) );
524 }
525
526 $co_instructor_ids = $courseModel->get_meta_value_by_key( '_lp_co_teacher', [] );
527 if ( absint( $courseModel->post_author ) !== get_current_user_id() &&
528 ! current_user_can( 'manage_options' ) &&
529 ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
530 throw new Exception( __( 'You are not allowed to update this course', 'learnpress' ) );
531 }
532
533 // Save course settings
534 $this->save_course_settings_to_model( $courseModel, $data );
535
536 // Save meta data
537 if ( ! empty( $courseModel->meta_data ) ) {
538 $coursePostModel = new CoursePostModel( $courseModel );
539 foreach ( $courseModel->meta_data as $meta_key => $meta_value ) {
540 $coursePostModel->save_meta_value_by_key( $meta_key, $meta_value );
541 }
542 $coursePostModel->save();
543 }
544
545 $response->status = 'success';
546 $response->message = __( 'Settings saved successfully!', 'learnpress' );
547
548 wp_send_json( $response );
549 } catch ( Throwable $th ) {
550 $response->status = 'error';
551 $response->message = $th->getMessage();
552 wp_send_json( $response );
553 }
554 }
555
556 /**
557 * Save all course settings to CourseModel
558 *
559 * @param CoursePostModel $courseModel
560 * @param array $data
561 *
562 * @throws Exception
563 */
564 public function save_course_settings_to_model( CoursePostModel &$courseModel, array $data ) {
565 // General settings
566 $this->save_general_settings_to_model( $courseModel, $data );
567
568 // Offline course settings
569 $this->save_offline_settings_to_model( $courseModel, $data );
570
571 // Price settings
572 $this->save_price_settings_to_model( $courseModel, $data );
573
574 // Extra info settings
575 $this->save_extra_settings_to_model( $courseModel, $data );
576
577 // Assessment settings
578 $this->save_assessment_settings_to_model( $courseModel, $data );
579
580 // Author settings
581 $this->save_author_settings_to_model( $courseModel, $data );
582 }
583
584 /**
585 * Save general settings to CourseModel
586 *
587 * @param CoursePostModel $courseModel
588 * @param array $data
589 */
590 protected function save_general_settings_to_model( CoursePostModel &$courseModel, array $data ) {
591 if ( isset( $data['_lp_duration'] ) ) {
592 $duration_value = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
593 $explode = explode( ' ', $duration_value );
594 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
595 $unit = $explode[1] ?? 'minute';
596
597 $courseModel->meta_data->{CoursePostModel::META_KEY_DURATION} = $number . ' ' . $unit;
598 }
599
600 $checkbox_fields = [
601 '_lp_block_expire_duration' => CoursePostModel::META_KEY_BLOCK_EXPIRE_DURATION,
602 '_lp_block_finished' => CoursePostModel::META_KEY_BLOCK_FINISH,
603 '_lp_allow_course_repurchase' => CoursePostModel::META_KEY_ALLOW_COURSE_REPURCHASE,
604 '_lp_has_finish' => CoursePostModel::META_KEY_HAS_FINISH,
605 '_lp_featured' => CoursePostModel::META_KEY_FEATURED,
606 ];
607
608 foreach ( $checkbox_fields as $key => $meta_key ) {
609 if ( isset( $data[ $key ] ) ) {
610 $courseModel->meta_data->{$meta_key} = $data[ $key ] === 'yes' ? 'yes' : '';
611 }
612 }
613
614 $simple_fields = [
615 '_lp_course_repurchase_option' => CoursePostModel::META_KEY_COURSE_REPURCHASE_OPTION,
616 '_lp_level' => CoursePostModel::META_KEY_LEVEL,
617 '_lp_featured_review' => CoursePostModel::META_KEY_FEATURED_REVIEW,
618 '_lp_external_link_buy_course' => CoursePostModel::META_KEY_EXTERNAL_LINK_BY_COURSE,
619 ];
620
621 foreach ( $simple_fields as $key => $meta_key ) {
622 if ( isset( $data[ $key ] ) ) {
623 $courseModel->meta_data->{$meta_key} = sanitize_text_field( $data[ $key ] );
624 }
625 }
626
627 $numeric_fields = [
628 '_lp_students' => CoursePostModel::META_KEY_STUDENTS,
629 '_lp_max_students' => CoursePostModel::META_KEY_MAX_STUDENTS,
630 '_lp_retake_count' => CoursePostModel::META_KEY_RETAKE_COUNT,
631 ];
632
633 foreach ( $numeric_fields as $key => $meta_key ) {
634 if ( isset( $data[ $key ] ) ) {
635 $value = absint( $data[ $key ] );
636 $courseModel->meta_data->{$meta_key} = $value;
637 }
638 }
639 }
640
641 /**
642 * Save offline course settings to CourseModel
643 *
644 * @param CoursePostModel $courseModel
645 * @param array $data
646 */
647 protected function save_offline_settings_to_model( CoursePostModel &$courseModel, array $data ) {
648 if ( isset( $data['_lp_offline_course'] ) ) {
649 $courseModel->meta_data->{CoursePostModel::META_KEY_OFFLINE_COURSE} = $data['_lp_offline_course'] === 'yes' ? 'yes' : '';
650 }
651
652 if ( isset( $data['_lp_offline_lesson_count'] ) ) {
653 $courseModel->meta_data->{CoursePostModel::META_KEY_OFFLINE_LESSON_COUNT} = absint( $data['_lp_offline_lesson_count'] );
654 }
655
656 if ( isset( $data['_lp_deliver_type'] ) ) {
657 $courseModel->meta_data->{CoursePostModel::META_KEY_DELIVER} = sanitize_text_field( $data['_lp_deliver_type'] );
658 }
659
660 if ( isset( $data['_lp_address'] ) ) {
661 $courseModel->meta_data->{CoursePostModel::META_KEY_ADDRESS} = sanitize_text_field( $data['_lp_address'] );
662 }
663 }
664
665 /**
666 * Save price settings to CourseModel
667 *
668 * @param CoursePostModel $courseModel
669 * @param array $data
670 */
671 protected function save_price_settings_to_model( CoursePostModel &$courseModel, array $data ) {
672 // Regular price
673 if ( isset( $data['_lp_regular_price'] ) ) {
674 $regular_price = floatval( $data['_lp_regular_price'] );
675 if ( $regular_price < 0 ) {
676 $regular_price = '';
677 }
678 $courseModel->meta_data->{CoursePostModel::META_KEY_REGULAR_PRICE} = $regular_price;
679 }
680
681 // Sale price
682 if ( isset( $data['_lp_sale_price'] ) ) {
683 $sale_price = $data['_lp_sale_price'] !== '' ? floatval( $data['_lp_sale_price'] ) : '';
684 $regular_price = $courseModel->get_regular_price();
685
686 if ( $sale_price !== '' && $sale_price > $regular_price ) {
687 $sale_price = '';
688 }
689 $courseModel->meta_data->{CoursePostModel::META_KEY_SALE_PRICE} = $sale_price;
690 }
691
692 // Sale dates
693 if ( isset( $data['_lp_sale_start'] ) ) {
694 $courseModel->meta_data->{CoursePostModel::META_KEY_SALE_START} = sanitize_text_field( $data['_lp_sale_start'] );
695 }
696
697 if ( isset( $data['_lp_sale_end'] ) ) {
698 $courseModel->meta_data->{CoursePostModel::META_KEY_SALE_END} = sanitize_text_field( $data['_lp_sale_end'] );
699 }
700
701 // Price prefix/suffix
702 if ( isset( $data['_lp_price_prefix'] ) ) {
703 $courseModel->meta_data->{CoursePostModel::META_KEY_PRICE_PREFIX} = sanitize_text_field( $data['_lp_price_prefix'] );
704 }
705
706 if ( isset( $data['_lp_price_suffix'] ) ) {
707 $courseModel->meta_data->{CoursePostModel::META_KEY_PRICE_SUFFIX} = sanitize_text_field( $data['_lp_price_suffix'] );
708 }
709
710 // No required enroll
711 if ( isset( $data['_lp_no_required_enroll'] ) ) {
712 $courseModel->meta_data->{CoursePostModel::META_KEY_NO_REQUIRED_ENROLL} = $data['_lp_no_required_enroll'] === 'yes' ? 'yes' : '';
713 }
714 }
715
716 /**
717 * Save extra info settings to CourseModel
718 *
719 * @param CoursePostModel $courseModel
720 * @param array $data
721 */
722 protected function save_extra_settings_to_model( CoursePostModel &$courseModel, array $data ) {
723 // Requirements
724 if ( isset( $data['_lp_requirements'] ) ) {
725 $requirements = ! empty( $data['_lp_requirements'] ) ? explode( ',', $data['_lp_requirements'] ) : [];
726 $requirements = array_filter(
727 $requirements,
728 function ( $item ) {
729 return ! is_null( $item ) && $item !== '';
730 }
731 );
732 $courseModel->meta_data->{CoursePostModel::META_KEY_REQUIREMENTS} = array_map( 'sanitize_text_field', array_values( $requirements ) );
733 }
734
735 if ( isset( $data['_lp_target_audiences'] ) ) {
736 $target_audiences = ! empty( $data['_lp_target_audiences'] ) ? explode( ',', $data['_lp_target_audiences'] ) : [];
737 $target_audiences = array_filter(
738 $target_audiences,
739 function ( $item ) {
740 return ! is_null( $item ) && $item !== '';
741 }
742 );
743 $courseModel->meta_data->{CoursePostModel::META_KEY_TARGET} = array_map( 'sanitize_text_field', array_values( $target_audiences ) );
744 }
745
746 if ( isset( $data['_lp_key_features'] ) ) {
747 $key_features = ! empty( $data['_lp_key_features'] ) ? explode( ',', $data['_lp_key_features'] ) : [];
748 $key_features = array_filter(
749 $key_features,
750 function ( $item ) {
751 return ! is_null( $item ) && $item !== '';
752 }
753 );
754 $courseModel->meta_data->{CoursePostModel::META_KEY_FEATURES} = array_map( 'sanitize_text_field', array_values( $key_features ) );
755 }
756
757 // FAQs
758 if ( isset( $data['_lp_faqs_question'] ) ) {
759 $questions = ! empty( $data['_lp_faqs_question'] ) ? explode( ',', $data['_lp_faqs_question'] ) : [];
760 $answers = ! empty( $data['_lp_faqs_answer'] ) ? explode( ',', $data['_lp_faqs_answer'] ) : [];
761 $faqs = [];
762
763 if ( ! empty( $questions ) ) {
764 foreach ( $questions as $index => $question ) {
765 $clean_question = trim( $question );
766 if ( ! empty( $clean_question ) ) {
767 $answer_content = $answers[ $index ] ?? '';
768 $faqs[] = [ sanitize_text_field( $clean_question ), wp_kses_post( $answer_content ) ];
769 }
770 }
771 }
772 $courseModel->meta_data->{CoursePostModel::META_KEY_FAQS} = $faqs;
773 }
774 }
775
776 /**
777 * Save assessment settings to CourseModel
778 *
779 * @param CoursePostModel $courseModel
780 * @param array $data
781 */
782 protected function save_assessment_settings_to_model( CoursePostModel &$courseModel, array $data ) {
783 // Course result evaluation type
784 if ( isset( $data['_lp_course_result'] ) ) {
785 $courseModel->meta_data->{CoursePostModel::META_KEY_EVALUATION_TYPE} = sanitize_text_field( $data['_lp_course_result'] );
786 }
787
788 // Passing condition
789 if ( isset( $data['_lp_passing_condition'] ) ) {
790 $passing_condition = floatval( $data['_lp_passing_condition'] );
791 if ( $passing_condition < 0 ) {
792 $passing_condition = 0;
793 } elseif ( $passing_condition > 100 ) {
794 $passing_condition = 100;
795 }
796 $courseModel->meta_data->{CoursePostModel::META_KEY_PASSING_CONDITION} = $passing_condition;
797 }
798 }
799
800 /**
801 * Save author settings to CourseModel
802 *
803 * @param CoursePostModel $courseModel
804 * @param array $data
805 */
806 protected function save_author_settings_to_model( CoursePostModel &$courseModel, array $data ) {
807 if ( ! isset( $data['_post_author'] ) ) {
808 return;
809 }
810
811 $new_author_id = absint( $data['_post_author'] );
812 if ( $new_author_id <= 0 || $new_author_id === (int) $courseModel->post_author ) {
813 return;
814 }
815 $courseModel->meta_data->_post_author = $new_author_id;
816 $courseModel->post_author = $new_author_id;
817 }
818
819 /**
820 * Duplicate for course.
821 *
822 */
823 public function duplicate_course() {
824 $response = new LP_REST_Response();
825 $response->data = new stdClass();
826
827 try {
828 $data = self::check_valid_course();
829 $course_id = $data['course_id'] ?? 0;
830 $courseModel = $data['course_model'];
831
832 if ( absint( $courseModel->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
833 throw new Exception( __( 'You are not allowed to duplicate this course', 'learnpress' ) );
834 }
835
836 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
837 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
838 }
839
840 $curd = new LP_Course_CURD();
841 $new_item_id = $curd->duplicate(
842 $course_id,
843 array(
844 'exclude_meta' => array(
845 'order-pending',
846 'order-processing',
847 'order-completed',
848 'order-cancelled',
849 'order-failed',
850 'count_enrolled_users',
851 '_lp_sample_data',
852 '_lp_retake_count',
853 ),
854 )
855 );
856
857 if ( is_wp_error( $new_item_id ) ) {
858 throw new Exception( $new_item_id->get_error_message() );
859 }
860
861 $courseModel = CourseModel::find( $new_item_id, true );
862 $html = BuilderListCoursesTemplate::render_course( $courseModel );
863 $response->status = 'success';
864 $response->data->html = $html;
865 $response->data->course_id_new = $new_item_id;
866 $response->data->redirect_url = CourseBuilder::get_link_course_builder( "courses/{$new_item_id}" );
867 $response->message = __( 'Course duplicated successfully', 'learnpress' );
868 wp_send_json( $response );
869 } catch ( Throwable $th ) {
870 $response->status = 'error';
871 $response->message = $th->getMessage();
872 wp_send_json( $response );
873 }
874 }
875
876 public function move_trash_course() {
877 $response = new LP_REST_Response();
878 $response->data = new stdClass();
879
880 try {
881 $data = self::check_valid_course();
882 $course_id = $data['course_id'] ?? 0;
883 $courseModel = $data['course_model'];
884 $status = $data['status'] ?? 'trash';
885
886 $co_instructor_ids = get_post_meta( $course_id, '_lp_co_teacher', false );
887 $co_instructor_ids = ! empty( $co_instructor_ids ) ? $co_instructor_ids : array();
888
889 if ( absint( $courseModel->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) && ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
890 throw new Exception( __( 'You are not allowed to delete this course', 'learnpress' ) );
891 }
892
893 if ( $status === 'delete' ) {
894 if ( ! current_user_can( 'manage_options' ) ) {
895 throw new Exception( __( 'You are not allowed to delete this course', 'learnpress' ) );
896 }
897
898 wp_delete_post( $course_id, true );
899
900 $message = __( 'Course has been deleted', 'learnpress' );
901 } elseif ( $status === 'draft' ) {
902 $update = wp_update_post(
903 array(
904 'ID' => $course_id,
905 'post_type' => LP_COURSE_CPT,
906 'post_status' => 'draft',
907 )
908 );
909
910 if ( ! $update ) {
911 throw new Exception( __( 'Course cannot be moved to draft', 'learnpress' ) );
912 }
913
914 $message = __( 'Course has been moved to draft', 'learnpress' );
915 } else {
916 // Store original slug before trashing (wp_trash_post adds __trashed suffix)
917 $original_slug = $courseModel->post_name;
918
919 $delete = wp_trash_post( $course_id );
920
921 if ( ! $delete ) {
922 throw new Exception( __( 'Course cannot be moved to trash', 'learnpress' ) );
923 }
924
925 // Restore original slug after trashing
926 if ( $original_slug ) {
927 wp_update_post(
928 array(
929 'ID' => $course_id,
930 'post_name' => $original_slug,
931 )
932 );
933 }
934
935 $message = __( 'Course moved to trash', 'learnpress' );
936 }
937
938 $response->status = 'success';
939 $response->data->button_title = __( 'Publish', 'learnpress' );
940 $response->data->status = $status;
941 $response->message = $message;
942 wp_send_json( $response );
943 } catch ( Throwable $th ) {
944 $response->status = 'error';
945 $response->message = $th->getMessage();
946 wp_send_json( $response );
947 }
948 }
949
950 public function add_course_category() {
951 $response = new LP_REST_Response();
952 $response->data = new stdClass();
953
954 try {
955 $data = self::check_valid_course();
956 $course_id = absint( $data['course_id'] ?? 0 );
957 if ( $course_id > 0 ) {
958 if ( ! CourseBuilderAccessPolicy::can_edit_course_by_id( $course_id ) ) {
959 throw new Exception( __( 'You are not allowed to update this course', 'learnpress' ) );
960 }
961 } elseif ( ! CourseBuilderAccessPolicy::can_create_item_type( LP_COURSE_CPT ) ) {
962 throw new Exception( __( 'You are not allowed to create courses', 'learnpress' ) );
963 }
964
965 if ( ! current_user_can( 'edit_lp_courses' ) ) {
966 throw new Exception( __( 'You are not allowed to create categories', 'learnpress' ) );
967 }
968
969 $name = sanitize_text_field( $data['name'] ?? '' );
970 $parent = isset( $data['parent'] ) ? (int) $data['parent'] : 0;
971
972 if ( $parent < 0 ) {
973 $parent = 0;
974 }
975
976 $term = wp_insert_term( $name, 'course_category', array( 'parent' => $parent ) );
977
978 if ( is_wp_error( $term ) ) {
979 throw new Exception( $term->get_error_message() );
980 }
981
982 $term_id = $term['term_id'];
983
984 $html = sprintf(
985 '<li id="in-course_category-%1$s" class="popular-category">
986 <label class="selectit">
987 <input value="%1$s" type="checkbox" name="tax_input[course_category][]" id="in-course_category-%1$s" checked="checked">
988 %2$s
989 </label>
990 </li>',
991 esc_attr( $term_id ),
992 esc_html( $name )
993 );
994
995 $response->status = 'success';
996 $response->data->html = $html;
997 $response->data->term_id = $term_id;
998 $response->data->parent = $parent;
999 $response->message = __( 'Insert category successfully!', 'learnpress' );
1000 wp_send_json( $response );
1001 } catch ( Throwable $th ) {
1002 $response->status = 'error';
1003 $response->message = $th->getMessage();
1004 wp_send_json( $response );
1005 }
1006 }
1007
1008 public function add_course_tag() {
1009 $response = new LP_REST_Response();
1010 $response->data = new stdClass();
1011
1012 try {
1013 $data = self::check_valid_course();
1014 $course_id = absint( $data['course_id'] ?? 0 );
1015 if ( $course_id > 0 ) {
1016 if ( ! CourseBuilderAccessPolicy::can_edit_course_by_id( $course_id ) ) {
1017 throw new Exception( __( 'You are not allowed to update this course', 'learnpress' ) );
1018 }
1019 } elseif ( ! CourseBuilderAccessPolicy::can_create_item_type( LP_COURSE_CPT ) ) {
1020 throw new Exception( __( 'You are not allowed to create courses', 'learnpress' ) );
1021 }
1022
1023 if ( ! current_user_can( 'edit_lp_courses' ) ) {
1024 throw new Exception( __( 'You are not allowed to create tags', 'learnpress' ) );
1025 }
1026
1027 $name = sanitize_text_field( wp_unslash( $data['name'] ?? '' ) );
1028 $term = wp_insert_term( $name, 'course_tag', array() );
1029
1030 if ( is_wp_error( $term ) ) {
1031 throw new Exception( $term->get_error_message() );
1032 }
1033
1034 $html = BuilderEditCourseTemplate::instance()->input_checkbox_tag_item( $term['term_id'], $name, false );
1035 $response->status = 'success';
1036 $response->data->html = $html;
1037 $response->message = __( 'Insert term successfully!', 'learnpress' );
1038 wp_send_json( $response );
1039 } catch ( Throwable $th ) {
1040 $response->status = 'error';
1041 $response->message = $th->getMessage();
1042 wp_send_json( $response );
1043 }
1044 }
1045
1046 /**
1047 * Duplicate for lesson.
1048 *
1049 */
1050 public function duplicate_lesson() {
1051 $response = new LP_REST_Response();
1052 $response->data = new stdClass();
1053
1054 try {
1055 $data = self::check_valid_lesson();
1056 $lesson_id = $data['lesson_id'] ?? 0;
1057 $lesson_model = $data['lesson_model'];
1058
1059 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1060 throw new Exception( __( 'You are not allowed to duplicate this lesson', 'learnpress' ) );
1061 }
1062
1063 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1064 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1065 }
1066
1067 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1068 $curd = new LP_Lesson_CURD();
1069 $new_item_id = $curd->duplicate( $lesson_id, $duplicate_args );
1070
1071 if ( is_wp_error( $new_item_id ) ) {
1072 throw new Exception( $new_item_id->get_error_message() );
1073 }
1074 $lesson_model_new = LessonPostModel::find( $new_item_id, true );
1075 $html = BuilderListLessonsTemplate::render_lesson( $lesson_model_new );
1076 $response->status = 'success';
1077 $response->data->html = $html;
1078 $response->message = __( 'Lesson duplicated successfully', 'learnpress' );
1079 wp_send_json( $response );
1080 } catch ( Throwable $th ) {
1081 $response->status = 'error';
1082 $response->message = $th->getMessage();
1083 wp_send_json( $response );
1084 }
1085 }
1086
1087 /**
1088 * Update Lesson from Course Builder.
1089 * Supports partial updates (title only, description only, or settings only).
1090 *
1091 * @since 4.3
1092 * @version 1.0.2
1093 */
1094 public function builder_update_lesson() {
1095 $response = new LP_REST_Response();
1096 $response->data = new stdClass();
1097
1098 try {
1099 $data = self::check_valid_lesson();
1100 $lesson_id = $data['lesson_id'] ?? 0;
1101 $title = $data['lesson_title'] ?? null;
1102 $description = $data['lesson_description'] ?? null;
1103 $settings = $data['lesson_settings'] ?? false;
1104 $is_elementor = $data['is_elementor'] ?? false;
1105 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1106 $insert = $data['insert'];
1107 $lesson_slug = ! empty( $data['lesson_permalink'] )
1108 ? sanitize_title( wp_unslash( (string) $data['lesson_permalink'] ) )
1109 : '';
1110 $course_id = absint( $data['course_id'] ?? 0 );
1111
1112 // Determine target status (draft or publish)
1113 $target_status = ! empty( $data['lesson_status'] ) && in_array( $data['lesson_status'], [ 'draft', 'publish' ], true )
1114 ? sanitize_text_field( $data['lesson_status'] )
1115 : 'publish';
1116 $restore_with_custom_slug = false;
1117
1118 if ( $insert ) {
1119 if ( ! CourseBuilderAccessPolicy::can_create_item_type( LP_LESSON_CPT ) ) {
1120 throw new Exception( __( 'You are not allowed to create lessons', 'learnpress' ) );
1121 }
1122
1123 $insert_arg = array(
1124 'post_type' => LP_LESSON_CPT,
1125 'post_title' => sanitize_text_field( $title ?? '' ),
1126 'post_content' => Template::sanitize_html_content( $description ?? '' ),
1127 'post_status' => $target_status,
1128 );
1129
1130 if ( ! empty( $lesson_slug ) ) {
1131 $insert_arg['post_name'] = $lesson_slug;
1132 }
1133
1134 $lesson_id = wp_insert_post( $insert_arg, true );
1135
1136 if ( is_wp_error( $lesson_id ) ) {
1137 throw new Exception( $lesson_id->get_error_message() );
1138 }
1139
1140 $lesson_model = LessonPostModel::find( $lesson_id, true );
1141 } else {
1142 $lesson_model = $data['lesson_model'];
1143
1144 if ( ! $lesson_model ) {
1145 throw new Exception( __( 'Lesson not found', 'learnpress' ) );
1146 }
1147
1148 if ( ! $course_id ) {
1149 $course_id = absint( $this->get_course_by_item_id( $lesson_id ) );
1150 }
1151
1152 // Support for co-instructor.
1153 $co_instructor_ids = get_post_meta( $course_id, '_lp_co_teacher', false );
1154 $co_instructor_ids = ! empty( $co_instructor_ids ) ? $co_instructor_ids : array();
1155
1156 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) && ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
1157 throw new Exception( __( 'You are not allowed to update this lesson', 'learnpress' ) );
1158 }
1159
1160 $update_arg = array(
1161 'ID' => $lesson_id,
1162 'post_type' => LP_LESSON_CPT,
1163 'post_status' => $target_status,
1164 );
1165
1166 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1167 \Elementor\Plugin::$instance->documents->get( $lesson_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1168 }
1169
1170 if ( isset( $data['lesson_title'] ) ) {
1171 $update_arg['post_title'] = sanitize_text_field( $title );
1172 }
1173
1174 if ( isset( $data['lesson_description'] ) ) {
1175 $update_arg['post_content'] = Template::sanitize_html_content( $description ?? '' );
1176 }
1177
1178 if ( ! empty( $lesson_slug ) ) {
1179 $update_arg['post_name'] = $lesson_slug;
1180 }
1181
1182 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $lesson_id, $target_status, $lesson_slug );
1183
1184 $update = wp_update_post( $update_arg );
1185
1186 if ( is_wp_error( $update ) ) {
1187 throw new Exception( $update->get_error_message() );
1188 }
1189
1190 if ( $restore_with_custom_slug ) {
1191 $this->sync_slug_after_restore( $lesson_id, $lesson_slug );
1192 }
1193
1194 if ( $course_id ) {
1195 $courseModelCache = CourseModel::find( $course_id, true );
1196 if ( $courseModelCache ) {
1197 $courseModelCache->sections_items = null;
1198 $courseModelCache->save();
1199 }
1200 }
1201 }
1202
1203 if ( $settings && $lesson_model ) {
1204 $this->save_lesson_settings_to_model( $lesson_model, $data );
1205 }
1206
1207 // Remove lesson from curriculum if status is not public
1208 if ( $target_status !== 'publish' ) {
1209 $this->remove_course_item_from_curriculum( $lesson_id, $course_id );
1210 }
1211
1212 do_action( 'learn-press/course-builder/update-lesson', $data, $lesson_model );
1213
1214 $saved_lesson_status = get_post_status( $lesson_id );
1215 if ( ! is_string( $saved_lesson_status ) || '' === $saved_lesson_status ) {
1216 $saved_lesson_status = $target_status;
1217 }
1218
1219 $response->status = 'success';
1220 $response->data->status = $saved_lesson_status;
1221 $response->data->button_title = $saved_lesson_status === 'publish' ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1222 $response->data->lesson_id_new = $insert ? $lesson_id : '';
1223 $response->message = $target_status === 'draft'
1224 ? esc_html__( 'Lesson saved as draft', 'learnpress' )
1225 : ( $insert ? esc_html__( 'Insert lesson successfully', 'learnpress' ) : esc_html__( 'Update lesson successfully', 'learnpress' ) );
1226
1227 $saved_lesson_post = get_post( $lesson_id );
1228 if ( $saved_lesson_post ) {
1229 $response->data->lesson_slug = $saved_lesson_post->post_name;
1230 }
1231
1232 $course_id_of_item = $this->get_course_by_item_id( $lesson_id );
1233 $response->data->permalink_available = false;
1234 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1235 if ( 'publish' === $saved_lesson_status && $course_id_of_item ) {
1236 $course = learn_press_get_course( $course_id_of_item );
1237 if ( $course ) {
1238 $response->data->permalink_available = true;
1239 $response->data->lesson_permalink = urldecode( $course->get_item_link( $lesson_id ) );
1240 }
1241 }
1242
1243 $lesson_model_for_html = LessonPostModel::find( $lesson_id, true );
1244 if ( $return_html ) {
1245 $response->data->list_item_html = $lesson_model_for_html
1246 ? BuilderListLessonsTemplate::render_lesson( $lesson_model_for_html )
1247 : '';
1248
1249 if ( $course_id ) {
1250 $courseModelForHtml = CourseModel::find( $course_id, true );
1251 if ( $courseModelForHtml && $lesson_model_for_html ) {
1252 $item = new stdClass();
1253 $item->item_id = $lesson_id;
1254 $item->title = $lesson_model_for_html->post_title;
1255 $item->item_type = LP_LESSON_CPT;
1256 AdminEditCurriculumTemplate::instance()->context_data = [ 'is_course_builder' => true ];
1257 $response->data->section_item_html = AdminEditCurriculumTemplate::instance()->html_section_item( $courseModelForHtml, $item );
1258 }
1259 }
1260 }
1261
1262 wp_send_json( $response );
1263 } catch ( Throwable $th ) {
1264 $response->status = 'error';
1265 $response->message = $th->getMessage();
1266 wp_send_json( $response );
1267 }
1268 }
1269
1270 public function move_trash_lesson() {
1271 $response = new LP_REST_Response();
1272 $response->data = new stdClass();
1273
1274 try {
1275 $data = self::check_valid_lesson();
1276 $lesson_id = $data['lesson_id'] ?? 0;
1277 $status = $data['status'] ?? 'trash';
1278 $lesson_model = $data['lesson_model'] ?? [];
1279 $lesson_slug = ! empty( $data['lesson_permalink'] )
1280 ? sanitize_title( wp_unslash( (string) $data['lesson_permalink'] ) )
1281 : '';
1282
1283 if ( ! $lesson_model ) {
1284 throw new Exception( __( 'Lesson not found', 'learnpress' ) );
1285 }
1286
1287 if ( absint( $lesson_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1288 throw new Exception( __( 'You are not allowed to delete this lesson', 'learnpress' ) );
1289 }
1290 $course_id = absint( $data['course_id'] ?? 0 );
1291 if ( ! $course_id ) {
1292 $course_id = absint( $this->get_course_by_item_id( $lesson_id ) );
1293 }
1294
1295 if ( $status === 'trash' ) {
1296 $original_slug = (string) get_post_field( 'post_name', $lesson_id );
1297 $move_trash = wp_trash_post( $lesson_id );
1298
1299 if ( is_wp_error( $move_trash ) ) {
1300 throw new Exception( esc_html__( 'Cannot move this lesson to trash', 'learnpress' ) );
1301 }
1302
1303 if ( '' !== $original_slug ) {
1304 wp_update_post(
1305 [
1306 'ID' => $lesson_id,
1307 'post_name' => $original_slug,
1308 ]
1309 );
1310 }
1311
1312 $message = esc_html__( 'This lesson has been moved to trash.', 'learnpress' );
1313 } elseif ( $status === 'delete' ) {
1314
1315 if ( $lesson_model->post_status !== 'trash' ) {
1316 throw new Exception( esc_html__( 'Lesson must be trashed before deleting.', 'learnpress' ) );
1317 }
1318
1319 $delete = wp_delete_post( $lesson_id );
1320
1321 if ( is_wp_error( $delete ) ) {
1322 throw new Exception( esc_html__( 'Cannot delete this lesson.', 'learnpress' ) );
1323 }
1324 $message = esc_html__( 'Delete this lesson successfully', 'learnpress' );
1325 } elseif ( in_array( $status, [ 'publish', 'draft' ], true ) ) {
1326 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $lesson_id, $status, $lesson_slug );
1327 $update_args = [
1328 'ID' => $lesson_id,
1329 'post_type' => LP_LESSON_CPT,
1330 'post_status' => $status,
1331 ];
1332
1333 if ( '' !== $lesson_slug ) {
1334 $update_args['post_name'] = $lesson_slug;
1335 }
1336
1337 $update = wp_update_post( $update_args );
1338 if ( ! $update ) {
1339 if ( 'draft' === $status ) {
1340 throw new Exception( __( 'Lesson cannot be moved to draft', 'learnpress' ) );
1341 }
1342
1343 throw new Exception( __( 'Lesson cannot be moved to publish', 'learnpress' ) );
1344 }
1345
1346 if ( $restore_with_custom_slug ) {
1347 $this->sync_slug_after_restore( $lesson_id, $lesson_slug );
1348 }
1349
1350 $message = 'draft' === $status
1351 ? __( 'Lesson has been moved to draft', 'learnpress' )
1352 : __( 'Lesson has been moved to publish', 'learnpress' );
1353 } else {
1354 throw new Exception( __( 'Invalid lesson status transition', 'learnpress' ) );
1355 }
1356
1357 $saved_lesson_status = get_post_status( $lesson_id );
1358 if ( ! is_string( $saved_lesson_status ) || '' === $saved_lesson_status ) {
1359 $saved_lesson_status = $status;
1360 }
1361
1362 if ( $saved_lesson_status !== 'publish' ) {
1363 $this->remove_course_item_from_curriculum( $lesson_id, $course_id );
1364 }
1365
1366 $response->data->status = $saved_lesson_status;
1367 $response->data->button_title = 'publish' === $saved_lesson_status ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1368 $response->data->lesson_slug = (string) get_post_field( 'post_name', $lesson_id );
1369 $response->data->permalink_available = false;
1370 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1371
1372 $course_id_of_item = $this->get_course_by_item_id( $lesson_id );
1373 if ( 'publish' === $saved_lesson_status && $course_id_of_item ) {
1374 $course = learn_press_get_course( $course_id_of_item );
1375 if ( $course ) {
1376 $response->data->permalink_available = true;
1377 $response->data->lesson_permalink = urldecode( $course->get_item_link( $lesson_id ) );
1378 }
1379 }
1380
1381 if ( 'delete' !== $status ) {
1382 $lesson_model_new = LessonPostModel::find( $lesson_id, true );
1383 $response->data->html = $lesson_model_new ? BuilderListLessonsTemplate::render_lesson( $lesson_model_new ) : '';
1384 }
1385
1386 $response->status = 'success';
1387 $response->message = $message;
1388 wp_send_json( $response );
1389 } catch ( Throwable $th ) {
1390 $response->status = 'error';
1391 $response->message = $th->getMessage();
1392 wp_send_json( $response );
1393 }
1394 }
1395
1396 /**
1397 * Save Lesson Settings
1398 */
1399 protected function save_lesson_settings_to_model( LessonPostModel $lessonModel, array $data ) {
1400 if ( isset( $data['_lp_duration'] ) ) {
1401 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1402 $explode = explode( ' ', $duration );
1403 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1404 $unit = $explode[1] ?? 'minute';
1405
1406 $lessonModel->save_meta_value_by_key( '_lp_duration', $number . ' ' . $unit );
1407 }
1408
1409 if ( isset( $data['_lp_preview'] ) ) {
1410 $enable = $data['_lp_preview'] === 'yes';
1411 $lessonModel->set_preview( $enable );
1412 }
1413 }
1414
1415 /**
1416 * Save Quiz Settings to QuizPostModel
1417 *
1418 * @param QuizPostModel $quizModel
1419 * @param array $data
1420 *
1421 * @since 4.3
1422 * @version 1.0.0
1423 */
1424 protected function save_quiz_settings_to_model( QuizPostModel $quizModel, array $data ) {
1425 if ( isset( $data['_lp_duration'] ) ) {
1426 $duration = ! empty( $data['_lp_duration'] ) ? str_replace( ',', ' ', $data['_lp_duration'] ) : '0 minute';
1427 $explode = explode( ' ', $duration );
1428 $number = (float) $explode[0] < 0 ? 0 : absint( $explode[0] );
1429 $unit = $explode[1] ?? 'minute';
1430
1431 $quizModel->save_meta_value_by_key( '_lp_duration', $number . ' ' . $unit );
1432 }
1433
1434 $checkbox_keys = [
1435 '_lp_instant_check',
1436 '_lp_negative_marking',
1437 '_lp_minus_skip_questions',
1438 '_lp_review',
1439 '_lp_show_correct_review',
1440 '_lp_show_check_answer',
1441 '_lp_show_hint',
1442 ];
1443
1444 foreach ( $checkbox_keys as $key ) {
1445 if ( isset( $data[ $key ] ) ) {
1446 $value = $data[ $key ] === 'yes' ? 'yes' : 'no';
1447 $quizModel->save_meta_value_by_key( $key, $value );
1448 }
1449 }
1450
1451 $numeric_keys = [
1452 '_lp_passing_grade',
1453 '_lp_retake_count',
1454 '_lp_pagination',
1455 ];
1456
1457 foreach ( $numeric_keys as $key ) {
1458 if ( isset( $data[ $key ] ) ) {
1459 $value = absint( $data[ $key ] );
1460 if ( '_lp_passing_grade' === $key && $value > 100 ) {
1461 $value = 100;
1462 }
1463 $quizModel->save_meta_value_by_key( $key, $value );
1464 }
1465 }
1466 }
1467
1468 /**
1469 * Update Quiz from Course Builder.
1470 * Supports partial updates (title only, description only, or settings only).
1471 *
1472 * @since 4.3
1473 * @version 1.0.2
1474 */
1475 public function builder_update_quiz() {
1476 $response = new LP_REST_Response();
1477 $response->data = new stdClass();
1478
1479 try {
1480 $data = self::check_valid_quiz();
1481 $quiz_id = $data['quiz_id'] ?? 0;
1482 $title = $data['quiz_title'] ?? null;
1483 $description = $data['quiz_description'] ?? null;
1484 $settings = $data['quiz_settings'] ?? false;
1485 $is_elementor = $data['is_elementor'] ?? false;
1486 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1487 $insert = $data['insert'];
1488 $quiz_slug = ! empty( $data['quiz_permalink'] )
1489 ? sanitize_title( wp_unslash( (string) $data['quiz_permalink'] ) )
1490 : '';
1491 $course_id = absint( $data['course_id'] ?? 0 );
1492
1493 // Determine target status (draft or publish)
1494 $target_status = ! empty( $data['quiz_status'] ) && in_array( $data['quiz_status'], [ 'draft', 'publish' ], true )
1495 ? sanitize_text_field( $data['quiz_status'] )
1496 : 'publish';
1497 $restore_with_custom_slug = false;
1498
1499 if ( $insert ) {
1500 if ( ! CourseBuilderAccessPolicy::can_create_item_type( LP_QUIZ_CPT ) ) {
1501 throw new Exception( __( 'You are not allowed to create quizzes', 'learnpress' ) );
1502 }
1503
1504 $insert_arg = array(
1505 'post_type' => LP_QUIZ_CPT,
1506 'post_title' => sanitize_text_field( $title ?? '' ),
1507 'post_content' => Template::sanitize_html_content( $description ?? '' ),
1508 'post_status' => $target_status,
1509 );
1510
1511 if ( ! empty( $quiz_slug ) ) {
1512 $insert_arg['post_name'] = $quiz_slug;
1513 }
1514
1515 $quiz_id = wp_insert_post( $insert_arg, true );
1516
1517 if ( is_wp_error( $quiz_id ) ) {
1518 throw new Exception( $quiz_id->get_error_message() );
1519 }
1520
1521 $quiz_model = QuizPostModel::find( $quiz_id, true );
1522 } else {
1523 $quiz_model = $data['quiz_model'];
1524
1525 if ( ! $quiz_model ) {
1526 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1527 }
1528
1529 if ( ! $course_id ) {
1530 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1531 }
1532
1533 // Support for co-instructor.
1534 $co_instructor_ids = get_post_meta( $course_id, '_lp_co_teacher', false );
1535 $co_instructor_ids = ! empty( $co_instructor_ids ) ? $co_instructor_ids : array();
1536
1537 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) && ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
1538 throw new Exception( __( 'You are not allowed to update this quiz', 'learnpress' ) );
1539 }
1540
1541 $update_arg = array(
1542 'ID' => $quiz_id,
1543 'post_type' => LP_QUIZ_CPT,
1544 'post_status' => $target_status,
1545 );
1546
1547 if ( isset( $data['quiz_title'] ) ) {
1548 $update_arg['post_title'] = sanitize_text_field( $title );
1549 }
1550
1551 if ( isset( $data['quiz_description'] ) ) {
1552 $update_arg['post_content'] = Template::sanitize_html_content( $description ?? '' );
1553 }
1554
1555 if ( ! empty( $quiz_slug ) ) {
1556 $update_arg['post_name'] = $quiz_slug;
1557 }
1558
1559 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1560 \Elementor\Plugin::$instance->documents->get( $quiz_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1561 }
1562
1563 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $quiz_id, $target_status, $quiz_slug );
1564
1565 $update = wp_update_post( $update_arg );
1566
1567 if ( is_wp_error( $update ) ) {
1568 throw new Exception( $update->get_error_message() );
1569 }
1570
1571 if ( $restore_with_custom_slug ) {
1572 $this->sync_slug_after_restore( $quiz_id, $quiz_slug );
1573 }
1574
1575 if ( $course_id ) {
1576 $courseModelCache = CourseModel::find( $course_id, true );
1577 if ( $courseModelCache ) {
1578 $courseModelCache->sections_items = null;
1579 $courseModelCache->save();
1580 }
1581 }
1582 }
1583
1584 if ( $settings && $quiz_model ) {
1585 $this->save_quiz_settings_to_model( $quiz_model, $data );
1586 }
1587
1588 // Remove quiz from curriculum if status is not public
1589 if ( $target_status !== 'publish' ) {
1590 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1591 }
1592
1593 do_action( 'learn-press/course-builder/update-quiz', $data, $quiz_model );
1594
1595 $saved_quiz_status = get_post_status( $quiz_id );
1596 if ( ! is_string( $saved_quiz_status ) || '' === $saved_quiz_status ) {
1597 $saved_quiz_status = $target_status;
1598 }
1599
1600 if ( $insert ) {
1601 $response->data->redirect_url = CourseBuilder::get_link_course_builder(
1602 CourseBuilderTemplate::MENU_QUIZZES . "/{$quiz_id}"
1603 );
1604 }
1605
1606 $response->status = 'success';
1607 $response->data->status = $saved_quiz_status;
1608 $response->data->button_title = $saved_quiz_status === 'publish' ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1609 $response->message = $target_status === 'draft'
1610 ? esc_html__( 'Quiz saved as draft', 'learnpress' )
1611 : ( $insert ? esc_html__( 'Insert quiz successfully', 'learnpress' ) : esc_html__( 'Update quiz successfully', 'learnpress' ) );
1612
1613 $saved_quiz_post = get_post( $quiz_id );
1614 if ( $saved_quiz_post ) {
1615 $response->data->quiz_slug = $saved_quiz_post->post_name;
1616 }
1617
1618 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1619 $response->data->permalink_available = false;
1620 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1621 if ( 'publish' === $saved_quiz_status && $course_id_of_item ) {
1622 $course = learn_press_get_course( $course_id_of_item );
1623 if ( $course ) {
1624 $response->data->permalink_available = true;
1625 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1626 }
1627 }
1628
1629 if ( $return_html ) {
1630 $quiz_model_new = QuizPostModel::find( $quiz_id, true );
1631 $response->data->list_item_html = $quiz_model_new
1632 ? BuilderListQuizzesTemplate::render_quiz( $quiz_model_new )
1633 : '';
1634
1635 if ( $course_id ) {
1636 $courseModelForHtml = CourseModel::find( $course_id, true );
1637 if ( $courseModelForHtml && $quiz_model_new ) {
1638 $item = new stdClass();
1639 $item->item_id = $quiz_id;
1640 $item->title = $quiz_model_new->post_title;
1641 $item->item_type = LP_QUIZ_CPT;
1642 AdminEditCurriculumTemplate::instance()->context_data = [ 'is_course_builder' => true ];
1643 $response->data->section_item_html = AdminEditCurriculumTemplate::instance()->html_section_item( $courseModelForHtml, $item );
1644 }
1645 }
1646 }
1647
1648 wp_send_json( $response );
1649 } catch ( Throwable $th ) {
1650 $response->status = 'error';
1651 $response->message = $th->getMessage();
1652 wp_send_json( $response );
1653 }
1654 }
1655
1656 public function duplicate_quiz() {
1657 $response = new LP_REST_Response();
1658 $response->data = new stdClass();
1659
1660 try {
1661 $data = self::check_valid_quiz();
1662 $quiz_id = $data['quiz_id'] ?? 0;
1663 $quiz_model = $data['quiz_model'];
1664
1665 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1666 throw new Exception( __( 'You are not allowed to duplicate this quiz', 'learnpress' ) );
1667 }
1668
1669 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1670 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1671 }
1672
1673 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1674 $curd = new LP_Quiz_CURD();
1675 $new_item_id = $curd->duplicate( $quiz_id, $duplicate_args );
1676
1677 if ( is_wp_error( $new_item_id ) ) {
1678 throw new Exception( $new_item_id->get_error_message() );
1679 }
1680 $response->status = 'success';
1681 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUIZZES . "/{$new_item_id}" );
1682 $response->message = __( 'Quiz duplicated successfully', 'learnpress' );
1683 wp_send_json( $response );
1684 } catch ( Throwable $th ) {
1685 $response->status = 'error';
1686 $response->message = $th->getMessage();
1687 wp_send_json( $response );
1688 }
1689 }
1690
1691 public function move_trash_quiz() {
1692 $response = new LP_REST_Response();
1693 $response->data = new stdClass();
1694
1695 try {
1696 $data = self::check_valid_quiz();
1697 $quiz_id = $data['quiz_id'] ?? 0;
1698 $status = $data['status'] ?? 'trash';
1699 $quiz_model = $data['quiz_model'] ?? [];
1700 $quiz_slug = ! empty( $data['quiz_permalink'] )
1701 ? sanitize_title( wp_unslash( (string) $data['quiz_permalink'] ) )
1702 : '';
1703
1704 if ( ! $quiz_model ) {
1705 throw new Exception( __( 'Quiz not found', 'learnpress' ) );
1706 }
1707
1708 if ( absint( $quiz_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1709 throw new Exception( __( 'You are not allowed to delete this quiz', 'learnpress' ) );
1710 }
1711 $course_id = absint( $data['course_id'] ?? 0 );
1712 if ( ! $course_id ) {
1713 $course_id = absint( $this->get_course_by_item_id( $quiz_id ) );
1714 }
1715
1716 if ( $status === 'trash' ) {
1717 $original_slug = (string) get_post_field( 'post_name', $quiz_id );
1718 $move_trash = wp_trash_post( $quiz_id );
1719
1720 if ( is_wp_error( $move_trash ) ) {
1721 throw new Exception( esc_html__( 'Cannot move this quiz to trash', 'learnpress' ) );
1722 }
1723
1724 if ( '' !== $original_slug ) {
1725 wp_update_post(
1726 [
1727 'ID' => $quiz_id,
1728 'post_name' => $original_slug,
1729 ]
1730 );
1731 }
1732
1733 $message = esc_html__( 'This quiz has been moved to trash.', 'learnpress' );
1734 } elseif ( $status === 'delete' ) {
1735
1736 if ( $quiz_model->post_status !== 'trash' ) {
1737 throw new Exception( esc_html__( 'Quiz must be trashed before deleting.', 'learnpress' ) );
1738 }
1739
1740 $delete = wp_delete_post( $quiz_id );
1741
1742 if ( is_wp_error( $delete ) ) {
1743 throw new Exception( esc_html__( 'Cannot delete this quiz.', 'learnpress' ) );
1744 }
1745 $message = esc_html__( 'Delete this quiz successfully', 'learnpress' );
1746 } elseif ( in_array( $status, [ 'publish', 'draft' ], true ) ) {
1747 $restore_with_custom_slug = $this->prepare_desired_slug_for_restore( $quiz_id, $status, $quiz_slug );
1748 $update_args = [
1749 'ID' => $quiz_id,
1750 'post_type' => LP_QUIZ_CPT,
1751 'post_status' => $status,
1752 ];
1753
1754 if ( '' !== $quiz_slug ) {
1755 $update_args['post_name'] = $quiz_slug;
1756 }
1757
1758 $update = wp_update_post( $update_args );
1759 if ( ! $update ) {
1760 if ( 'draft' === $status ) {
1761 throw new Exception( __( 'Quiz cannot be moved to draft', 'learnpress' ) );
1762 }
1763
1764 throw new Exception( __( 'Quiz cannot be moved to publish', 'learnpress' ) );
1765 }
1766
1767 if ( $restore_with_custom_slug ) {
1768 $this->sync_slug_after_restore( $quiz_id, $quiz_slug );
1769 }
1770
1771 $message = 'draft' === $status
1772 ? __( 'Quiz has been moved to draft', 'learnpress' )
1773 : __( 'Quiz has been moved to publish', 'learnpress' );
1774 } else {
1775 throw new Exception( __( 'Invalid quiz status transition', 'learnpress' ) );
1776 }
1777
1778 $saved_quiz_status = get_post_status( $quiz_id );
1779 if ( ! is_string( $saved_quiz_status ) || '' === $saved_quiz_status ) {
1780 $saved_quiz_status = $status;
1781 }
1782
1783 if ( $saved_quiz_status !== 'publish' ) {
1784 $this->remove_course_item_from_curriculum( $quiz_id, $course_id );
1785 }
1786
1787 $response->data->status = $saved_quiz_status;
1788 $response->data->button_title = 'publish' === $saved_quiz_status ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1789 $response->data->quiz_slug = (string) get_post_field( 'post_name', $quiz_id );
1790 $response->data->permalink_available = false;
1791 $response->data->permalink_notice = $this->get_item_permalink_unavailable_message();
1792
1793 $course_id_of_item = $this->get_course_by_item_id( $quiz_id );
1794 if ( 'publish' === $saved_quiz_status && $course_id_of_item ) {
1795 $course = learn_press_get_course( $course_id_of_item );
1796 if ( $course ) {
1797 $response->data->permalink_available = true;
1798 $response->data->quiz_permalink = urldecode( $course->get_item_link( $quiz_id ) );
1799 }
1800 }
1801
1802 if ( 'delete' !== $status ) {
1803 $fresh_quiz_model = QuizPostModel::find( $quiz_id, true );
1804 $response->data->html = $fresh_quiz_model
1805 ? BuilderListQuizzesTemplate::render_quiz( $fresh_quiz_model )
1806 : '';
1807 }
1808
1809 $response->status = 'success';
1810 $response->message = $message;
1811 wp_send_json( $response );
1812 } catch ( Throwable $th ) {
1813 $response->status = 'error';
1814 $response->message = $th->getMessage();
1815 wp_send_json( $response );
1816 }
1817 }
1818
1819 public function duplicate_question() {
1820 $response = new LP_REST_Response();
1821 $response->data = new stdClass();
1822
1823 try {
1824 $data = self::check_valid_question();
1825 $question_id = $data['question_id'] ?? 0;
1826 $question_model = $data['question_model'];
1827
1828 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
1829 throw new Exception( __( 'You are not allowed to duplicate this question', 'learnpress' ) );
1830 }
1831
1832 if ( ! function_exists( 'learn_press_duplicate_post' ) ) {
1833 require_once LP_PLUGIN_PATH . 'inc/admin/lp-admin-functions.php';
1834 }
1835
1836 $duplicate_args = apply_filters( 'learn-press/duplicate-post-args', array( 'post_status' => 'publish' ) );
1837 $curd = new LP_Question_CURD();
1838 $new_item_id = $curd->duplicate( $question_id, $duplicate_args );
1839
1840 if ( is_wp_error( $new_item_id ) ) {
1841 throw new Exception( $new_item_id->get_error_message() );
1842 }
1843 $response->status = 'success';
1844 $response->data->redirect_url = CourseBuilder::get_link_course_builder( CourseBuilderTemplate::MENU_QUESTIONS . "/{$new_item_id}" );
1845 $response->message = __( 'Question duplicated successfully', 'learnpress' );
1846 wp_send_json( $response );
1847 } catch ( Throwable $th ) {
1848 $response->status = 'error';
1849 $response->message = $th->getMessage();
1850 wp_send_json( $response );
1851 }
1852 }
1853
1854 public function builder_update_question() {
1855 $response = new LP_REST_Response();
1856 $response->data = new stdClass();
1857
1858 try {
1859 $data = self::check_valid_question();
1860 $question_id = $data['question_id'] ?? 0;
1861 $title = $data['question_title'] ?? '';
1862 $description = $data['question_description'] ?? '';
1863 $is_elementor = $data['is_elementor'] ?? false;
1864 $return_html = ( $data['return_html'] ?? 'no' ) === 'yes';
1865 $insert = $data['insert'];
1866 $question_slug = ! empty( $data['question_permalink'] )
1867 ? sanitize_title( wp_unslash( (string) $data['question_permalink'] ) )
1868 : '';
1869
1870 // Determine target status (draft or publish)
1871 $target_status = ! empty( $data['question_status'] ) && in_array( $data['question_status'], [ 'draft', 'publish' ], true )
1872 ? sanitize_text_field( $data['question_status'] )
1873 : 'publish';
1874
1875 if ( $insert ) {
1876 if ( ! CourseBuilderAccessPolicy::can_create_item_type( LP_QUESTION_CPT ) ) {
1877 throw new Exception( __( 'You are not allowed to create questions', 'learnpress' ) );
1878 }
1879
1880 $insert_arg = array(
1881 'post_type' => LP_QUESTION_CPT,
1882 'post_title' => sanitize_text_field( $title ?? '' ),
1883 'post_content' => Template::sanitize_html_content( $description ?? '' ),
1884 'post_status' => $target_status,
1885 );
1886
1887 if ( ! empty( $question_slug ) ) {
1888 $insert_arg['post_name'] = $question_slug;
1889 }
1890
1891 $question_id = wp_insert_post( $insert_arg, true );
1892
1893 if ( is_wp_error( $question_id ) ) {
1894 throw new Exception( $question_id->get_error_message() );
1895 }
1896 } else {
1897 $question_model = $data['question_model'];
1898
1899 if ( ! $question_model ) {
1900 throw new Exception( __( 'Question not found', 'learnpress' ) );
1901 }
1902
1903 $course_id = $this->get_course_by_item_id( $question_id );
1904
1905 // Support for co-instructor.
1906 $co_instructor_ids = get_post_meta( $course_id, '_lp_co_teacher', false );
1907 $co_instructor_ids = ! empty( $co_instructor_ids ) ? $co_instructor_ids : array();
1908
1909 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) && ! in_array( get_current_user_id(), $co_instructor_ids ) ) {
1910 throw new Exception( __( 'You are not allowed to update this question', 'learnpress' ) );
1911 }
1912
1913 $update_arg = array(
1914 'ID' => $question_id,
1915 'post_type' => LP_QUESTION_CPT,
1916 'post_status' => $target_status,
1917 );
1918
1919 if ( defined( 'ELEMENTOR_VERSION' ) ) {
1920 \Elementor\Plugin::$instance->documents->get( $question_id )->set_is_built_with_elementor( ! empty( $is_elementor ) );
1921 }
1922
1923 if ( isset( $data['question_title'] ) ) {
1924 $update_arg['post_title'] = sanitize_text_field( $title );
1925 }
1926
1927 if ( isset( $data['question_description'] ) ) {
1928 $update_arg['post_content'] = Template::sanitize_html_content( $description ?? '' );
1929 }
1930
1931 if ( ! empty( $question_slug ) ) {
1932 $update_arg['post_name'] = $question_slug;
1933 }
1934
1935 $update = wp_update_post( $update_arg );
1936
1937 if ( is_wp_error( $update ) ) {
1938 throw new Exception( $update->get_error_message() );
1939 }
1940 }
1941
1942 // Remove question from quizzes if status is not public
1943 if ( $target_status !== 'publish' ) {
1944 $this->remove_question_from_assigned_quizzes( $question_id );
1945 }
1946
1947 do_action( 'learn-press/course-builder/update-question', $data, $question_model ?? null );
1948
1949 $saved_question_status = get_post_status( $question_id );
1950 if ( ! is_string( $saved_question_status ) || '' === $saved_question_status ) {
1951 $saved_question_status = $target_status;
1952 }
1953
1954 if ( $insert ) {
1955 $response->data->redirect_url = CourseBuilder::get_link_course_builder(
1956 CourseBuilderTemplate::MENU_QUESTIONS . "/{$question_id}"
1957 );
1958 }
1959
1960 $response->status = 'success';
1961 $response->data->status = $saved_question_status;
1962 $response->data->button_title = $saved_question_status === 'publish' ? __( 'Update', 'learnpress' ) : __( 'Publish', 'learnpress' );
1963
1964 $response->message = $target_status === 'draft'
1965 ? esc_html__( 'Question saved as draft', 'learnpress' )
1966 : ( $insert ? esc_html__( 'Insert question successfully', 'learnpress' ) : esc_html__( 'Update question successfully', 'learnpress' ) );
1967
1968 $saved_question = get_post( $question_id );
1969 if ( $saved_question ) {
1970 $response->data->question_slug = $saved_question->post_name;
1971 $response->data->question_permalink = get_permalink( $question_id );
1972 }
1973
1974 if ( $return_html ) {
1975 $question_model_new = QuestionPostModel::find( $question_id, true );
1976 $response->data->list_item_html = $question_model_new
1977 ? BuilderListQuestionsTemplate::render_question( $question_model_new )
1978 : '';
1979 }
1980
1981 wp_send_json( $response );
1982 } catch ( Throwable $th ) {
1983 $response->status = 'error';
1984 $response->message = $th->getMessage();
1985 wp_send_json( $response );
1986 }
1987 }
1988
1989 public function move_trash_question() {
1990 $response = new LP_REST_Response();
1991 $response->data = new stdClass();
1992
1993 try {
1994 $data = self::check_valid_question();
1995 $question_id = $data['question_id'] ?? 0;
1996 $status = $data['status'] ?? 'trash';
1997 $question_model = $data['question_model'] ?? [];
1998
1999 if ( ! $question_model ) {
2000 throw new Exception( __( 'Question not found', 'learnpress' ) );
2001 }
2002
2003 if ( absint( $question_model->post_author ) !== get_current_user_id() && ! current_user_can( 'manage_options' ) ) {
2004 throw new Exception( __( 'You are not allowed to delete this question', 'learnpress' ) );
2005 }
2006
2007 if ( $status === 'trash' ) {
2008 $move_trash = wp_trash_post( $question_id );
2009
2010 if ( is_wp_error( $move_trash ) ) {
2011 throw new Exception( esc_html__( 'Cannot move this question to trash', 'learnpress' ) );
2012 }
2013 $message = esc_html__( 'This question has been moved to trash.', 'learnpress' );
2014 } elseif ( $status === 'delete' ) {
2015
2016 if ( $question_model->post_status !== 'trash' ) {
2017 throw new Exception( esc_html__( 'Question must be trashed before deleting.', 'learnpress' ) );
2018 }
2019
2020 $delete = wp_delete_post( $question_id );
2021
2022 if ( is_wp_error( $delete ) ) {
2023 throw new Exception( esc_html__( 'Cannot delete this question.', 'learnpress' ) );
2024 }
2025 $message = esc_html__( 'Delete this question successfully', 'learnpress' );
2026
2027 } elseif ( $status === 'publish' ) {
2028 $update = wp_update_post(
2029 array(
2030 'ID' => $question_id,
2031 'post_type' => LP_QUESTION_CPT,
2032 'post_status' => 'publish',
2033 )
2034 );
2035 if ( ! $update ) {
2036 throw new Exception( __( 'Question cannot be moved to publish', 'learnpress' ) );
2037 }
2038
2039 $message = __( 'Question has been moved to publish', 'learnpress' );
2040 } elseif ( $status === 'draft' ) {
2041 $update = wp_update_post(
2042 array(
2043 'ID' => $question_id,
2044 'post_type' => LP_QUESTION_CPT,
2045 'post_status' => 'draft',
2046 )
2047 );
2048 if ( ! $update ) {
2049 throw new Exception( __( 'Question cannot be restored to draft', 'learnpress' ) );
2050 }
2051
2052 $message = __( 'Question has been restored to draft', 'learnpress' );
2053 }
2054
2055 if ( $status !== 'publish' ) {
2056 $this->remove_question_from_assigned_quizzes( $question_id );
2057 }
2058
2059 $response->data->status = $status;
2060 $response->data->button_title = __( 'Publish', 'learnpress' );
2061
2062 if ( 'delete' !== $status ) {
2063 $fresh_question_model = QuestionPostModel::find( $question_id, true );
2064 $response->data->html = $fresh_question_model
2065 ? BuilderListQuestionsTemplate::render_question( $fresh_question_model )
2066 : '';
2067 }
2068
2069 $response->status = 'success';
2070 $response->message = $message;
2071 wp_send_json( $response );
2072 } catch ( Throwable $th ) {
2073 $response->status = 'error';
2074 $response->message = $th->getMessage();
2075 wp_send_json( $response );
2076 }
2077 }
2078
2079 /**
2080 * Remove lesson/quiz assignment from curriculum sections.
2081 *
2082 * @param int $item_id
2083 * @param int $course_id_fallback
2084 *
2085 * @return void
2086 */
2087 protected function remove_course_item_from_curriculum( int $item_id, int $course_id_fallback = 0 ) {
2088 if ( $item_id <= 0 ) {
2089 return;
2090 }
2091
2092 global $wpdb;
2093
2094 $section_items = $wpdb->get_results(
2095 $wpdb->prepare(
2096 "
2097 SELECT si.section_id, s.section_course_id
2098 FROM {$wpdb->learnpress_section_items} si
2099 INNER JOIN {$wpdb->learnpress_sections} s ON s.section_id = si.section_id
2100 WHERE si.item_id = %d
2101 ",
2102 $item_id
2103 )
2104 );
2105 if ( ! $section_items ) {
2106 if ( $course_id_fallback > 0 ) {
2107 $this->clean_course_curriculum_cache( $course_id_fallback );
2108 }
2109
2110 return;
2111 }
2112
2113 foreach ( $section_items as $section_item ) {
2114 $section_id = absint( $section_item->section_id ?? 0 );
2115 $course_id = absint( $section_item->section_course_id ?? 0 );
2116 if ( ! $section_id || ! $course_id ) {
2117 continue;
2118 }
2119
2120 $course_section_item_model = CourseSectionItemModel::find( $section_id, $item_id );
2121 if ( ! $course_section_item_model ) {
2122 continue;
2123 }
2124
2125 $course_section_item_model->section_course_id = $course_id;
2126 $course_section_item_model->delete();
2127 }
2128 }
2129
2130 /**
2131 * Keep course curriculum caches in sync after direct section item removal.
2132 *
2133 * @param int $course_id
2134 *
2135 * @return void
2136 */
2137 protected function clean_course_curriculum_cache( int $course_id ) {
2138 if ( $course_id <= 0 ) {
2139 return;
2140 }
2141
2142 try {
2143 $courseModel = CourseModel::find( $course_id, true );
2144 if ( ! $courseModel ) {
2145 return;
2146 }
2147
2148 $courseModel->sections_items = null;
2149 $courseModel->total_items = null;
2150 $courseModel->save( true );
2151 } catch ( Throwable $e ) {
2152 error_log( __METHOD__ . ': ' . $e->getMessage() );
2153 }
2154 }
2155
2156 /**
2157 * Remove question assignment from all quizzes.
2158 *
2159 * @param int $question_id
2160 *
2161 * @return void
2162 */
2163 protected function remove_question_from_assigned_quizzes( int $question_id ) {
2164 if ( $question_id <= 0 ) {
2165 return;
2166 }
2167
2168 global $wpdb;
2169 $wpdb->delete(
2170 $wpdb->prefix . 'learnpress_quiz_questions',
2171 array( 'question_id' => $question_id ),
2172 array( '%d' )
2173 );
2174 }
2175
2176 protected function get_course_by_item_id( $item_id ) {
2177 static $cache = [];
2178
2179 global $wpdb;
2180
2181 if ( empty( $item_id ) ) {
2182 return false;
2183 }
2184
2185 $item_id = absint( $item_id );
2186 if ( isset( $cache[ $item_id ] ) ) {
2187 return $cache[ $item_id ] ? $cache[ $item_id ] : false;
2188 }
2189
2190 $course_id = $wpdb->get_var(
2191 $wpdb->prepare(
2192 "SELECT c.ID FROM {$wpdb->posts} c
2193 INNER JOIN {$wpdb->learnpress_sections} s ON c.ID = s.section_course_id
2194 INNER JOIN {$wpdb->learnpress_section_items} si ON si.section_id = s.section_id
2195 WHERE si.item_id = %d ORDER BY si.section_id DESC LIMIT 1
2196 ",
2197 $item_id
2198 )
2199 );
2200 $cache[ $item_id ] = absint( $course_id );
2201
2202 if ( $cache[ $item_id ] ) {
2203 return $cache[ $item_id ];
2204 }
2205
2206 return false;
2207 }
2208
2209 /**
2210 * Save global settings for Course Builder.
2211 *
2212 * @since 4.3.6
2213 * @version 1.0.0
2214 */
2215 public function save_global_settings() {
2216 $response = new LP_REST_Response();
2217 $response->data = new stdClass();
2218
2219 try {
2220 $params = wp_unslash( $_REQUEST['data'] ?? '' );
2221 if ( empty( $params ) ) {
2222 throw new Exception( 'Error: params invalid!' );
2223 }
2224
2225 $data = LP_Helper::json_decode( $params, true );
2226
2227 if ( ! current_user_can( ADMIN_ROLE ) ) {
2228 throw new Exception( __( 'Permission denied', 'learnpress' ) );
2229 }
2230
2231 $hide_instructor_access_admin_screen = ! empty( $data['hide_instructor_access_admin_screen'] ) && $data['hide_instructor_access_admin_screen'] === 'yes' ? 'yes' : 'no';
2232 $logo_remove = ! empty( $data['course_builder_logo_remove'] ) && $data['course_builder_logo_remove'] === 'yes';
2233 $logo_id = absint( $data['course_builder_logo_id'] ?? 0 );
2234
2235 if ( $logo_remove ) {
2236 $logo_id = 0;
2237 }
2238
2239 LP_Settings::update_option( 'hide_instructor_access_admin_screen', $hide_instructor_access_admin_screen );
2240 LP_Settings::update_option( 'course_builder_logo_id', $logo_id );
2241
2242 $logo_url = '';
2243 if ( $logo_id ) {
2244 $logo_url = wp_get_attachment_image_url( $logo_id, 'full' );
2245 }
2246
2247 $response->status = 'success';
2248 $response->message = __( 'Course Builder settings updated.', 'learnpress' );
2249 $response->data->hide_instructor_access_admin_screen = $hide_instructor_access_admin_screen;
2250 $response->data->course_builder_logo_id = $logo_id;
2251 $response->data->course_builder_logo_url = $logo_url;
2252
2253 wp_send_json( $response );
2254 } catch ( Throwable $th ) {
2255 $response->status = 'error';
2256 $response->message = $th->getMessage();
2257 wp_send_json( $response );
2258 }
2259 }
2260 }
2261