PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.3.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.3.7
4.4.9 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 All 140 releases
learnpress / inc / Ajax / CourseBuilder / CourseBuilderAjax.php

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

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