diff --git a/includes/event/event-capabilities.php b/includes/event/event-capabilities.php index 1a5e1adf..887bf6a4 100644 --- a/includes/event/event-capabilities.php +++ b/includes/event/event-capabilities.php @@ -5,18 +5,25 @@ use Exception; use GP; use WP_User; +use DateTimeImmutable; +use DateTimeZone; use Wporg\TranslationEvents\Attendee\Attendee; use Wporg\TranslationEvents\Attendee\Attendee_Repository; use Wporg\TranslationEvents\Stats\Stats_Calculator; class Event_Capabilities { - private const MANAGE = 'manage_translation_events'; - private const CREATE = 'create_translation_event'; - private const VIEW = 'view_translation_event'; - private const EDIT = 'edit_translation_event'; - private const TRASH = 'trash_translation_event'; - private const DELETE = 'delete_translation_event'; - private const EDIT_ATTENDEES = 'edit_translation_event_attendees'; + private const MANAGE = 'manage_translation_events'; + private const CREATE = 'create_translation_event'; + private const VIEW = 'view_translation_event'; + private const EDIT = 'edit_translation_event'; + private const TRASH = 'trash_translation_event'; + private const DELETE = 'delete_translation_event'; + private const EDIT_ATTENDEES = 'edit_translation_event_attendees'; + private const EDIT_TITLE = 'edit_translation_event_title'; + private const EDIT_DESCRIPTION = 'edit_translation_event_description'; + private const EDIT_START = 'edit_translation_event_start'; + private const EDIT_END = 'edit_translation_event_end'; + private const EDIT_TIMEZONE = 'edit_translation_event_timezone'; /** * All the capabilities that concern Events. @@ -29,6 +36,11 @@ class Event_Capabilities { self::TRASH, self::DELETE, self::EDIT_ATTENDEES, + self::EDIT_TITLE, + self::EDIT_DESCRIPTION, + self::EDIT_START, + self::EDIT_END, + self::EDIT_TIMEZONE, ); private Event_Repository_Interface $event_repository; @@ -64,6 +76,11 @@ private function has_cap( string $cap, array $args, WP_User $user ): bool { case self::TRASH: case self::DELETE: case self::EDIT_ATTENDEES: + case self::EDIT_TITLE: + case self::EDIT_DESCRIPTION: + case self::EDIT_START: + case self::EDIT_END: + case self::EDIT_TIMEZONE: if ( ! isset( $args[2] ) || ! is_numeric( $args[2] ) ) { return false; } @@ -87,6 +104,9 @@ private function has_cap( string $cap, array $args, WP_User $user ): bool { if ( self::EDIT_ATTENDEES === $cap ) { return $this->has_edit_attendees( $user, $event ); } + if ( self::EDIT_TITLE === $cap || self::EDIT_DESCRIPTION === $cap || self::EDIT_START === $cap || self::EDIT_END === $cap || self::EDIT_TIMEZONE === $cap ) { + return $this->has_edit_field( $user, $event, $cap ); + } break; } @@ -136,14 +156,6 @@ private function has_view( WP_User $user, Event $event ): bool { * @return bool */ private function has_edit( WP_User $user, Event $event ): bool { - if ( $event->end()->is_in_the_past() ) { - return false; - } - - if ( $this->stats_calculator->event_has_stats( $event->id() ) ) { - return false; - } - if ( $event->author_id() === $user->ID ) { return true; } @@ -240,6 +252,43 @@ private function has_edit_attendees( WP_User $user, Event $event ): bool { return false; } + /** + * Evaluate whether a user can edit event title for a specific event. + * + * @param WP_User $user User for which we're evaluating the capability. + * @param Event $event Event for which we're evaluating the capability. + * @return bool + */ + private function has_edit_field( WP_User $user, Event $event, $cap ): bool { + $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); + $event_end_plus_1_hr = $event->end()->modify( '+1 hour' ); + + if ( self::EDIT_DESCRIPTION === $cap ) { + return true; + } + + if ( $event->start() > $now ) { + return true; + } + + if ( $event->is_active() && ! $this->stats_calculator->event_has_stats( $event->id() ) ) { + return true; + } + + if ( $event->is_active() && $this->stats_calculator->event_has_stats( $event->id() ) ) { + return ( self::EDIT_TITLE === $cap || self::EDIT_END === $cap ); + } + + if ( $event->end()->is_in_the_past() && $now < $event_end_plus_1_hr ) { + return ( self::EDIT_TITLE === $cap || self::EDIT_END === $cap ); + } + if ( $event->end()->is_in_the_past() && $now > $event_end_plus_1_hr ) { + return ( self::EDIT_DESCRIPTION === $cap ); + } + + return false; + } + public function register_hooks(): void { add_action( 'user_has_cap', diff --git a/includes/event/event-form-handler.php b/includes/event/event-form-handler.php index 583f76a3..7ac8bd6b 100644 --- a/includes/event/event-form-handler.php +++ b/includes/event/event-form-handler.php @@ -29,7 +29,8 @@ public function handle( array $form_data ): void { wp_send_json_error( esc_html__( 'Invalid form name.', 'gp-translation-events' ), 403 ); } - $event_id = isset( $form_data['event_id'] ) ? sanitize_text_field( wp_unslash( $form_data['event_id'] ) ) : 0; + $event_id = isset( $form_data['event_id'] ) ? intval( sanitize_text_field( wp_unslash( $form_data['event_id'] ) ) ) : 0; + $event = null; if ( 'create_event' === $action && ( ! current_user_can( 'create_translation_event' ) ) ) { wp_send_json_error( esc_html__( 'You do not have permissions to create events.', 'gp-translation-events' ), 403 ); @@ -54,10 +55,12 @@ public function handle( array $form_data ): void { } $response_message = ''; + if ( $event_id ) { + $event = $this->event_repository->get_event( $event_id ); + } + if ( 'trash_event' === $action ) { // Trash event. - $event_id = intval( sanitize_text_field( wp_unslash( $form_data['event_id'] ) ) ); - $event = $this->event_repository->get_event( $event_id ); if ( ! $event ) { wp_send_json_error( esc_html__( 'Event does not exist.', 'gp-translation-events' ), 404 ); } @@ -84,6 +87,9 @@ public function handle( array $form_data ): void { // Create or update event. try { + if ( 'edit_event' === $action && $event ) { + $form_data['event_timezone'] = $event->timezone()->getName(); + } $new_event = $this->parse_form_data( $form_data ); } catch ( InvalidTimeZone $e ) { wp_send_json_error( esc_html__( 'Invalid time zone.', 'gp-translation-events' ), 422 ); @@ -104,11 +110,6 @@ public function handle( array $form_data ): void { return; } - if ( $new_event->end() < new DateTime( 'now', new DateTimeZone( 'UTC' ) ) ) { - wp_send_json_error( esc_html__( 'Past events cannot be created or edited.', 'gp-translation-events' ), 422 ); - return; - } - // This is a list of slugs that are not allowed, as they conflict with the event URLs. $invalid_slugs = array( 'new', 'edit', 'attend', 'my-events' ); if ( in_array( sanitize_title( $new_event->title() ), $invalid_slugs, true ) ) { @@ -132,10 +133,24 @@ public function handle( array $form_data ): void { try { $event->set_status( $new_event->status() ); - $event->set_title( $new_event->title() ); - $event->set_description( $new_event->description() ); - $event->set_timezone( $new_event->timezone() ); - $event->set_times( $new_event->start(), $new_event->end() ); + if ( current_user_can( 'edit_translation_event_title', $event->id() ) ) { + $event->set_title( $new_event->title() ); + } + if ( current_user_can( 'edit_translation_event_description', $event->id() ) ) { + $event->set_description( $new_event->description() ); + } + if ( current_user_can( 'edit_translation_event_timezone', $event->id() ) ) { + $event->set_timezone( $new_event->timezone() ); + } + + $event->validate_times( $new_event->start(), $new_event->end() ); + + if ( current_user_can( 'edit_translation_event_start', $event->id() ) ) { + $event->set_start( $new_event->start() ); + } + if ( current_user_can( 'edit_translation_event_end', $event->id() ) ) { + $event->set_end( $new_event->end() ); + } } catch ( Exception $e ) { wp_send_json_error( esc_html__( 'Failed to update event.', 'gp-translation-events' ), 422 ); return; diff --git a/includes/event/event.php b/includes/event/event.php index 719e3876..be7e5c8b 100644 --- a/includes/event/event.php +++ b/includes/event/event.php @@ -57,7 +57,9 @@ public function __construct( string $description ) { $this->author_id = $author_id; - $this->set_times( $start, $end ); + $this->validate_times( $start, $end ); + $this->set_start( $start ); + $this->set_end( $end ); $this->set_timezone( $timezone ); $this->set_status( $status ); $this->set_title( $title ); @@ -129,13 +131,12 @@ public function set_slug( string $slug ): void { $this->slug = $slug; } - /** - * @throws InvalidStart|InvalidEnd - */ - public function set_times( Event_Start_Date $start, Event_End_Date $end ): void { - $this->validate_times( $start, $end ); + public function set_start( Event_Start_Date $start ): void { $this->start = $start; - $this->end = $end; + } + + public function set_end( Event_End_Date $end ): void { + $this->end = $end; } public function set_timezone( DateTimeZone $timezone ): void { @@ -164,7 +165,7 @@ public function set_description( string $description ): void { * @throws InvalidStart * @throws InvalidEnd */ - private function validate_times( Event_Start_Date $start, Event_End_Date $end ) { + public function validate_times( Event_Start_Date $start, Event_End_Date $end ) { if ( $end <= $start ) { throw new InvalidEnd(); } diff --git a/phpcs.xml b/phpcs.xml index ba460e9c..97b61362 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -13,7 +13,7 @@ - + diff --git a/templates/events-form.php b/templates/events-form.php index 014465c0..275c8bf9 100644 --- a/templates/events-form.php +++ b/templates/events-form.php @@ -36,7 +36,7 @@
- + id() ) ?: 'readonly' ); ?> required size="42">
id() ); ?> @@ -46,7 +46,7 @@
- +
- + id() ) ?: 'readonly' ); ?> >
- + id() ) ?: 'readonly' ); ?>>
- id() ) ?: 'disabled' ); ?> > timezone()->getName(), get_user_locale() ), diff --git a/tests/event/event-capabilities.php b/tests/event/event-capabilities.php index 64845ac0..ee4ea130 100644 --- a/tests/event/event-capabilities.php +++ b/tests/event/event-capabilities.php @@ -109,13 +109,18 @@ public function test_gp_admin_can_edit() { $this->assertTrue( user_can( $non_author_user_id, 'edit_translation_event', $event_id ) ); } - public function test_cannot_edit_past_event() { + public function test_can_edit_past_event() { + $this->set_normal_user_as_current(); + $event_id = $this->event_factory->create_inactive_past( $this->now ); - $this->assertFalse( current_user_can( 'edit_translation_event', $event_id ) ); + + $this->assertTrue( current_user_can( 'edit_translation_event', $event_id ) ); } - public function test_cannot_edit_event_with_stats() { - $author_user_id = get_current_user_id(); + public function test_can_edit_event_with_stats() { + $this->set_normal_user_as_current(); + $author_user_id = get_current_user_id(); + $event_id = $this->event_factory->create_active( $this->now ); $translation_set = $this->factory->translation_set->create_with_project_and_locale(); $original = $this->factory->original->create( array( 'project_id' => $translation_set->project_id ) ); @@ -127,7 +132,7 @@ public function test_cannot_edit_event_with_stats() { ) ); $this->stats_factory->create( $event_id, $author_user_id, $original->id, 'create', $translation_set->locale ); - $this->assertFalse( current_user_can( 'edit_translation_event', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event', $event_id ) ); } public function test_cannot_trash_event_with_stats() { @@ -222,4 +227,89 @@ public function test_gp_admin_can_delete() { add_filter( 'gp_translation_events_can_crud_event', '__return_true' ); $this->assertTrue( current_user_can( 'delete_translation_event', $event_id ) ); } + + public function test_editable_fields_before_event_start() { + $this->set_normal_user_as_current(); + + $event_id = $this->event_factory->create_inactive_future( $this->now ); + + $this->assertTrue( current_user_can( 'edit_translation_event_title', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_description', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_start', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_end', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_timezone', $event_id ) ); + } + + public function test_editable_fields_after_event_start_no_stats() { + $this->set_normal_user_as_current(); + + $event_id = $this->event_factory->create_active( $this->now ); + + $this->assertTrue( current_user_can( 'edit_translation_event_title', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_description', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_start', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_end', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_timezone', $event_id ) ); + } + + public function test_editable_fields_after_event_start_with_stats() { + $this->set_normal_user_as_current(); + $author_user_id = get_current_user_id(); + + $event_id = $this->event_factory->create_active( $this->now ); + $translation_set = $this->factory->translation_set->create_with_project_and_locale(); + $original = $this->factory->original->create( array( 'project_id' => $translation_set->project_id ) ); + $this->factory->translation->create( + array( + 'original_id' => $original->id, + 'translation_set_id' => $translation_set->id, + 'status' => 'current', + ) + ); + $this->stats_factory->create( $event_id, $author_user_id, $original->id, 'create', $translation_set->locale ); + + $this->assertTrue( current_user_can( 'edit_translation_event_title', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_description', $event_id ) ); + $this->assertFalse( current_user_can( 'edit_translation_event_start', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_end', $event_id ) ); + $this->assertFalse( current_user_can( 'edit_translation_event_timezone', $event_id ) ); + } + + public function test_editable_fields_within_one_hour_after_event_ends() { + $this->set_normal_user_as_current(); + + $timezone = new DateTimeZone( 'Europe/Lisbon' ); + $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); + $event_id = $this->event_factory->create_event( + $now->modify( '-2 hours' ), + $now->modify( '-59 minutes' ), + $timezone, + array(), + ); + + $this->assertTrue( current_user_can( 'edit_translation_event_title', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_description', $event_id ) ); + $this->assertFalse( current_user_can( 'edit_translation_event_start', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_end', $event_id ) ); + $this->assertFalse( current_user_can( 'edit_translation_event_timezone', $event_id ) ); + } + + public function test_editable_fields_more_than_one_hour_after_event_ends() { + $this->set_normal_user_as_current(); + + $timezone = new DateTimeZone( 'Europe/Lisbon' ); + $now = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ); + $event_id = $this->event_factory->create_event( + $now->modify( '-3 hours' ), + $now->modify( '-1 hours -1 minutes' ), + $timezone, + array(), + ); + + $this->assertFalse( current_user_can( 'edit_translation_event_title', $event_id ) ); + $this->assertTrue( current_user_can( 'edit_translation_event_description', $event_id ) ); + $this->assertFalse( current_user_can( 'edit_translation_event_start', $event_id ) ); + $this->assertFalse( current_user_can( 'edit_translation_event_end', $event_id ) ); + $this->assertFalse( current_user_can( 'edit_translation_event_timezone', $event_id ) ); + } } diff --git a/tests/event/event-repository.php b/tests/event/event-repository.php index 64e29066..e04e19f9 100644 --- a/tests/event/event-repository.php +++ b/tests/event/event-repository.php @@ -104,7 +104,8 @@ public function test_update_event() { $event = $this->repository->get_event( $event_id ); // phpcs:disable Squiz.PHP.DisallowMultipleAssignments.Found - $event->set_times( $updated_start = ( new Event_Start_Date( 'now' ) )->modify( '+1 days' ), $updated_end = ( new Event_End_Date( 'now' ) )->modify( '+2 days' ) ); + $event->set_start( $updated_start = ( new Event_Start_Date( 'now' ) )->modify( '+1 days' ) ); + $event->set_end( $updated_end = ( new Event_End_Date( 'now' ) )->modify( '+2 days' ) ); $event->set_timezone( $updated_timezone = new DateTimeZone( 'Europe/Madrid' ) ); $event->set_status( $updated_status = 'draft' ); $event->set_title( $updated_title = 'Updated title' );