diff --git a/public_html/wp-content/plugins/wcpt/tests/bootstrap.php b/public_html/wp-content/plugins/wcpt/tests/bootstrap.php index 493149857..80085dfa2 100644 --- a/public_html/wp-content/plugins/wcpt/tests/bootstrap.php +++ b/public_html/wp-content/plugins/wcpt/tests/bootstrap.php @@ -12,6 +12,7 @@ function manually_load_plugins() { require_once dirname( dirname( dirname( __DIR__ ) ) ) . '/sunrise.php'; require_once dirname( __DIR__ ) . '/wcpt-wordcamp/wordcamp-new-site.php'; + require_once dirname( __DIR__ ) . '/wcpt-wordcamp/wordcamp-admin.php'; } tests_add_filter( 'muplugins_loaded', __NAMESPACE__ . '\manually_load_plugins' ); diff --git a/public_html/wp-content/plugins/wcpt/tests/wcpt-wordcamp/test-wordcamp-admin.php b/public_html/wp-content/plugins/wcpt/tests/wcpt-wordcamp/test-wordcamp-admin.php new file mode 100644 index 000000000..b36e17fc8 --- /dev/null +++ b/public_html/wp-content/plugins/wcpt/tests/wcpt-wordcamp/test-wordcamp-admin.php @@ -0,0 +1,282 @@ +post->create( array( + 'post_type' => WCPT_POST_TYPE_ID, + 'post_status' => 'draft', + ) ); + + // Set the desired status directly in the database to avoid triggering hooks. + if ( 'draft' !== $status ) { + $wpdb->update( + $wpdb->posts, + array( 'post_status' => $status ), + array( 'ID' => $post_id ) + ); + clean_post_cache( $post_id ); + } + + return $post_id; + } + + /** + * @covers WordCamp_Admin::get_required_fields + */ + public function test_get_required_fields_closed_includes_actual_attendees() { + $post_id = $this->create_wordcamp(); + + $fields = WordCamp_Admin::get_required_fields( 'closed', $post_id ); + + $this->assertContains( 'Actual Attendees', $fields ); + } + + /** + * @covers WordCamp_Admin::get_required_fields + */ + public function test_get_required_fields_closed_does_not_include_scheduled_fields() { + $post_id = $this->create_wordcamp(); + + $fields = WordCamp_Admin::get_required_fields( 'closed', $post_id ); + + $this->assertNotContains( 'Start Date (YYYY-mm-dd)', $fields ); + $this->assertNotContains( 'Location', $fields ); + } + + /** + * Closing a WordCamp should be blocked when Actual Attendees is not filled. + * + * @covers WordCamp_Admin::require_complete_meta_to_publish_wordcamp + */ + public function test_closing_blocked_without_actual_attendees() { + $post_id = $this->create_wordcamp( 'wcpt-scheduled' ); + + // Simulate no Actual Attendees in POST data. + $_POST = array(); + + $post_data = array( + 'post_type' => WCPT_POST_TYPE_ID, + 'post_status' => 'wcpt-closed', + ); + + $post_data_raw = array( + 'ID' => $post_id, + ); + + $result = self::$admin->require_complete_meta_to_publish_wordcamp( $post_data, $post_data_raw ); + + $this->assertNotEquals( 'wcpt-closed', $result['post_status'], 'Status should not be wcpt-closed when Actual Attendees is missing.' ); + $this->assertEquals( 'wcpt-scheduled', $result['post_status'], 'Status should revert to the previous status.' ); + } + + /** + * Closing a WordCamp should succeed when Actual Attendees is filled. + * + * @covers WordCamp_Admin::require_complete_meta_to_publish_wordcamp + */ + public function test_closing_allowed_with_actual_attendees() { + $post_id = $this->create_wordcamp( 'wcpt-scheduled' ); + + // Simulate Actual Attendees in POST data. + $_POST = array( + 'wcpt_actual_attendees' => '150', + ); + + $post_data = array( + 'post_type' => WCPT_POST_TYPE_ID, + 'post_status' => 'wcpt-closed', + ); + + $post_data_raw = array( + 'ID' => $post_id, + ); + + $result = self::$admin->require_complete_meta_to_publish_wordcamp( $post_data, $post_data_raw ); + + $this->assertEquals( 'wcpt-closed', $result['post_status'], 'Status should be wcpt-closed when Actual Attendees is provided.' ); + } + + /** + * Resaving an already-closed WordCamp should not require re-validation. + * + * @covers WordCamp_Admin::require_complete_meta_to_publish_wordcamp + */ + public function test_resaving_closed_wordcamp_allowed() { + $post_id = $this->create_wordcamp( 'wcpt-closed' ); + + // Simulate no Actual Attendees in POST data (e.g. field wasn't re-submitted). + $_POST = array(); + + $post_data = array( + 'post_type' => WCPT_POST_TYPE_ID, + 'post_status' => 'wcpt-closed', + ); + + $post_data_raw = array( + 'ID' => $post_id, + ); + + $result = self::$admin->require_complete_meta_to_publish_wordcamp( $post_data, $post_data_raw ); + + $this->assertEquals( 'wcpt-closed', $result['post_status'], 'Status should remain wcpt-closed when resaving an already-closed WordCamp.' ); + } + + /** + * Non-WordCamp post types should pass through without validation. + * + * @covers WordCamp_Admin::require_complete_meta_to_publish_wordcamp + */ + public function test_non_wordcamp_post_type_passes_through() { + $post_data = array( + 'post_type' => 'post', + 'post_status' => 'wcpt-closed', + ); + + $post_data_raw = array( + 'ID' => 1, + ); + + $result = self::$admin->require_complete_meta_to_publish_wordcamp( $post_data, $post_data_raw ); + + $this->assertEquals( $post_data, $result, 'Non-WordCamp post data should not be modified.' ); + } + + /** + * Actual Attendees should be protected before the event end date passes. + * + * @covers WordCamp_Admin::get_protected_fields + */ + public function test_actual_attendees_protected_before_end_date() { + $post_id = $this->create_wordcamp(); + + // Set end date to the future. + update_post_meta( $post_id, 'End Date (YYYY-mm-dd)', strtotime( '+30 days' ) ); + + // get_protected_fields() uses get_post() which relies on the global $post. + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Required to set context for get_protected_fields(). + $GLOBALS['post'] = get_post( $post_id ); + + $protected = WordCamp_Admin::get_protected_fields(); + + $this->assertContains( 'Actual Attendees', $protected, 'Actual Attendees should be protected before the event ends.' ); + + // Clean up. + unset( $GLOBALS['post'] ); + } + + /** + * Actual Attendees should not be protected after the event end date passes. + * + * @covers WordCamp_Admin::get_protected_fields + */ + public function test_actual_attendees_not_protected_after_end_date() { + $post_id = $this->create_wordcamp(); + + // Set end date to the past. + update_post_meta( $post_id, 'End Date (YYYY-mm-dd)', strtotime( '-30 days' ) ); + + // get_protected_fields() uses get_post() which relies on the global $post. + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Required to set context for get_protected_fields(). + $GLOBALS['post'] = get_post( $post_id ); + + $protected = WordCamp_Admin::get_protected_fields(); + + $this->assertNotContains( 'Actual Attendees', $protected, 'Actual Attendees should not be protected after the event ends.' ); + + // Clean up. + unset( $GLOBALS['post'] ); + } + + /** + * When no end date is set, start date should be used for protection. + * + * @covers WordCamp_Admin::get_protected_fields + */ + public function test_actual_attendees_uses_start_date_when_no_end_date() { + $post_id = $this->create_wordcamp(); + + // Set only start date in the future, no end date. + update_post_meta( $post_id, 'Start Date (YYYY-mm-dd)', strtotime( '+30 days' ) ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Required to set context for get_protected_fields(). + $GLOBALS['post'] = get_post( $post_id ); + + $protected = WordCamp_Admin::get_protected_fields(); + + $this->assertContains( 'Actual Attendees', $protected, 'Actual Attendees should be protected using start date when no end date is set.' ); + + // Clean up. + unset( $GLOBALS['post'] ); + } + + /** + * Closing should store missing fields in a transient. + * + * @covers WordCamp_Admin::require_complete_meta_to_publish_wordcamp + */ + public function test_closing_stores_missing_fields_transient() { + $post_id = $this->create_wordcamp( 'wcpt-scheduled' ); + + $_POST = array(); + + $post_data = array( + 'post_type' => WCPT_POST_TYPE_ID, + 'post_status' => 'wcpt-closed', + ); + + $post_data_raw = array( + 'ID' => $post_id, + ); + + self::$admin->require_complete_meta_to_publish_wordcamp( $post_data, $post_data_raw ); + + $transient = get_transient( 'wcpt_missing_fields_' . $post_id ); + + $this->assertNotEmpty( $transient, 'Missing fields transient should be set.' ); + $this->assertContains( 'Actual Attendees', $transient, 'Transient should contain Actual Attendees.' ); + } + + /** + * Clean up $_POST after each test. + */ + public function tear_down() { + $_POST = array(); + + parent::tear_down(); + } +} diff --git a/public_html/wp-content/plugins/wcpt/wcpt-wordcamp/wordcamp-admin.php b/public_html/wp-content/plugins/wcpt/wcpt-wordcamp/wordcamp-admin.php index 823010e2a..6703d562b 100644 --- a/public_html/wp-content/plugins/wcpt/wcpt-wordcamp/wordcamp-admin.php +++ b/public_html/wp-content/plugins/wcpt/wcpt-wordcamp/wordcamp-admin.php @@ -483,15 +483,6 @@ public static function meta_keys( $meta_group = '' ) { unset( $retval['Series Event'] ); } - /* - * The "Actual Attendees" field is only able to be set after the event is concluded. - * - * get_post() allows this to target the editor, allowing for report export. - */ - if ( get_post() && get_post_status() !== 'wcpt-closed' ) { - unset( $retval['Actual Attendees'] ); - } - break; case 'all': @@ -529,15 +520,6 @@ public static function meta_keys( $meta_group = '' ) { unset( $retval['Series Event'] ); } - /* - * The "Actual Attendees" field is only able to be set after the event is concluded. - * - * get_post() allows this to target the editor, allowing for report export. - */ - if ( get_post() && get_post_status() !== 'wcpt-closed' ) { - unset( $retval['Actual Attendees'] ); - } - $retval = array_merge( $retval, array( @@ -1075,45 +1057,117 @@ public function require_complete_meta_to_publish_wordcamp( $post_data, $post_dat // The ID of the last site that was created before this rule went into effect, so that we don't apply the rule retroactively. $min_site_id = apply_filters( 'wcpt_require_complete_meta_min_site_id', '2416297' ); - $required_needs_site_fields = $this->get_required_fields( 'needs-site', $post_data_raw['ID'] ); - $required_scheduled_fields = $this->get_required_fields( 'scheduled', $post_data_raw['ID'] ); - // Needs Site. if ( 'wcpt-needs-site' == $post_data['post_status'] && absint( $post_data_raw['ID'] ) > $min_site_id ) { - foreach ( $required_needs_site_fields as $field ) { + $post_data = $this->validate_required_fields_for_status( + $post_data, + $post_data_raw, + 'needs-site', + 'wcpt-needs-email', + 1 + ); + } - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce check would have done in `metabox_save`. - $value = $_POST[ wcpt_key_to_str( $field, 'wcpt_' ) ] ?? ''; + // Scheduled. + if ( 'wcpt-scheduled' == $post_data['post_status'] && isset( $post_data_raw['ID'] ) && absint( $post_data_raw['ID'] ) > $min_site_id ) { + $post_data = $this->validate_required_fields_for_status( + $post_data, + $post_data_raw, + 'scheduled', + 'wcpt-needs-schedule', + 3 + ); + } - if ( empty( $value ) || 'null' == $value ) { - $post_data['post_status'] = 'wcpt-needs-email'; - $this->active_admin_notices[] = 1; - break; - } + // Closed. + if ( 'wcpt-closed' == $post_data['post_status'] ) { + $post_data = $this->validate_closed_status( $post_data, $post_data_raw ); + } + + return $post_data; + } + + /** + * Validate required fields for a specific status transition + * + * @param array $post_data Sanitized post data. + * @param array $post_data_raw Raw post data. + * @param string $status_type Status type for get_required_fields() ('needs-site', 'scheduled', 'closed'). + * @param string $fallback_status Status to revert to if validation fails. + * @param int $notice_id Admin notice ID to trigger. + * + * @return array Modified post data. + */ + private function validate_required_fields_for_status( $post_data, $post_data_raw, $status_type, $fallback_status, $notice_id ) { + $required_fields = $this->get_required_fields( $status_type, $post_data_raw['ID'] ); + + foreach ( $required_fields as $field ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce check would have been done in `metabox_save`. + $value = isset( $_POST[ wcpt_key_to_str( $field, 'wcpt_' ) ] ) ? sanitize_text_field( wp_unslash( $_POST[ wcpt_key_to_str( $field, 'wcpt_' ) ] ) ) : ''; + + if ( empty( $value ) || 'null' === $value ) { + $post_data['post_status'] = $fallback_status; + $this->active_admin_notices[] = $notice_id; + break; } } - // Scheduled. - if ( 'wcpt-scheduled' == $post_data['post_status'] && isset( $post_data_raw['ID'] ) && absint( $post_data_raw['ID'] ) > $min_site_id ) { - foreach ( $required_scheduled_fields as $field ) { - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce check would have done in `metabox_save`. - $value = $_POST[ wcpt_key_to_str( $field, 'wcpt_' ) ] ?? ''; - - if ( empty( $value ) || 'null' == $value ) { - $post_data['post_status'] = 'wcpt-needs-schedule'; - $this->active_admin_notices[] = 3; - break; - } + return $post_data; + } + + /** + * Validate closed status transition with additional End Date check + * + * @param array $post_data Sanitized post data. + * @param array $post_data_raw Raw post data. + * + * @return array Modified post data. + */ + private function validate_closed_status( $post_data, $post_data_raw ) { + if ( empty( $post_data_raw['ID'] ) ) { + return $post_data; + } + + $post = get_post( $post_data_raw['ID'] ); + if ( ! $post ) { + return $post_data; + } + + // Get the old status to check if this is an actual transition. + $old_status = $post->post_status; + if ( 'wcpt-closed' === $old_status ) { + // Already closed, allow saving other changes. + return $post_data; + } + + $required_closed_fields = $this->get_required_fields( 'closed', $post_data_raw['ID'] ); + $missing_fields = array(); + + foreach ( $required_closed_fields as $field ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce check would have been done in `metabox_save`. + $value = isset( $_POST[ wcpt_key_to_str( $field, 'wcpt_' ) ] ) ? sanitize_text_field( wp_unslash( $_POST[ wcpt_key_to_str( $field, 'wcpt_' ) ] ) ) : ''; + + if ( empty( $value ) || 'null' === $value ) { + $missing_fields[] = $field; } } + // If there are validation errors, prevent the status change. + if ( ! empty( $missing_fields ) ) { + $post_data['post_status'] = $old_status; + $this->active_admin_notices[] = 5; + + // Store missing fields for the error message. + set_transient( 'wcpt_missing_fields_' . $post_data_raw['ID'], $missing_fields, 60 ); + } + return $post_data; } /** * Get a list of fields required to move to a certain post status * - * @param string $status 'needs-site' | 'scheduled' | 'any'. + * @param string $status 'needs-site' | 'scheduled' | 'closed' | 'any'. * * @return array */ @@ -1147,6 +1201,10 @@ public static function get_required_fields( $status, $post_id ) { // Required because the Events Widget needs a physical address in order to show events. $scheduled[] = self::get_address_key( $post_id ); + $closed = array( + 'Actual Attendees', + ); + switch ( $status ) { case 'needs-site': $required_fields = $needs_site; @@ -1156,6 +1214,10 @@ public static function get_required_fields( $status, $post_id ) { $required_fields = $scheduled; break; + case 'closed': + $required_fields = $closed; + break; + case 'any': default: $required_fields = array_merge( $needs_site, $scheduled ); @@ -1226,6 +1288,25 @@ public static function get_protected_fields() { ); } + // Protect "Actual Attendees" field until the event has ended. + $post = get_post(); + if ( $post && WCPT_POST_TYPE_ID === $post->post_type ) { + $end_date = get_post_meta( $post->ID, 'End Date (YYYY-mm-dd)', true ); + + // If no end date is set, use the start date. + if ( empty( $end_date ) ) { + $end_date = get_post_meta( $post->ID, 'Start Date (YYYY-mm-dd)', true ); + } + + // If we have an end date and it hasn't passed yet, protect the field. + if ( ! empty( $end_date ) ) { + $end_date_at_midnight = strtotime( '23:59:59', $end_date ); + if ( $end_date_at_midnight > time() ) { + $protected_fields[] = 'Actual Attendees'; + } + } + } + return $protected_fields; } @@ -1286,10 +1367,37 @@ public function get_admin_notices() { self::get_address_key( $post->ID ) ), ), + + 5 => array( + 'type' => 'error', + 'notice' => $this->get_close_validation_notice( $post->ID ), + ), ); } + /** + * Get the validation notice for closing a WordCamp + * + * @param int $post_id The post ID. + * + * @return string + */ + private function get_close_validation_notice( $post_id ) { + $missing_fields = get_transient( 'wcpt_missing_fields_' . $post_id ); + + if ( ! empty( $missing_fields ) ) { + delete_transient( 'wcpt_missing_fields_' . $post_id ); + + return sprintf( + __( 'This WordCamp cannot be closed. The following required fields must be filled in: %s.', 'wordcamporg' ), + implode( ', ', $missing_fields ) + ); + } + + return __( 'This WordCamp cannot be closed at this time.', 'wordcamporg' ); + } + /** * Check if the post has geolocation data. * @@ -1654,6 +1762,12 @@ function wcpt_metabox( $meta_keys, $metabox ) { global $post_id; $required_fields = WordCamp_Admin::get_required_fields( 'any', $post_id ); + $protected_fields = WordCamp_Admin::get_protected_fields(); + + // Add "Actual Attendees" to required fields when it's not protected (editable). + if ( ! in_array( 'Actual Attendees', $protected_fields, true ) && isset( $meta_keys['Actual Attendees'] ) ) { + $required_fields[] = 'Actual Attendees'; + } // @todo When you refactor meta_keys() to support changing labels -- see note in meta_keys() -- also make it support these notes. $messages = array( @@ -1669,6 +1783,39 @@ function wcpt_metabox( $meta_keys, $metabox ) { 'Series Event' => '(Campus Connect only) Event is part of a multi-venue or multi-session series (e.g., workshops held across several campuses)', ); + // Update the Actual Attendees field message based on whether it's protected and CampTix data. + $post = get_post( $post_id ); + if ( $post && isset( $meta_keys['Actual Attendees'] ) ) { + $is_protected = in_array( 'Actual Attendees', $protected_fields, true ); + + if ( $is_protected ) { + // Field is readonly - show message that it can't be set until after event concludes. + $messages['Actual Attendees'] = 'This field cannot be set until after the event concludes.'; + } + + // Add CampTix ticket sales information regardless of protected status. + $site_id = get_wordcamp_site_id( $post ); + if ( $site_id ) { + $camptix_stats = get_blog_option( $site_id, 'camptix_stats', array() ); + $attendees_attended = $camptix_stats['attended'] ?? 0; + $tickets_sold = $camptix_stats['sold'] ?? 0; + + if ( $attendees_attended > 0 || $tickets_sold > 0 ) { + $camptix_info = sprintf( + 'CampTix: %d attended, %d sold.', + $attendees_attended, + $tickets_sold + ); + + if ( $is_protected ) { + $messages['Actual Attendees'] .= ' ' . $camptix_info; + } else { + $messages['Actual Attendees'] = 'Number of attendees who actually attended the event. ' . $camptix_info; + } + } + } + } + if ( 'wcpt_venue_info' === $metabox ) { $address_instructions = 'Please include the city, state/province and country.';