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.';