From ad6741195037a7eaf0c3918fa791cd56804a9612 Mon Sep 17 00:00:00 2001 From: protitude Date: Thu, 18 Jun 2026 21:04:28 -0600 Subject: [PATCH 1/8] access_outages: d8-2763 fix phpcs/phpstan --- modules/access_outages/access_outages.info.yml | 3 +-- modules/access_outages/access_outages.module | 15 +++++++++++---- .../src/Plugin/Block/OutagesBlock.php | 5 ++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/modules/access_outages/access_outages.info.yml b/modules/access_outages/access_outages.info.yml index 17451e90..593600c6 100644 --- a/modules/access_outages/access_outages.info.yml +++ b/modules/access_outages/access_outages.info.yml @@ -3,9 +3,8 @@ description: ACCESS Outages module package: Custom type: module core_version_requirement: ^10 || ^11 -version: 1.0 dependencies: - - access + - access:access libraries: - access_outages/outages_library diff --git a/modules/access_outages/access_outages.module b/modules/access_outages/access_outages.module index 65a9fd6a..8c3f9681 100644 --- a/modules/access_outages/access_outages.module +++ b/modules/access_outages/access_outages.module @@ -2,12 +2,15 @@ /** * @file + * Hooks and preprocess functions for the ACCESS Outages module. */ /** * Implements hook_theme(). + * + * @phpstan-return array */ -function access_outages_theme() { +function access_outages_theme(): array { return [ 'outages_block' => [ 'variables' => [ @@ -18,10 +21,14 @@ function access_outages_theme() { } /** - * Help display affinity group outages by passing cider resource ids to - * to the access_outages library + * Implements hook_preprocess_HOOK() for layout. + * + * Helps display affinity group outages by passing cider resource ids to the + * access_outages library. + * + * @phpstan-param array $vars */ -function access_outages_preprocess_layout(&$vars) { +function access_outages_preprocess_layout(array &$vars): void { $entity = $vars['content']['#entity']; if ($entity->bundle() == 'affinity_group') { $node_storage = \Drupal::entityTypeManager()->getStorage('node'); diff --git a/modules/access_outages/src/Plugin/Block/OutagesBlock.php b/modules/access_outages/src/Plugin/Block/OutagesBlock.php index 2dea09ae..7d2af777 100644 --- a/modules/access_outages/src/Plugin/Block/OutagesBlock.php +++ b/modules/access_outages/src/Plugin/Block/OutagesBlock.php @@ -17,8 +17,11 @@ class OutagesBlock extends BlockBase { /** * {@inheritdoc} + * + * @return array + * The block render array. */ - public function build() { + public function build(): array { return [ '#theme' => 'outages_block', '#data' => [], From 29c1b934f442bdd16a9f82e17060808a20e25a6a Mon Sep 17 00:00:00 2001 From: protitude Date: Thu, 18 Jun 2026 21:05:19 -0600 Subject: [PATCH 2/8] access_entity_copy: d8-2763 fix phpcs/phpstan --- .../access_entity_copy/access_entity_copy.info.yml | 3 +-- .../access_entity_copy/access_entity_copy.module | 13 +++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/modules/access_entity_copy/access_entity_copy.info.yml b/modules/access_entity_copy/access_entity_copy.info.yml index def34a40..24f9126c 100644 --- a/modules/access_entity_copy/access_entity_copy.info.yml +++ b/modules/access_entity_copy/access_entity_copy.info.yml @@ -4,5 +4,4 @@ type: module core_version_requirement: ^10 || ^11 package: Custom dependencies: - - access - + - access:access diff --git a/modules/access_entity_copy/access_entity_copy.module b/modules/access_entity_copy/access_entity_copy.module index 80d60024..7d255f00 100644 --- a/modules/access_entity_copy/access_entity_copy.module +++ b/modules/access_entity_copy/access_entity_copy.module @@ -1,16 +1,18 @@ get('title')->value; $form['field_me_looking_for']['widget']['#default_value'] = $entity->get('field_me_looking_for')->value; $form['field_me_preferred_attributes']['widget'][0]['#default_value'] = $entity->get('field_me_preferred_attributes')->value; - $form['body']['widget'][0]['summary']['#default_value'] = $entity->get('body')->summary; + $form['body']['widget'][0]['summary']['#default_value'] = $entity->get('body')->getValue()[0]['summary'] ?? NULL; $form['body']['widget'][0]['#default_value'] = $entity->get('body')->value; $form['field_mentorship_program']['widget']['#default_value'] = $entity->get('field_mentorship_program')->target_id; } - From 04308a6abe611ee635f445905e342492bd756ba5 Mon Sep 17 00:00:00 2001 From: protitude Date: Thu, 18 Jun 2026 21:15:18 -0600 Subject: [PATCH 3/8] access_shortcodes: d8-2763 fix phpcs/phpstan --- modules/access_shortcodes/access_shortcodes.info.yml | 2 +- modules/access_shortcodes/access_shortcodes.module | 6 +++++- .../src/Plugin/Shortcode/AccordionShortcode.php | 7 +++++++ .../src/Plugin/Shortcode/CTABlockShortcode.php | 7 +++++++ .../src/Plugin/Shortcode/IconBlockShortcode.php | 9 ++++++++- .../src/Plugin/Shortcode/SquareShortcode.php | 7 +++++++ 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/modules/access_shortcodes/access_shortcodes.info.yml b/modules/access_shortcodes/access_shortcodes.info.yml index 3dc06341..b244fec9 100644 --- a/modules/access_shortcodes/access_shortcodes.info.yml +++ b/modules/access_shortcodes/access_shortcodes.info.yml @@ -4,4 +4,4 @@ type: module core_version_requirement: ^10 || ^11 package: Custom dependencies: - - access + - access:access diff --git a/modules/access_shortcodes/access_shortcodes.module b/modules/access_shortcodes/access_shortcodes.module index 247ebaa7..53860a39 100644 --- a/modules/access_shortcodes/access_shortcodes.module +++ b/modules/access_shortcodes/access_shortcodes.module @@ -2,12 +2,16 @@ /** * @file + * Hooks and theme definitions for the ACCESS Shortcodes module. */ /** * Define our variables (parameters) for each shortcode. + * + * @return array + * The theme definitions. */ -function access_shortcodes_theme() { +function access_shortcodes_theme(): array { return [ 'shortcode_icon_block' => [ 'variables' => [ diff --git a/modules/access_shortcodes/src/Plugin/Shortcode/AccordionShortcode.php b/modules/access_shortcodes/src/Plugin/Shortcode/AccordionShortcode.php index e4dda8ed..e4ae4f51 100644 --- a/modules/access_shortcodes/src/Plugin/Shortcode/AccordionShortcode.php +++ b/modules/access_shortcodes/src/Plugin/Shortcode/AccordionShortcode.php @@ -18,6 +18,13 @@ class AccordionShortcode extends ShortcodeBase { /** * {@inheritdoc} + * + * @param array $attributes + * The shortcode attributes. + * @param string $text + * The text between the shortcode tags. + * @param string $langcode + * The language code. */ public function process(array $attributes, $text, $langcode = Language::LANGCODE_NOT_SPECIFIED) { $attributes = $this->getAttributes([ diff --git a/modules/access_shortcodes/src/Plugin/Shortcode/CTABlockShortcode.php b/modules/access_shortcodes/src/Plugin/Shortcode/CTABlockShortcode.php index 361984e7..b341a064 100644 --- a/modules/access_shortcodes/src/Plugin/Shortcode/CTABlockShortcode.php +++ b/modules/access_shortcodes/src/Plugin/Shortcode/CTABlockShortcode.php @@ -18,6 +18,13 @@ class CTABlockShortcode extends ShortcodeBase { /** * {@inheritdoc} + * + * @param array $attributes + * The shortcode attributes. + * @param string $text + * The text between the shortcode tags. + * @param string $langcode + * The language code. */ public function process(array $attributes, $text, $langcode = Language::LANGCODE_NOT_SPECIFIED) { $attributes = $this->getAttributes([ diff --git a/modules/access_shortcodes/src/Plugin/Shortcode/IconBlockShortcode.php b/modules/access_shortcodes/src/Plugin/Shortcode/IconBlockShortcode.php index e7330118..48a4baeb 100644 --- a/modules/access_shortcodes/src/Plugin/Shortcode/IconBlockShortcode.php +++ b/modules/access_shortcodes/src/Plugin/Shortcode/IconBlockShortcode.php @@ -18,6 +18,13 @@ class IconBlockShortcode extends ShortcodeBase { /** * {@inheritdoc} + * + * @param array $attributes + * The shortcode attributes. + * @param string $text + * The text between the shortcode tags. + * @param string $langcode + * The language code. */ public function process(array $attributes, $text, $langcode = Language::LANGCODE_NOT_SPECIFIED) { $attributes = $this->getAttributes([ @@ -62,7 +69,7 @@ public function process(array $attributes, $text, $langcode = Language::LANGCODE */ public function tips($long = FALSE) { $output = []; - $output[] = '

' . $this->t('[icon_box img="image url" alt="alt text for image" title="Your title here" text="Your text here" link="Link for icon box" boxed="boxed" btnLink="https://example.com" btnText="Optional button Link"][/icon_box] ') . ' '; + $output[] = '

' . $this->t('[icon_box img="image url" alt="alt text for image" title="Your title here" text="Your text here" link="Link for icon box" boxed="boxed" btnLink="https://example.com" btnText="Optional button Link"][/icon_box]') . ' '; if ($long) { $output[] = $this->t('Builds an icon box with the image that you specify and the title text.') . '

'; } diff --git a/modules/access_shortcodes/src/Plugin/Shortcode/SquareShortcode.php b/modules/access_shortcodes/src/Plugin/Shortcode/SquareShortcode.php index a2f0f235..c677704b 100644 --- a/modules/access_shortcodes/src/Plugin/Shortcode/SquareShortcode.php +++ b/modules/access_shortcodes/src/Plugin/Shortcode/SquareShortcode.php @@ -18,6 +18,13 @@ class SquareShortcode extends ShortcodeBase { /** * {@inheritdoc} + * + * @param array $attributes + * The shortcode attributes. + * @param string $text + * The text between the shortcode tags. + * @param string $langcode + * The language code. */ public function process(array $attributes, $text, $langcode = Language::LANGCODE_NOT_SPECIFIED) { $attributes = $this->getAttributes([ From cecb0bb8ad213787bba4e1a706b63ab3bc0a281f Mon Sep 17 00:00:00 2001 From: protitude Date: Thu, 18 Jun 2026 21:19:48 -0600 Subject: [PATCH 4/8] ondemand: d8-2763 fix phpcs/phpstan --- modules/ondemand/ondemand.info.yml | 2 +- modules/ondemand/ondemand.module | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/modules/ondemand/ondemand.info.yml b/modules/ondemand/ondemand.info.yml index 39045c69..41ed1ca0 100644 --- a/modules/ondemand/ondemand.info.yml +++ b/modules/ondemand/ondemand.info.yml @@ -4,4 +4,4 @@ type: module core_version_requirement: ^10 || ^11 package: Custom dependencies: - - access + - access:access diff --git a/modules/ondemand/ondemand.module b/modules/ondemand/ondemand.module index 8657f9aa..ab4cd3dd 100644 --- a/modules/ondemand/ondemand.module +++ b/modules/ondemand/ondemand.module @@ -6,14 +6,16 @@ */ use Drupal\Component\Utility\Html; +use Drupal\search_api\Plugin\views\query\SearchApiQuery; use Drupal\user\Entity\User; +use Drupal\user\UserInterface; use Drupal\views\ViewExecutable; use Drupal\views\Plugin\views\query\QueryPluginBase; /** - * + * Implements hook_user_login(). */ -function ondemand_user_login($account) { +function ondemand_user_login(UserInterface $account): void { $current_domain_name = \Drupal::service('access_misc.sitetools')->getDomain(); if ($current_domain_name == 'open-ondemand' || $current_domain_name == 'pa-science') { @@ -56,16 +58,17 @@ function ondemand_user_login($account) { } /** - * Filters for People SearchAPI view. - * - * @param \Drupal\views\ViewExecutable $view - * @param \Drupal\views\Plugin\views\query\QueryPluginBase $query + * Implements hook_views_query_alter(). * - * @return void + * Filters for People SearchAPI view. */ -function ondemand_views_query_alter(ViewExecutable $view, QueryPluginBase $query) { +function ondemand_views_query_alter(ViewExecutable $view, QueryPluginBase $query): void { if ($view->id() === 'cyberteam_people_facets' && $view->current_display === 'page_1') { + // The People view is powered by Search API. + if (!$query instanceof SearchApiQuery) { + return; + } $token = \Drupal::token(); $domain_name = Html::getClass($token->replace(t('[domain:name]'))); From e239f34d9f9aa6236ce10ec5b6d29dc1c4d2e0fd Mon Sep 17 00:00:00 2001 From: protitude Date: Thu, 18 Jun 2026 21:24:31 -0600 Subject: [PATCH 5/8] access_match_engagement: d8-2763 fix phpcs/phpstan --- .../access_match_engagement.info.yml | 2 +- .../access_match_engagement.install | 16 +- .../access_match_engagement.module | 223 +++++++++++------- .../src/Controller/MatchController.php | 150 +++++++++++- .../src/Plugin/Block/MatchNodeBlock.php | 56 +++-- 5 files changed, 325 insertions(+), 122 deletions(-) diff --git a/modules/access_match_engagement/access_match_engagement.info.yml b/modules/access_match_engagement/access_match_engagement.info.yml index 6197e8f7..d5fd9c35 100644 --- a/modules/access_match_engagement/access_match_engagement.info.yml +++ b/modules/access_match_engagement/access_match_engagement.info.yml @@ -4,4 +4,4 @@ type: module core_version_requirement: ^10 || ^11 package: Custom dependencies: - - access + - access:access diff --git a/modules/access_match_engagement/access_match_engagement.install b/modules/access_match_engagement/access_match_engagement.install index 9b56b51b..8dcfce11 100644 --- a/modules/access_match_engagement/access_match_engagement.install +++ b/modules/access_match_engagement/access_match_engagement.install @@ -9,18 +9,18 @@ use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; /** - * Hook_update. + * Sets the default 'interested' setting. */ -function access_match_engagement_update_8001() { +function access_match_engagement_update_8001(): void { $config = \Drupal::configFactory()->getEditable('access_match_engagement.settings'); $config->set('interested', 0); $config->save(); } /** - * + * Migrates field_status values to match moderation_state values. */ -function access_match_engagement_update_9001() { +function access_match_engagement_update_9001(): void { // Change status field values to match moderation_state values. $new_status_values = [ 'Draft' => 'draft', @@ -71,14 +71,14 @@ function access_match_engagement_update_9001() { } // Restore existing data in fields & revision tables. - if (!is_null($rows)) { + if (!empty($rows)) { foreach ($rows as $row) { $row = (array) $row; $row['field_status_value'] = $new_status_values[$row['field_status_value']]; $database->insert($table)->fields($row)->execute(); } } - if (!is_null($revision_rows)) { + if (!empty($revision_rows)) { foreach ($revision_rows as $row) { $row = (array) $row; $row['field_status_value'] = $new_status_values[$row['field_status_value']]; @@ -91,9 +91,9 @@ function access_match_engagement_update_9001() { /** * Change the match_engagement field_status values of 'accepted' to 'prelaunch'. */ -function access_match_engagement_update_9002() { +function access_match_engagement_update_9002(): void { $database = \Drupal::database(); - $query = $database->update('node__field_status') + $database->update('node__field_status') ->fields(['field_status_value' => 'prelaunch']) ->condition('field_status_value', 'accepted') ->execute(); diff --git a/modules/access_match_engagement/access_match_engagement.module b/modules/access_match_engagement/access_match_engagement.module index 01c62de8..f415b43d 100644 --- a/modules/access_match_engagement/access_match_engagement.module +++ b/modules/access_match_engagement/access_match_engagement.module @@ -10,12 +10,14 @@ use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityForm; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Link; use Drupal\Core\Render\Markup; use Drupal\Core\Url; +use Drupal\node\NodeInterface; use Drupal\user\Entity\User; +use Drupal\user\UserInterface; use Drupal\views\ViewExecutable; use Symfony\Component\HttpFoundation\RedirectResponse; use Drupal\access_misc\Plugin\Util\SiteTools; @@ -23,23 +25,25 @@ use Drupal\access_misc\Plugin\Util\SiteTools; /** * Implements hook_views_pre_render(). */ -function access_match_engagement_views_pre_render(ViewExecutable $view) { - if (isset($view) && ($view->storage->id() == 'match_engagement_view')) { +function access_match_engagement_views_pre_render(ViewExecutable $view): void { + if ($view->storage->id() == 'match_engagement_view') { $view->element['#attached']['library'][] = 'access_match_engagement/match_engagement_view'; } } /** - * Implements hook_preprocess_views_view_field for views-view-field.html.twig. + * Implements hook_preprocess_views_view_field(). + * + * @phpstan-param array $variables */ -function access_match_engagement_preprocess_views_view_field(&$variables) { +function access_match_engagement_preprocess_views_view_field(array &$variables): void { $view = $variables['view']; $field = $variables['field']; // Overwrite MATCH Engagements submissions on /match-engagements-submissions. - // /admin/structure/views/view/match_engagements_submissions/edit/page_1 + // /admin/structure/views/view/match_engagements_submissions/edit/page_1. if ($view->id() == 'match_engagements_submissions' && $view->current_display == 'page_1' && $field->options['id'] == 'nothing') { - $output =$variables['output']; + $output = $variables['output']; $matches = []; preg_match('/~\s*(\d+)\s*~/', $output, $matches); @@ -59,8 +63,10 @@ function access_match_engagement_preprocess_views_view_field(&$variables) { /** * Implements hook_block_build_BASE_BLOCK_ID_alter(). + * + * @phpstan-param array $build */ -function access_match_engagement_block_build_views_block_alter(array &$build, BlockPluginInterface $block) { +function access_match_engagement_block_build_views_block_alter(array &$build, BlockPluginInterface $block): void { // Using label because block numbers seem to change. if ($block->label() == 'match engagement view: Block - My Engagement') { $build['#create_placeholder'] = FALSE; @@ -69,8 +75,10 @@ function access_match_engagement_block_build_views_block_alter(array &$build, Bl /** * Implements hook_form_alter(). + * + * @phpstan-param array $form */ -function access_match_engagement_form_alter(&$form, FormStateInterface $form_state, $form_id) { +function access_match_engagement_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void { if ($form_id == 'node_match_engagement_edit_form' || $form_id == 'node_match_engagement_form') { // Only allow MATCH Engagement creation on the ACCESS domain. $siteTools = \Drupal::service('access_misc.sitetools'); @@ -109,8 +117,8 @@ function access_match_engagement_form_alter(&$form, FormStateInterface $form_sta 'class' => [ 'bg-light-teal', 'my-5', - 'p-5' - ] + 'p-5', + ], ], ]; @@ -121,10 +129,10 @@ function access_match_engagement_form_alter(&$form, FormStateInterface $form_sta $form['field_tags_replace']['field_suggest']['replace_button'] = [ '#type' => 'button', '#value' => t('Suggest Tags'), - '#limit_validation_errors' => array(), + '#limit_validation_errors' => [], '#attributes' => [ 'class' => [ - 'ml-0' + 'ml-0', ], ], '#ajax' => [ @@ -136,18 +144,19 @@ function access_match_engagement_form_alter(&$form, FormStateInterface $form_sta '#markup' => "", ]; - $add_tags = \Drupal::service('access_misc.addtags'); $output = $add_tags->getView(); $tag_label = t('Tags'); $tag_description = t('Select up to 6 tags that relate to your engagement. Tags will help us find people with related expertise.'); $tag_summary = t('Select Tags'); + $node_add_tags_markup = "
$tag_label
" + . "
$tag_description
" + . "
" + . "
$tag_summary$output
"; + $form['node_add_tags'] = [ - '#markup' => "
$tag_label
-
$tag_description
-
-
$tag_summary$output
", + '#markup' => $node_add_tags_markup, '#weight' => 15, '#allowed_tags' => [ 'ul', @@ -183,7 +192,7 @@ function access_match_engagement_form_alter(&$form, FormStateInterface $form_sta // Form has been accepted, moderation-wise. // Fixup certain date-time fields to act as date-only. - $date_only_fields = ame_get_dateonly_fields(); + $date_only_fields = access_match_engagement_ame_get_dateonly_fields(); foreach ($date_only_fields as $fieldname) { $form[$fieldname]['widget'][0]['value']['#date_time_element'] = 'none'; $form[$fieldname]['widget'][0]['value']['#date_time_format'] = ''; @@ -191,10 +200,6 @@ function access_match_engagement_form_alter(&$form, FormStateInterface $form_sta $current_user = \Drupal::currentUser(); $roles = $current_user->getRoles(); - $unaccepted_states = ['draft', 'in_review']; - $moderation_state = $form['moderation_state']['widget'][0]['state']['#default_value']; - $accepted = !in_array($moderation_state, $unaccepted_states); - if (!in_array('administrator', $roles) && !in_array('match_sc', $roles)) { $form['field_email_user']['#access'] = FALSE; $form['field_notes_to_author']['#access'] = FALSE; @@ -213,13 +218,13 @@ function access_match_engagement_form_alter(&$form, FormStateInterface $form_sta $form['#validate'][] = 'access_match_engagement_form_validate'; $form['save_description'] = [ '#markup' => '
' . - t("Save as Submitted when your request is ready, or Draft if you need more time") . - '
', + t('Save as Submitted when your request is ready, or Draft if you need more time') . + '', '#weight' => 99, '#allowed_tags' => [ 'a', 'div', - 'span' + 'span', ], ]; $form['moderation_state']['widget'][0]['state']['#title'] = t('Save as'); @@ -239,8 +244,14 @@ function access_match_engagement_form_alter(&$form, FormStateInterface $form_sta /** * Ajax callback function to replace the section with '#markup'. + * + * @param array $form + * The form array. + * + * @return array + * The updated form element. */ -function access_match_engagement_replace_section_callback(array &$form, FormStateInterface $form_state) { +function access_match_engagement_replace_section_callback(array &$form, FormStateInterface $form_state): array { $raw_data = $form_state->getUserInput(); $body = $raw_data['body'][0]['value']; $body_filter = $body ? Xss::filter($body) : ''; @@ -282,17 +293,25 @@ function access_match_engagement_replace_section_callback(array &$form, FormStat ]; } - $form['field_tags_replace']['#attributes']['data-suggest'] = $suggested_tag_ids ; + $form['field_tags_replace']['#attributes']['data-suggest'] = $suggested_tag_ids; // Return the updated section. return $form['field_tags_replace']; } /** + * Marks a required date field as not required. + * * This callback is used on a date field, the first milestone goal, that is * required only after the state of the form has been changed to accepted. + * + * @param array $element + * The form element. + * + * @return array + * The modified form element. */ -function access_match_engagement_customize_required_date($element, $form_state) { +function access_match_engagement_customize_required_date(array $element, FormStateInterface $form_state): array { $element['#required'] = FALSE; $element['value']['#required'] = FALSE; @@ -300,13 +319,16 @@ function access_match_engagement_customize_required_date($element, $form_state) } /** - * Implements hook_ENTITY_TYPE_view_alter(). + * Implements hook_ENTITY_TYPE_view(). + * * For the display of a single match engagement (it's not a view) * replace the link to user profile with link to community-persona. + * + * @phpstan-param array $build */ -function access_match_engagement_node_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) { +function access_match_engagement_node_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, string $view_mode): void { - if ($display->getTargetBundle() == 'match_engagement') { + if ($display->getTargetBundle() == 'match_engagement' && $entity instanceof FieldableEntityInterface) { if (!empty($build['field_researcher'])) { foreach ($entity->get('field_researcher')->getValue() as $i => $researcher) { $uid = $researcher['target_id']; @@ -319,35 +341,43 @@ function access_match_engagement_node_view(array &$build, EntityInterface $entit } /** + * Submit handler that displays a message based on the moderation state. * + * @param array $form + * The form array. */ -function access_match_engagement_submit_function(&$form, FormStateInterface $form_state) { +function access_match_engagement_submit_function(array &$form, FormStateInterface $form_state): void { $moderation_state = $form_state->getValues()['moderation_state'][0]['value']; - update_drupal_message($moderation_state); + access_match_engagement_update_drupal_message($moderation_state); } /** - * Validate form - * Need at least one tag. - * Check that names in the Match Team student(s), consultant(s) or mentors(s) fields have the correct role s + * Validate form, need at least one tag. + * + * Check that names in the Match Team student(s), consultant(s) or + * mentors(s) fields have the correct role s * (student-facilitator/consultant/mentor). - * For mentor(s) or consultant(s) field, we will automatically add the role to the user iff they already - * are in the cssn system (defined by have an item in their program list = "ACCESS CSSN"). + * For mentor(s) or consultant(s) field, we will automatically add the + * role to the user iff they already are in the cssn system (defined by + * have an item in their program list = "ACCESS CSSN"). + * + * @param array $form + * The form array. */ -function access_match_engagement_form_validate(&$form, FormStateInterface $form_state) { +function access_match_engagement_form_validate(array &$form, FormStateInterface $form_state): void { $error = ''; $values = $form_state->getValues(); $tags = $values['field_tags']; if (!empty($values)) { $person_array = $values['field_students']; - $error .= match_check_role($person_array, 'student', FALSE); + $error .= access_match_engagement_match_check_role($person_array, 'student', FALSE); $person_array = $values['field_mentor']; - $error .= match_check_role($person_array, 'mentor', TRUE); + $error .= access_match_engagement_match_check_role($person_array, 'mentor', TRUE); $person_array = $values['field_consultant']; - $error .= match_check_role($person_array, 'consultant', TRUE); + $error .= access_match_engagement_match_check_role($person_array, 'consultant', TRUE); } if (count($tags) > 6) { @@ -355,58 +385,66 @@ function access_match_engagement_form_validate(&$form, FormStateInterface $form_ } if (!empty($error)) { - $form_state->setError($form, t($error)); + $form_state->setError($form, t('@error', ['@error' => $error])); } } /** - * Assemble a list of messages for the user that tell if any - * of the users in person_array do not have the proper roles as - * described in the _validate function above. If do_add_Role is + * Assemble a list of messages for the user. + * + * Tells if any of the users in person_array do not have the proper + * roles as described in the _validate function above. If do_add_role is * true, add the role to the user if the are already in the - * ACCESS_CSSN program + * ACCESS_CSSN program. + * + * @param array $person_array + * The list of person field values to check. */ -function match_check_role($person_array, $role_name, $do_add_role) { +function access_match_engagement_match_check_role(array $person_array, string $role_name, bool $do_add_role): string { $error_msg = ''; - // While checking each of these fields, the is_array skips the 'add another item' translatable markup item. + // While checking each of these fields, the is_array skips the 'add + // another item' translatable markup item. foreach ($person_array as $person) { if (is_array($person) && !empty($person['target_id'])) { $user = User::load($person['target_id']); - $has_role = validate_user_has_role($user, $role_name); + $has_role = access_match_engagement_validate_user_has_role($user, $role_name); if (!$has_role) { - if ($do_add_role && user_in_program($user, 'ACCESS CSSN')) { + if ($do_add_role && access_match_engagement_user_in_program($user, 'ACCESS CSSN')) { $user->addRole($role_name); $user->save(); } else { - $error_msg .= t($user->getDisplayName() . ' is not a CSSN ' . $role_name . '. Please ask them to complete the form to join the CSSN.
', [':url' => 'https://support.access-ci.org/form/join-the-cssn-network']); + $error_msg .= t('@name is not a CSSN @role. Please ask them to complete the form to join the CSSN.
', [ + '@name' => $user->getDisplayName(), + '@role' => $role_name, + ':url' => 'https://support.access-ci.org/form/join-the-cssn-network', + ]); } } } } - return ($error_msg); + return $error_msg; } /** * Return boolean - whether a program is included in list of user's programs. */ -function user_in_program($user, $program_name) { +function access_match_engagement_user_in_program(UserInterface $user, string $program_name): bool { $regions = $user->get('field_region')->getValue(); $terms = []; foreach ($regions as $region) { $region_tid = $region['target_id']; $terms[$region_tid] = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->load($region_tid)->getName(); } - $program = implode(', ', $terms); - return (in_array($program_name, $terms)); + return in_array($program_name, $terms); } /** - * + * Checks whether the given user account has the given role. */ -function validate_user_has_role($user, $role_name) { +function access_match_engagement_validate_user_has_role(UserInterface $user, string $role_name): bool { $roles_array = $user->getRoles(); foreach ($roles_array as $role) { @@ -418,10 +456,12 @@ function validate_user_has_role($user, $role_name) { } /** - * Return a list of datetime field names where we - * want to hide the time portion. + * Return a list of datetime field names where we want to hide the time portion. + * + * @return array + * The list of field names. */ -function ame_get_dateonly_fields() { +function access_match_engagement_ame_get_dateonly_fields(): array { return [ "field_milestone_actual_date", "field_actual_completion_date_1", @@ -440,8 +480,11 @@ function ame_get_dateonly_fields() { /** * Implements hook_mail(). + * + * @phpstan-param array $message + * @phpstan-param array $params */ -function access_match_engagement_mail($key, &$message, $params) { +function access_match_engagement_mail(string $key, array &$message, array $params): void { switch ($key) { case 'ame-email-notes': case 'interested-update': @@ -453,10 +496,9 @@ function access_match_engagement_mail($key, &$message, $params) { } /** - * + * Sets a status message for the user based on the moderation state. */ -function update_drupal_message($moderation_state) { - $message = ''; +function access_match_engagement_update_drupal_message(string $moderation_state): void { if ($moderation_state == 'draft') { $message = t('Your project has been saved. Please send for review when you’re ready and we’ll get back to you as soon as possible.'); } @@ -467,18 +509,16 @@ function update_drupal_message($moderation_state) { $moderation_state = str_replace('_', ' ', $moderation_state); $message = t('Your project has been saved. The current status is: @status.', ['@status' => $moderation_state]); } - // If $message is not empty, add it to the drupal message queue. - if ($message) { - \Drupal::messenger()->addMessage($message); - } + // Add the message to the drupal message queue. + \Drupal::messenger()->addMessage($message); } /** * Implements hook_entity_presave(). */ -function access_match_engagement_entity_presave(EntityInterface $entity) { +function access_match_engagement_entity_presave(EntityInterface $entity): void { $type = $entity->bundle(); - if ($type == 'match_engagement') { + if ($type == 'match_engagement' && $entity instanceof NodeInterface) { $get_fields = $entity->getFields(); // Set field_status to moderation_state. $moderation_state = $get_fields['moderation_state']->getValue(); @@ -486,7 +526,7 @@ function access_match_engagement_entity_presave(EntityInterface $entity) { $moderation_state = $moderation_state[0]['value']; $entity->set('field_status', $moderation_state); Cache::invalidateTags($entity->getCacheTagsToInvalidate()); - update_drupal_message($moderation_state); + access_match_engagement_update_drupal_message($moderation_state); } // Set field_researcher to current user if field_researcher is empty. $researcher = $get_fields['field_researcher']->getValue(); @@ -536,17 +576,21 @@ function access_match_engagement_entity_presave(EntityInterface $entity) { /** * Build access_match_engagement interested Email. + * + * @param array $to_uids + * The user IDs to notify. */ -function access_match_engagement_interested_email($to_uids) { +function access_match_engagement_interested_email(array $to_uids): void { $body['string'] = [ '#type' => 'inline_template', '#template' => '

{{ intro }} {{ link | raw }}.

', '#context' => [ - 'intro' => t('New interested user alert! Please check website for '), - 'link' => "details", + 'intro' => t('New interested user alert! Please check website for'), + 'link' => " details", ], ]; - // Lookup each user by to_uids and add their email to the $to_email string comma separated. + // Lookup each user by to_uids and add their email to the $to_email + // string comma separated. $to_email = ''; foreach ($to_uids as $uid) { $user = User::load($uid); @@ -560,14 +604,21 @@ function access_match_engagement_interested_email($to_uids) { $params['body'] = $render_service->renderRoot($body); $params['title'] = "New Interested User Alert"; // Add logger message of sending interested email. - \Drupal::logger('access_match_engagement')->notice('Sending interested email to: ' . $to_email); - ame_send('interested-update', $params); + \Drupal::logger('access_match_engagement')->notice('Sending interested email to: @email', ['@email' => $to_email]); + access_match_engagement_ame_send('interested-update', $params); } /** * Build access_match_engagement notes Email. + * + * @param int|string $ame_nid + * The node ID of the engagement. + * @param string $ame_notes + * The notes to include in the email. + * @param int|string $author + * The user ID of the engagement author. */ -function access_match_engagement_email($ame_nid, $ame_title, $ame_notes, $author, $ame_type) { +function access_match_engagement_email($ame_nid, string $ame_title, string $ame_notes, $author, string $ame_type): void { $options = ['absolute' => TRUE]; $here = Url::fromRoute('entity.node.canonical', ['node' => $ame_nid], $options); $body['string'] = [ @@ -593,13 +644,19 @@ function access_match_engagement_email($ame_nid, $ame_title, $ame_notes, $author $params['to'] = $to_email; $params['body'] = $render_service->render($body); $params['title'] = "MATCH$ame_type Engagement: $ame_title"; - ame_send('ame-email-notes', $params); + access_match_engagement_ame_send('ame-email-notes', $params); } /** * Send email. + * + * @param array $params + * Parameters used to build the message. + * + * @return array + * The result of the mail manager's mail() call. */ -function ame_send($key, $params) { +function access_match_engagement_ame_send(string $key, array $params): array { $to = $params['to']; $langcode = \Drupal::currentUser()->getPreferredLangcode(); $send = TRUE; @@ -612,7 +669,7 @@ function ame_send($key, $params) { /** * Hook_cron. */ -function access_match_engagement_cron() { +function access_match_engagement_cron(): void { $env = getenv('PANTHEON_ENVIRONMENT'); if ($env == 'live') { $config = \Drupal::configFactory()->getEditable('access_match_engagement.settings'); diff --git a/modules/access_match_engagement/src/Controller/MatchController.php b/modules/access_match_engagement/src/Controller/MatchController.php index 8dd9130b..cafc7bbe 100644 --- a/modules/access_match_engagement/src/Controller/MatchController.php +++ b/modules/access_match_engagement/src/Controller/MatchController.php @@ -2,24 +2,148 @@ namespace Drupal\access_match_engagement\Controller; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Logger\LoggerChannelFactoryInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\PageCache\ResponsePolicy\KillSwitch; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountProxyInterface; +use Drupal\Core\State\StateInterface; use Drupal\Core\Url; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; /** * Controller for Match. */ -class MatchController extends ControllerBase { +final class MatchController extends ControllerBase { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The current route match. + * + * @var \Drupal\Core\Routing\RouteMatchInterface + */ + protected $routeMatch; + + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountProxyInterface + */ + protected $currentUser; + + /** + * The config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * The messenger. + * + * @var \Drupal\Core\Messenger\MessengerInterface + */ + protected $messenger; + + /** + * The logger channel factory. + * + * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface + */ + protected $loggerFactory; + + /** + * The page cache kill switch. + * + * @var \Drupal\Core\PageCache\ResponsePolicy\KillSwitch + */ + protected $killSwitch; + + /** + * Constructs a MatchController object. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + * @param \Drupal\Core\Session\AccountProxyInterface $current_user + * The current user. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory. + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger. + * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory + * The logger channel factory. + * @param \Drupal\Core\PageCache\ResponsePolicy\KillSwitch $kill_switch + * The page cache kill switch. + */ + public function __construct( + EntityTypeManagerInterface $entity_type_manager, + RouteMatchInterface $route_match, + AccountProxyInterface $current_user, + ConfigFactoryInterface $config_factory, + StateInterface $state, + MessengerInterface $messenger, + LoggerChannelFactoryInterface $logger_factory, + KillSwitch $kill_switch, + ) { + $this->entityTypeManager = $entity_type_manager; + $this->routeMatch = $route_match; + $this->currentUser = $current_user; + $this->configFactory = $config_factory; + $this->state = $state; + $this->messenger = $messenger; + $this->loggerFactory = $logger_factory; + $this->killSwitch = $kill_switch; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): self { + return new self( + $container->get('entity_type.manager'), + $container->get('current_route_match'), + $container->get('current_user'), + $container->get('config.factory'), + $container->get('state'), + $container->get('messenger'), + $container->get('logger.factory'), + $container->get('page_cache_kill_switch'), + ); + } /** * Build content to display on page. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A redirect response back to the node. */ public function interestedContent() { - $nid = \Drupal::routeMatch()->getRawParameter('node'); + $nid = $this->routeMatch->getRawParameter('node'); // Load entity node using node id. - $node = \Drupal::entityTypeManager()->getStorage('node')->load($nid); + $node = $this->entityTypeManager->getStorage('node')->load($nid); if ($node->getType() == 'match_engagement' || $node->getType() == 'mentorship_engagement') { - $current_user = \Drupal::currentUser()->id(); + $current_user = $this->currentUser->id(); $interested_users = $node->get('field_match_interested_users')->getValue(); if (array_search($current_user, array_column($interested_users, 'target_id')) !== FALSE) { foreach ($interested_users as $key => $interested_user) { @@ -29,24 +153,24 @@ public function interestedContent() { } $node->set('field_match_interested_users', $interested_users); $node->save(); - \Drupal::messenger()->addStatus(t("You have been removed from the interested list")); + $this->messenger->addStatus($this->t("You have been removed from the interested list")); } else { $interested_users[] = ['target_id' => $current_user]; // Get current user. - $current_user = \Drupal::currentUser(); - \Drupal::logger('access_match_engagement')->notice('User @current_user added to interested list', ['@current_user' => $current_user->getAccountName()]); + $current_user = $this->currentUser; + $this->loggerFactory->get('access_match_engagement')->notice('User @current_user added to interested list', ['@current_user' => $current_user->getAccountName()]); if ($node->getType() == 'match_engagement') { - $config = \Drupal::configFactory()->getEditable('access_match_engagement.settings'); + $config = $this->configFactory->getEditable('access_match_engagement.settings'); $config->set('interested', 1); $config->save(); } if ($node->getType() == 'mentorship_engagement') { - $interested_list = \Drupal::state()->get('access_mentorship_interested'); + $interested_list = $this->state->get('access_mentorship_interested'); $create_list = []; if (!empty($interested_list) && $interested_list !== '0') { $decoded = json_decode($interested_list, TRUE); - if ($decoded !== null && is_array($decoded)) { + if ($decoded !== NULL && is_array($decoded)) { $create_list = $decoded; } } @@ -54,15 +178,15 @@ public function interestedContent() { $create_list[] = $nid; } $interested_list = json_encode($create_list); - \Drupal::state()->set('access_mentorship_interested', $interested_list); + $this->state->set('access_mentorship_interested', $interested_list); } // Update node field. $node->set('field_match_interested_users', $interested_users); $node->save(); - \Drupal::messenger()->addStatus($this->t("You have been added to the interested list")); + $this->messenger->addStatus($this->t("You have been added to the interested list")); } } - \Drupal::service('page_cache_kill_switch')->trigger(); + $this->killSwitch->trigger(); // Redirect to node. $url = Url::fromRoute('entity.node.canonical', ['node' => $nid]); return new RedirectResponse($url->toString()); diff --git a/modules/access_match_engagement/src/Plugin/Block/MatchNodeBlock.php b/modules/access_match_engagement/src/Plugin/Block/MatchNodeBlock.php index b5bb1d51..ed00bdd1 100644 --- a/modules/access_match_engagement/src/Plugin/Block/MatchNodeBlock.php +++ b/modules/access_match_engagement/src/Plugin/Block/MatchNodeBlock.php @@ -7,11 +7,9 @@ use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\file\Entity\File; use Drupal\Core\File\FileUrlGeneratorInterface; -use Drupal\Core\Url; -use Drupal\image\Entity\ImageStyle; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountProxyInterface; use Drupal\Core\Cache\Cache; /** @@ -22,7 +20,7 @@ * admin_label = @Translation("Access Match Node Block") * ) */ -class MatchNodeBlock extends BlockBase implements +final class MatchNodeBlock extends BlockBase implements ContainerFactoryPluginInterface { /** @@ -46,12 +44,19 @@ class MatchNodeBlock extends BlockBase implements */ protected $routMatchInterface; + /** + * The current user. + * + * @var \Drupal\Core\Session\AccountProxyInterface + */ + protected $currentUser; + /** * {@inheritdoc} * * @param \Symfony\Component\DependencyInjection\ContainerInterface $container * Container pulled in. - * @param array $configuration + * @param array $configuration * Configuration added. * @param string $plugin_id * Plugin_id added. @@ -68,13 +73,14 @@ public static function create(ContainerInterface $container, array $configuratio $container->get('entity_type.manager'), $container->get('current_route_match'), $container->get('file_url_generator'), + $container->get('current_user'), ); } /** * {@inheritdoc} * - * @param array $configuration + * @param array $configuration * Configuration array. * @param string $plugin_id * Plugin id string. @@ -82,26 +88,34 @@ public static function create(ContainerInterface $container, array $configuratio * Plugin Definition mixed. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_interface * Invokes renderer. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match_interface + * The current route match. * @param \Drupal\Core\File\FileUrlGeneratorInterface $file_url_generator * File url generator. + * @param \Drupal\Core\Session\AccountProxyInterface $current_user + * The current user. */ public function __construct( array $configuration, $plugin_id, $plugin_definition, - EntityTypeManagerInterface - $entity_interface, + EntityTypeManagerInterface $entity_interface, RouteMatchInterface $route_match_interface, - FileUrlGeneratorInterface $file_url_generator + FileUrlGeneratorInterface $file_url_generator, + AccountProxyInterface $current_user, ) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->entityInterface = $entity_interface; $this->routMatchInterface = $route_match_interface; $this->fileUrlGenerator = $file_url_generator; + $this->currentUser = $current_user; } /** * {@inheritdoc} + * + * @return array + * A render array. */ public function build() { $thisNode = $this->routMatchInterface->getParameter('node'); @@ -109,6 +123,7 @@ public function build() { $nid = $thisNode->id(); $node = $this->entityInterface->getStorage('node')->load($nid); $status = $node->get('field_status')->getValue(); + $is_recruiting = FALSE; if ($status) { $status = $status[0]['value']; $is_recruiting = strcasecmp($status, 'recruiting') == 0 ? TRUE : FALSE; @@ -119,18 +134,17 @@ public function build() { $interested_users = $node->get('field_match_interested_users')->getValue(); // Lookup user names from uid. $interested_users = $this->getInterestedUsers($interested_users); - $status = $node->get('field_status')->getValue() ?? $node->get('field_status')->getValue()[0]['value']; $interested_button = ''; if ($is_recruiting) { $interested_list = $node->get('field_match_interested_users')->getValue(); - $user = \Drupal::currentUser()->id(); + $user = $this->currentUser->id(); if (array_search($user, array_column($interested_list, 'target_id')) !== FALSE) { $uninterested_text = $this->t("I'm no longer Interested"); $interested_button = "$uninterested_text"; } else { $interested_text = $this->t("I'm Interested"); - $interested_button = $is_recruiting ? "$interested_text" : ''; + $interested_button = "$interested_text"; } } $match_node_block['string'] = [ @@ -158,7 +172,7 @@ public function build() { } else { return [ - '#markup' => $this->t('Match Node Block - not a match node') + '#markup' => $this->t('Match Node Block - not a match node'), ]; } @@ -166,15 +180,21 @@ public function build() { /** * Get interested users. + * + * @param array> $interested_users + * The interested users field values. + * + * @return array + * The interested users' full names. */ - public function getInterestedUsers($interested_users) { + public function getInterestedUsers(array $interested_users): array { // Only show interested users to match_sc, match_pm, and admin. $accepted_roles = ['administrator', 'match_sc', 'match_pm']; - $current_user = \Drupal::currentUser(); + $current_user = $this->currentUser; $roles = $current_user->getRoles(); if (empty(in_array($accepted_roles, $roles))) { return []; - }; + } $interested_users = array_column($interested_users, 'target_id'); $users = $this->entityInterface->getStorage('user')->loadMultiple($interested_users); @@ -193,7 +213,8 @@ public function getCacheTags() { if ($node = $this->routMatchInterface->getParameter('node')) { // If there is node add its cachetag. return Cache::mergeTags(parent::getCacheTags(), ['node:' . $node->id()]); - } else { + } + else { // Return default tags instead. return parent::getCacheTags(); } @@ -208,4 +229,5 @@ public function getCacheContexts() { // Every new route this block will rebuild. return Cache::mergeContexts(parent::getCacheContexts(), ['route']); } + } From 770ee523f2ae5144991427918af587279fb7608a Mon Sep 17 00:00:00 2001 From: protitude Date: Thu, 18 Jun 2026 21:24:49 -0600 Subject: [PATCH 6/8] access_badges: d8-2763 fix phpcs/phpstan --- modules/access_badges/access_badges.info.yml | 5 +- modules/access_badges/access_badges.install | 27 +-- modules/access_badges/access_badges.module | 24 ++- .../access_badges/access_badges.services.yml | 5 + .../src/Controller/BadgePendingController.php | 48 ++++-- .../src/Form/BadgeCsvUploadForm.php | 155 ++++++++++++++++-- .../src/Form/BadgePendingForm.php | 67 ++++++-- .../src/Form/BadgeReviewForm.php | 69 ++++++-- .../access_badges/src/Plugin/BadgeTools.php | 122 +++++++++++--- .../access_badges/src/Plugin/CronManager.php | 9 +- .../access_badges/src/Service/BadgeSorter.php | 2 +- .../src/Service/CsvProcessor.php | 65 +++++--- 12 files changed, 472 insertions(+), 126 deletions(-) diff --git a/modules/access_badges/access_badges.info.yml b/modules/access_badges/access_badges.info.yml index 4590c788..288a7281 100644 --- a/modules/access_badges/access_badges.info.yml +++ b/modules/access_badges/access_badges.info.yml @@ -4,6 +4,5 @@ type: module core_version_requirement: ^10 || ^11 package: Custom dependencies: - - access - - ultimate_cron - + - drupal:access + - drupal:ultimate_cron diff --git a/modules/access_badges/access_badges.install b/modules/access_badges/access_badges.install index ca444709..300ee034 100644 --- a/modules/access_badges/access_badges.install +++ b/modules/access_badges/access_badges.install @@ -5,10 +5,12 @@ * Access Badges updates. */ +use Drupal\user\Entity\User; + /** * Add Badges to taxonomy. */ -function access_badges_install() { +function access_badges_install(): void { $terms = [ 'New to CSSN', 'New to ACCESS', @@ -79,7 +81,6 @@ function access_badges_install() { // Set badges for users. $badge_tools->setBadges($scipe, $users); - // Set Knowledge Base Resource Contributor badges. $query = \Drupal::database()->select('webform_submission', 'ws'); $query->fields('ws', ['sid', 'uid']); @@ -91,7 +92,8 @@ function access_badges_install() { foreach ($wf_submissions as $submission) { if (!isset($authors[$submission->uid])) { $authors[$submission->uid] = 1; - } else { + } + else { $authors[$submission->uid]++; } } @@ -101,18 +103,20 @@ function access_badges_install() { $kb_ten = \Drupal::service('access_badges.badgeTools')->getBadgeTid('KB Resource Contributor (10)'); foreach ($authors as $uid => $count) { - $user = \Drupal\user\Entity\User::load($uid); + $user = User::load($uid); $badgetid = $user->get('field_user_badges')->getValue(); if ($count < 4) { $badgetid[] = ['target_id' => $kb_one]; $user->set('field_user_badges', $badgetid); $user->save(); - } elseif ($count > 5 && $count < 10) { + } + elseif ($count > 5 && $count < 10) { $badgetid[] = ['target_id' => $kb_five]; $user->set('field_user_badges', $badgetid); $user->save(); - } elseif ($count >= 10) { + } + elseif ($count >= 10) { $badgetid[] = ['target_id' => $kb_ten]; $user->set('field_user_badges', $badgetid); $user->save(); @@ -129,7 +133,6 @@ function access_badges_install() { $badge_tools->fieldToBadge('field_mentor', 'CCMNet Engagement Participant', 'mentorship_engagement'); } - /** * Sort all user badges to match taxonomy term weight/name order. */ @@ -141,7 +144,7 @@ function access_badges_update_10001(): string { /** * Create access_badges_pending table for CSV badge assignment queue. */ -function access_badges_update_10002() { +function access_badges_update_10002(): void { $schema = \Drupal::database()->schema(); if (!$schema->tableExists('access_badges_pending')) { $schema->createTable('access_badges_pending', _access_badges_pending_schema()); @@ -150,8 +153,11 @@ function access_badges_update_10002() { /** * Returns the schema definition for the access_badges_pending table. + * + * @return array + * The schema definition. */ -function _access_badges_pending_schema() { +function _access_badges_pending_schema(): array { return [ 'description' => 'Pending badge assignments from CSV uploads.', 'fields' => [ @@ -234,13 +240,12 @@ function _access_badges_pending_schema() { /** * Remove Badges from taxonomy. */ -function access_badges_uninstall() { +function access_badges_uninstall(): void { // Drop the pending badges table. \Drupal::database()->schema()->dropTable('access_badges_pending'); \Drupal::database()->truncate('user__field_user_badges')->execute(); - $tids = \Drupal::entityQuery('taxonomy_term') ->condition('vid', 'badges') ->accessCheck(FALSE) diff --git a/modules/access_badges/access_badges.module b/modules/access_badges/access_badges.module index 95444c94..c7d6b296 100644 --- a/modules/access_badges/access_badges.module +++ b/modules/access_badges/access_badges.module @@ -2,18 +2,23 @@ /** * @file + * Access Badges hooks. */ +use Drupal\Core\Url; +use Drupal\user\Entity\User; use Drupal\Core\Form\FormStateInterface; use Drupal\user\UserInterface; /** * Implements hook_form_alter(). + * + * @phpstan-param array $form */ -function access_badges_form_alter(&$form, FormStateInterface $form_state, $form_id) { +function access_badges_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void { if ($form_id == 'user_form' || $form_id == 'user_register_form') { // Check if user is an administator. - $user = \Drupal\user\Entity\User::load(\Drupal::currentUser()->id()); + $user = User::load(\Drupal::currentUser()->id()); $roles = $user->getRoles(); if (!in_array('administrator', $roles)) { $form['#attached']['library'][] = 'access_badges/hide_ief_button'; @@ -26,20 +31,20 @@ function access_badges_form_alter(&$form, FormStateInterface $form_state, $form_ /** * Implements hook_user_presave(). */ -function access_badges_user_presave(UserInterface $user) { +function access_badges_user_presave(UserInterface $user): void { \Drupal::service('access_badges.badge_sorter')->sortUserBadges($user); } /** * Implements hook_cron(). */ -function access_badges_cron() { +function access_badges_cron(): void { // Run this cron job every day at 4am. $currentTime = \Drupal::time()->getCurrentTime(); $hour = date('H', $currentTime); $min = date('i', $currentTime); - // Cron runs every 10 minutes + // Cron runs every 10 minutes. if ($hour == 4 && $min < 20) { $badge_tools = \Drupal::service('access_badges.badgeTools'); // Get 'New to ACCESS' term id. @@ -79,7 +84,7 @@ function access_badges_cron() { * Auto-assigns badges from pending queue when a new user registers * with a matching email address (exact match, case-insensitive via collation). */ -function access_badges_user_insert(UserInterface $account) { +function access_badges_user_insert(UserInterface $account): void { $email = $account->getEmail(); if (empty($email)) { return; @@ -121,8 +126,11 @@ function access_badges_user_insert(UserInterface $account) { /** * Implements hook_mail(). + * + * @phpstan-param array $message + * @phpstan-param array $params */ -function access_badges_mail($key, &$message, $params) { +function access_badges_mail(string $key, array &$message, array $params): void { if ($key === 'pending_match_digest') { $message['subject'] = t('Badge Assignments: New matches need review'); @@ -140,7 +148,7 @@ function access_badges_mail($key, &$message, $params) { } $lines[] = ''; - $review_url = \Drupal\Core\Url::fromRoute('access_badges.review', [], ['absolute' => TRUE])->toString(); + $review_url = Url::fromRoute('access_badges.review', [], ['absolute' => TRUE])->toString(); $lines[] = t('Review these matches at: @url', ['@url' => $review_url]); $message['body'] = $lines; diff --git a/modules/access_badges/access_badges.services.yml b/modules/access_badges/access_badges.services.yml index 9967373e..4775975c 100644 --- a/modules/access_badges/access_badges.services.yml +++ b/modules/access_badges/access_badges.services.yml @@ -1,6 +1,10 @@ services: access_badges.badgeTools: class: Drupal\access_badges\Plugin\BadgeTools + arguments: + - '@entity_type.manager' + - '@database' + - '@datetime.time' access_badges.badge_sorter: class: Drupal\access_badges\Service\BadgeSorter @@ -17,3 +21,4 @@ services: - '@database' - '@entity_type.manager' - '@access_badges.badgeTools' + - '@datetime.time' diff --git a/modules/access_badges/src/Controller/BadgePendingController.php b/modules/access_badges/src/Controller/BadgePendingController.php index a7d995aa..7b243330 100644 --- a/modules/access_badges/src/Controller/BadgePendingController.php +++ b/modules/access_badges/src/Controller/BadgePendingController.php @@ -3,16 +3,18 @@ namespace Drupal\access_badges\Controller; use Drupal\access_badges\Plugin\BadgeTools; +use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Database\Connection; +use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RedirectResponse; -use Drupal\Core\Url; +use Symfony\Component\HttpFoundation\RequestStack; /** * Controller for badge pending action routes. */ -class BadgePendingController extends ControllerBase { +final class BadgePendingController extends ControllerBase { /** * The database connection. @@ -28,35 +30,53 @@ class BadgePendingController extends ControllerBase { */ protected $badgeTools; + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + /** * Constructs a BadgePendingController. */ - public function __construct(Connection $database, BadgeTools $badge_tools) { + public function __construct(Connection $database, BadgeTools $badge_tools, TimeInterface $time, RequestStack $request_stack) { $this->database = $database; $this->badgeTools = $badge_tools; + $this->time = $time; + $this->requestStack = $request_stack; } /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( $container->get('database'), - $container->get('access_badges.badgeTools') + $container->get('access_badges.badgeTools'), + $container->get('datetime.time'), + $container->get('request_stack') ); } /** * Redirects the Badge Assignments landing page to the Pending sub-tab. */ - public function badgeAssignments() { + public function badgeAssignments(): RedirectResponse { return new RedirectResponse(Url::fromRoute('access_badges.pending')->toString()); } /** * Deletes a pending row. */ - public function delete($id) { + public function delete(string $id): RedirectResponse { $this->database->delete('access_badges_pending') ->condition('id', $id) ->execute(); @@ -67,8 +87,8 @@ public function delete($id) { /** * Assigns a badge from an inline possible match. */ - public function assign($uid, $badge_tid, $vocabulary, $email) { - $assigned = $this->badgeTools->assignBadgeToUser($uid, $badge_tid, $vocabulary); + public function assign(string $uid, string $badge_tid, string $vocabulary, string $email): RedirectResponse { + $assigned = $this->badgeTools->assignBadgeToUser((int) $uid, (int) $badge_tid, $vocabulary); if ($assigned) { $this->messenger()->addStatus($this->t('Badge assigned successfully.')); } @@ -88,11 +108,11 @@ public function assign($uid, $badge_tid, $vocabulary, $email) { /** * Sends an inline possible match to the pending queue. */ - public function sendToPending($email, $first_name, $last_name, $organization = '') { + public function sendToPending(string $email, string $first_name, string $last_name, string $organization = ''): RedirectResponse { // This route requires badge_tid and vocabulary from the query string. - $request = \Drupal::request(); - $badge_tid = $request->query->get('badge_tid', 0); - $vocabulary = $request->query->get('vocabulary', 'badges'); + $request = $this->requestStack->getCurrentRequest(); + $badge_tid = $request ? $request->query->get('badge_tid', 0) : 0; + $vocabulary = $request ? $request->query->get('vocabulary', 'badges') : 'badges'; if (!empty($email) && !empty($badge_tid)) { // Check for duplicate. @@ -112,7 +132,7 @@ public function sendToPending($email, $first_name, $last_name, $organization = ' 'organization' => $organization, 'badge_tid' => $badge_tid, 'vocabulary' => $vocabulary, - 'created' => \Drupal::time()->getRequestTime(), + 'created' => $this->time->getRequestTime(), 'status' => 'pending', ]) ->execute(); diff --git a/modules/access_badges/src/Form/BadgeCsvUploadForm.php b/modules/access_badges/src/Form/BadgeCsvUploadForm.php index e98f6e0c..f995d3c8 100644 --- a/modules/access_badges/src/Form/BadgeCsvUploadForm.php +++ b/modules/access_badges/src/Form/BadgeCsvUploadForm.php @@ -3,15 +3,18 @@ namespace Drupal\access_badges\Form; use Drupal\access_badges\Service\CsvProcessor; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; /** * CSV upload form for bulk badge assignment. */ -class BadgeCsvUploadForm extends FormBase { +final class BadgeCsvUploadForm extends FormBase { /** * The CSV processor service. @@ -20,19 +23,48 @@ class BadgeCsvUploadForm extends FormBase { */ protected $csvProcessor; + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * The file system service. + * + * @var \Drupal\Core\File\FileSystemInterface + */ + protected $fileSystem; + + /** + * The messenger service. + * + * @var \Drupal\Core\Messenger\MessengerInterface + */ + protected $messenger; + /** * Constructs a BadgeCsvUploadForm. */ - public function __construct(CsvProcessor $csv_processor) { + public function __construct(CsvProcessor $csv_processor, EntityTypeManagerInterface $entity_type_manager, FileSystemInterface $file_system, MessengerInterface $messenger) { $this->csvProcessor = $csv_processor; + $this->entityTypeManager = $entity_type_manager; + $this->fileSystem = $file_system; + $this->messenger = $messenger; } /** * {@inheritdoc} + * + * @return static */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( - $container->get('access_badges.csv_processor') + $container->get('access_badges.csv_processor'), + $container->get('entity_type.manager'), + $container->get('file_system'), + $container->get('messenger') ); } @@ -45,8 +77,16 @@ public function getFormId() { /** * {@inheritdoc} + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The form. */ - public function buildForm(array $form, FormStateInterface $form_state) { + public function buildForm(array $form, FormStateInterface $form_state): array { $form['vocabulary'] = [ '#type' => 'select', '#title' => $this->t('Vocabulary'), @@ -93,17 +133,31 @@ public function buildForm(array $form, FormStateInterface $form_state) { /** * AJAX callback to update badge options. + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The badge_tid form element. */ - public function updateBadgeOptions(array &$form, FormStateInterface $form_state) { + public function updateBadgeOptions(array &$form, FormStateInterface $form_state): array { return $form['badge_tid']; } /** * Gets badge term options for a vocabulary. + * + * @param string $vocabulary + * The vocabulary machine name. + * + * @return array + * The badge term options, keyed by term ID. */ - protected function getBadgeOptions($vocabulary) { + protected function getBadgeOptions(string $vocabulary): array { $options = []; - $terms = \Drupal::entityTypeManager() + $terms = $this->entityTypeManager ->getStorage('taxonomy_term') ->loadByProperties(['vid' => $vocabulary]); foreach ($terms as $term) { @@ -115,8 +169,13 @@ protected function getBadgeOptions($vocabulary) { /** * {@inheritdoc} + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. */ - public function validateForm(array &$form, FormStateInterface $form_state) { + public function validateForm(array &$form, FormStateInterface $form_state): void { $files = $this->getRequest()->files->get('files'); $file = $files['csv_file'] ?? NULL; @@ -155,8 +214,13 @@ public function validateForm(array &$form, FormStateInterface $form_state) { /** * {@inheritdoc} + * + * @param array $form + * The form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. */ - public function submitForm(array &$form, FormStateInterface $form_state) { + public function submitForm(array &$form, FormStateInterface $form_state): void { $file_path = $form_state->get('csv_file_path'); $header_map = $form_state->get('csv_header_map'); $badge_tid = $form_state->getValue('badge_tid'); @@ -185,8 +249,20 @@ public function submitForm(array &$form, FormStateInterface $form_state) { /** * Processes CSV rows synchronously. + * + * @param string $file_path + * Path to the CSV file. + * @param array $header_map + * Mapping of column index to header name. + * @param int|string $badge_tid + * The badge term ID. + * @param string $vocabulary + * The vocabulary machine name. + * + * @return array>> + * The processing results, keyed by result type. */ - protected function processSync($file_path, $header_map, $badge_tid, $vocabulary) { + protected function processSync($file_path, array $header_map, $badge_tid, $vocabulary): array { $results = [ 'assigned' => [], 'already_assigned' => [], @@ -213,9 +289,20 @@ protected function processSync($file_path, $header_map, $badge_tid, $vocabulary) /** * Launches batch processing for large CSVs. + * + * @param string $file_path + * Path to the CSV file. + * @param array $header_map + * Mapping of column index to header name. + * @param int|string $badge_tid + * The badge term ID. + * @param string $vocabulary + * The vocabulary machine name. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. */ - protected function processBatch($file_path, $header_map, $badge_tid, $vocabulary, FormStateInterface $form_state) { - $temp_path = \Drupal::service('file_system')->getTempDirectory() . '/badge_csv_' . time() . '.csv'; + protected function processBatch($file_path, array $header_map, $badge_tid, $vocabulary, FormStateInterface $form_state): void { + $temp_path = $this->fileSystem->getTempDirectory() . '/badge_csv_' . time() . '.csv'; copy($file_path, $temp_path); $batch = [ @@ -234,8 +321,19 @@ protected function processBatch($file_path, $header_map, $badge_tid, $vocabulary /** * Batch operation callback. + * + * @param string $file_path + * Path to the CSV file. + * @param array $header_map + * Mapping of column index to header name. + * @param int|string $badge_tid + * The badge term ID. + * @param string $vocabulary + * The vocabulary machine name. + * @param array $context + * The batch context. */ - public static function batchProcess($file_path, $header_map, $badge_tid, $vocabulary, &$context) { + public static function batchProcess($file_path, array $header_map, $badge_tid, $vocabulary, array &$context): void { $csv_processor = \Drupal::service('access_badges.csv_processor'); if (!isset($context['sandbox']['progress'])) { @@ -290,8 +388,15 @@ public static function batchProcess($file_path, $header_map, $badge_tid, $vocabu /** * Batch finished callback. + * + * @param bool $success + * Whether the batch completed successfully. + * @param array $results + * The batch results. + * @param array $operations + * The batch operations. */ - public static function batchFinished($success, $results, $operations) { + public static function batchFinished($success, array $results, array $operations): void { if ($success) { $assigned_count = count($results['assigned'] ?? []); $pending_count = count($results['pending'] ?? []); @@ -314,8 +419,14 @@ public static function batchFinished($success, $results, $operations) { /** * Builds the results display render array. + * + * @param array $results + * The processing results. + * + * @return array + * The results render array. */ - protected function buildResultsDisplay(array $results) { + protected function buildResultsDisplay(array $results): array { $build = [ '#type' => 'container', '#attributes' => ['class' => ['badge-csv-results']], @@ -412,8 +523,18 @@ protected function buildResultsDisplay(array $results) { /** * Builds table for possible matches with action buttons. + * + * @param array> $possible_matches + * The possible match rows. + * @param int|string $badge_tid + * The badge term ID. + * @param string $vocabulary + * The vocabulary machine name. + * + * @return array + * The table render array. */ - protected function buildPossibleMatchesTable(array $possible_matches, $badge_tid = 0, $vocabulary = 'badges') { + protected function buildPossibleMatchesTable(array $possible_matches, $badge_tid = 0, $vocabulary = 'badges'): array { $header = [ $this->t('CSV Email'), $this->t('CSV Name'), diff --git a/modules/access_badges/src/Form/BadgePendingForm.php b/modules/access_badges/src/Form/BadgePendingForm.php index b1e57ac8..e8e42c3f 100644 --- a/modules/access_badges/src/Form/BadgePendingForm.php +++ b/modules/access_badges/src/Form/BadgePendingForm.php @@ -3,6 +3,7 @@ namespace Drupal\access_badges\Form; use Drupal\Core\Database\Connection; +use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; @@ -12,7 +13,7 @@ /** * Management form for pending badge assignments. */ -class BadgePendingForm extends FormBase { +final class BadgePendingForm extends FormBase { /** * The database connection. @@ -28,21 +29,30 @@ class BadgePendingForm extends FormBase { */ protected $entityTypeManager; + /** + * The date formatter. + * + * @var \Drupal\Core\Datetime\DateFormatterInterface + */ + protected $dateFormatter; + /** * Constructs a BadgePendingForm. */ - public function __construct(Connection $database, EntityTypeManagerInterface $entity_type_manager) { + public function __construct(Connection $database, EntityTypeManagerInterface $entity_type_manager, DateFormatterInterface $date_formatter) { $this->database = $database; $this->entityTypeManager = $entity_type_manager; + $this->dateFormatter = $date_formatter; } /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( $container->get('database'), - $container->get('entity_type.manager') + $container->get('entity_type.manager'), + $container->get('date.formatter') ); } @@ -55,8 +65,16 @@ public function getFormId() { /** * {@inheritdoc} + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The form structure. */ - public function buildForm(array $form, FormStateInterface $form_state) { + public function buildForm(array $form, FormStateInterface $form_state): array { // Filter by badge. $badge_options = $this->getPendingBadgeOptions('pending'); if (!empty($badge_options)) { @@ -108,8 +126,11 @@ public function buildForm(array $form, FormStateInterface $form_state) { /** * Gets pending rows from the database. + * + * @return array + * The table rows keyed by row ID. */ - protected function getPendingRows($status, $badge_filter = '') { + protected function getPendingRows(string $status, string $badge_filter = ''): array { $query = $this->database->select('access_badges_pending', 'p') ->fields('p') ->condition('p.status', $status) @@ -130,7 +151,7 @@ protected function getPendingRows($status, $badge_filter = '') { 'last_name' => $row->last_name, 'organization' => $row->organization, 'badge' => $badge_name, - 'created' => \Drupal::service('date.formatter')->format($row->created, 'short'), + 'created' => $this->dateFormatter->format($row->created, 'short'), 'operations' => [ 'data' => [ '#type' => 'link', @@ -147,8 +168,11 @@ protected function getPendingRows($status, $badge_filter = '') { /** * Gets badge term options that exist in pending rows. + * + * @return array + * The badge options keyed by term ID. */ - protected function getPendingBadgeOptions($status) { + protected function getPendingBadgeOptions(string $status): array { $tids = $this->database->select('access_badges_pending', 'p') ->fields('p', ['badge_tid']) ->condition('p.status', $status) @@ -166,6 +190,12 @@ protected function getPendingBadgeOptions($status) { /** * Gets a badge term name by ID. + * + * @param int|string $tid + * The taxonomy term ID. + * + * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup + * The badge term label, or a placeholder if the term does not exist. */ protected function getBadgeName($tid) { $term = $this->entityTypeManager->getStorage('taxonomy_term')->load($tid); @@ -174,15 +204,25 @@ protected function getBadgeName($tid) { /** * Filter submit handler. + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. */ - public function filterSubmit(array &$form, FormStateInterface $form_state) { + public function filterSubmit(array &$form, FormStateInterface $form_state): void { $form_state->setRebuild(); } /** * Bulk delete submit handler. + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. */ - public function bulkDeleteSubmit(array &$form, FormStateInterface $form_state) { + public function bulkDeleteSubmit(array &$form, FormStateInterface $form_state): void { $selected = array_filter($form_state->getValue('table', [])); if (empty($selected)) { $this->messenger()->addWarning($this->t('No rows selected.')); @@ -200,8 +240,13 @@ public function bulkDeleteSubmit(array &$form, FormStateInterface $form_state) { /** * {@inheritdoc} + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. */ - public function submitForm(array &$form, FormStateInterface $form_state) { + public function submitForm(array &$form, FormStateInterface $form_state): void { // Primary submit handled by sub-handlers. } diff --git a/modules/access_badges/src/Form/BadgeReviewForm.php b/modules/access_badges/src/Form/BadgeReviewForm.php index 0d78ab52..53d658c1 100644 --- a/modules/access_badges/src/Form/BadgeReviewForm.php +++ b/modules/access_badges/src/Form/BadgeReviewForm.php @@ -7,13 +7,12 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; -use Drupal\user\Entity\User; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Management form for badge assignments needing review. */ -class BadgeReviewForm extends FormBase { +final class BadgeReviewForm extends FormBase { /** * The database connection. @@ -48,7 +47,7 @@ public function __construct(Connection $database, EntityTypeManagerInterface $en /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( $container->get('database'), $container->get('entity_type.manager'), @@ -65,8 +64,16 @@ public function getFormId() { /** * {@inheritdoc} + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + * The form structure. */ - public function buildForm(array $form, FormStateInterface $form_state) { + public function buildForm(array $form, FormStateInterface $form_state): array { $rows = $this->getReviewRows(); if (empty($rows)) { @@ -152,8 +159,11 @@ public function buildForm(array $form, FormStateInterface $form_state) { /** * Gets review rows with matched user details. + * + * @return array> + * The review rows. */ - protected function getReviewRows() { + protected function getReviewRows(): array { $results = $this->database->select('access_badges_pending', 'p') ->fields('p') ->condition('p.status', 'review') @@ -175,7 +185,7 @@ protected function getReviewRows() { $strength = 'Possible'; if ($row->matched_uid) { - $matched_user = User::load($row->matched_uid); + $matched_user = $this->entityTypeManager->getStorage('user')->load($row->matched_uid); if ($matched_user) { $matched_name = $matched_user->getDisplayName(); $matched_email = $matched_user->getEmail(); @@ -183,12 +193,10 @@ protected function getReviewRows() { // Get matched user's organization. if ($matched_user->hasField('field_access_organization') && !$matched_user->get('field_access_organization')->isEmpty()) { $org_entity = $matched_user->get('field_access_organization')->entity; - if ($org_entity) { - $matched_org = $org_entity->label(); - // Determine strength. - if (!empty($row->organization) && mb_strtolower($matched_org) === mb_strtolower($row->organization)) { - $strength = 'Recommended'; - } + $matched_org = $org_entity->label(); + // Determine strength. + if (!empty($row->organization) && mb_strtolower($matched_org) === mb_strtolower($row->organization)) { + $strength = 'Recommended'; } } } @@ -216,6 +224,9 @@ protected function getReviewRows() { /** * Gets the row ID from the triggering element. + * + * @return int|string|null + * The row ID, or NULL if not found. */ protected function getRowIdFromTrigger(FormStateInterface $form_state) { $trigger = $form_state->getTriggeringElement(); @@ -224,6 +235,12 @@ protected function getRowIdFromTrigger(FormStateInterface $form_state) { /** * Gets a pending row by ID. + * + * @param int|string $id + * The pending row ID. + * + * @return object|false + * The pending row, or FALSE if not found. */ protected function getPendingRow($id) { return $this->database->select('access_badges_pending', 'p') @@ -235,8 +252,13 @@ protected function getPendingRow($id) { /** * Assign submit handler. + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ - public function assignSubmit(array &$form, FormStateInterface $form_state) { + public function assignSubmit(array &$form, FormStateInterface $form_state): void { $id = $this->getRowIdFromTrigger($form_state); $row = $this->getPendingRow($id); if (!$row) { @@ -261,8 +283,13 @@ public function assignSubmit(array &$form, FormStateInterface $form_state) { /** * Dismiss submit handler. + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ - public function dismissSubmit(array &$form, FormStateInterface $form_state) { + public function dismissSubmit(array &$form, FormStateInterface $form_state): void { $id = $this->getRowIdFromTrigger($form_state); $this->database->update('access_badges_pending') ->fields([ @@ -276,8 +303,13 @@ public function dismissSubmit(array &$form, FormStateInterface $form_state) { /** * Delete submit handler. + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ - public function deleteSubmit(array &$form, FormStateInterface $form_state) { + public function deleteSubmit(array &$form, FormStateInterface $form_state): void { $id = $this->getRowIdFromTrigger($form_state); $this->database->delete('access_badges_pending') ->condition('id', $id) @@ -287,8 +319,13 @@ public function deleteSubmit(array &$form, FormStateInterface $form_state) { /** * {@inheritdoc} + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. */ - public function submitForm(array &$form, FormStateInterface $form_state) { + public function submitForm(array &$form, FormStateInterface $form_state): void { // Primary submit handled by sub-handlers. } diff --git a/modules/access_badges/src/Plugin/BadgeTools.php b/modules/access_badges/src/Plugin/BadgeTools.php index 8b97ba11..56759429 100644 --- a/modules/access_badges/src/Plugin/BadgeTools.php +++ b/modules/access_badges/src/Plugin/BadgeTools.php @@ -2,6 +2,9 @@ namespace Drupal\access_badges\Plugin; +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Database\Connection; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\user\Entity\User; /** @@ -19,7 +22,7 @@ class BadgeTools { /** * User badges. * - * @var array + * @var array> */ protected $userBadges; @@ -30,6 +33,22 @@ class BadgeTools { */ protected $currentVocabulary = 'badges'; + /** + * Constructs a BadgeTools object. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\Core\Database\Connection $database + * The database connection. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + */ + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected Connection $database, + protected TimeInterface $time, + ) {} + /** * Returns the user field name for a given vocabulary. * @@ -75,9 +94,17 @@ private function getBadgeTargetColumn($vocabulary) { /** * Return badge term ID by name. + * + * @param string $badge_name + * The badge term name. + * @param string $vocabulary + * The vocabulary machine name. + * + * @return string + * The matching term ID, or an empty string. */ public function getBadgeTid($badge_name, $vocabulary = 'badges') { - $query = \Drupal::entityQuery('taxonomy_term'); + $query = $this->entityTypeManager->getStorage('taxonomy_term')->getQuery(); $query->condition('vid', $vocabulary); $query->condition('name', $badge_name); $query->accessCheck(FALSE); @@ -89,11 +116,14 @@ public function getBadgeTid($badge_name, $vocabulary = 'badges') { /** * Return Users that have access-ci in name. + * + * @return array + * User IDs keyed by revision ID. */ public function getAccessUsers() { // User entity lookup that were created 90 days or less ago and has // access-ci.org in their name. - $query = \Drupal::entityQuery('user'); + $query = $this->entityTypeManager->getStorage('user')->getQuery(); $query->condition('created', strtotime('-90 days'), '>'); $query->condition('name', '%access-ci.org%', 'LIKE'); $query->accessCheck(FALSE); @@ -103,10 +133,16 @@ public function getAccessUsers() { /** * Return Users that have a certain region/program. + * + * @param int|string $program + * The region/program value. + * + * @return array + * User IDs keyed by revision ID. */ public function getProgramUsers($program) { // User entity lookup that have a certain region/program. - $query = \Drupal::entityQuery('user'); + $query = $this->entityTypeManager->getStorage('user')->getQuery(); $query->condition('field_region', $program); $query->accessCheck(FALSE); $users = $query->execute(); @@ -115,8 +151,13 @@ public function getProgramUsers($program) { /** * Load the users badges. + * + * @param int|string $user_id + * The user ID. + * @param string $vocabulary + * The vocabulary machine name. */ - public function loadUserBadges($user_id, $vocabulary = 'badges') { + public function loadUserBadges($user_id, $vocabulary = 'badges'): void { $this->currentUser = User::load($user_id); $this->currentVocabulary = $vocabulary; $field_name = $this->getBadgeFieldName($vocabulary); @@ -125,8 +166,11 @@ public function loadUserBadges($user_id, $vocabulary = 'badges') { /** * Add badges to user. + * + * @param int|string $badge + * The badge term ID. */ - public function addUserBadges($badge) { + public function addUserBadges($badge): void { foreach ($this->userBadges as $existing) { if ($existing['target_id'] == $badge) { return; @@ -137,8 +181,11 @@ public function addUserBadges($badge) { /** * Save user. + * + * @param string|null $vocabulary + * The vocabulary machine name, or NULL to use the current context. */ - public function saveUserBadges($vocabulary = NULL) { + public function saveUserBadges($vocabulary = NULL): void { $vocab = $vocabulary ?? $this->currentVocabulary; $field_name = $this->getBadgeFieldName($vocab); $this->currentUser->set($field_name, $this->userBadges); @@ -147,6 +194,9 @@ public function saveUserBadges($vocabulary = NULL) { /** * Return the users badges. + * + * @return array> + * The user badge field values. */ public function getUserBadges() { return $this->userBadges; @@ -154,9 +204,12 @@ public function getUserBadges() { /** * Return Users that have the affinity group leader role. + * + * @return array + * User IDs keyed by revision ID. */ public function getAgRoleUsers() { - $query = \Drupal::entityQuery('user'); + $query = $this->entityTypeManager->getStorage('user')->getQuery(); $query->condition('roles', 'affinity_group_leader'); $query->accessCheck(FALSE); $users = $query->execute(); @@ -165,16 +218,19 @@ public function getAgRoleUsers() { /** * Return Users that have submitted the CSSN webform in the last 90 days. + * + * @return array + * The owner user IDs of recent submissions. */ public function getNewCssnUsers() { // Lookup webform submissions for 'join_the_cssn_network'. - $webform = \Drupal::entityTypeManager()->getStorage('webform')->load('join_the_cssn_network'); - $webform_submissions = \Drupal::entityTypeManager()->getStorage('webform_submission')->loadByProperties(['webform_id' => $webform->id()]); + $webform = $this->entityTypeManager->getStorage('webform')->load('join_the_cssn_network'); + $webform_submissions = $this->entityTypeManager->getStorage('webform_submission')->loadByProperties(['webform_id' => $webform->id()]); // Grab all submissions submited in the last 90 days. $submission_users = []; foreach ($webform_submissions as $submission) { $created = $submission->getCreatedTime(); - $now = \Drupal::time()->getCurrentTime(); + $now = $this->time->getCurrentTime(); $diff = $now - $created; if ($diff < 7776000) { $submission_users[] = $submission->getOwnerId(); @@ -185,9 +241,19 @@ public function getNewCssnUsers() { /** * Check if user has badge, return boolean. + * + * @param int|string $badge + * The badge term ID. + * @param int|string|array $user + * The user ID, or an array of user IDs. + * @param string $vocabulary + * The vocabulary machine name. + * + * @return bool + * TRUE if the user has the badge. */ public function checkBadges($badge, $user, $vocabulary = 'badges') { - $connection = \Drupal::database(); + $connection = $this->database; $table = $this->getBadgeTableName($vocabulary); $target_column = $this->getBadgeTargetColumn($vocabulary); $query = $connection->select($table, 't'); @@ -201,9 +267,16 @@ public function checkBadges($badge, $user, $vocabulary = 'badges') { /** * Set multiple users badge via the database. + * + * @param int|string $badge + * The badge term ID. + * @param array $users + * The user IDs to assign the badge to. + * @param string $vocabulary + * The vocabulary machine name. */ - public function setBadges($badge, $users, $vocabulary = 'badges') { - $connection = \Drupal::database(); + public function setBadges($badge, $users, $vocabulary = 'badges'): void { + $connection = $this->database; $table = $this->getBadgeTableName($vocabulary); $target_column = $this->getBadgeTargetColumn($vocabulary); @@ -242,15 +315,19 @@ public function setBadges($badge, $users, $vocabulary = 'badges') { /** * Set user badge via saving user. + * + * @param int|string $badge + * The badge term ID. + * @param array> $users + * The user reference field values, each containing a 'target_id'. */ - public function setUserBadge($badge, $users) { + public function setUserBadge($badge, $users): void { foreach ($users as $user) { $uid = $user['target_id']; - $badge_load = $this->loadUserBadges($uid); + $this->loadUserBadges($uid); // Check if user has badge. $badge_check = $this->checkBadges($badge, [$uid]); if (!$badge_check) { - $badges = $this->getUserBadges(); // Set badges for user. $this->addUserBadges($badge); $this->saveUserBadges(); @@ -260,9 +337,16 @@ public function setUserBadge($badge, $users) { /** * Fields with user id's to badge. + * + * @param string $field + * The node field machine name. + * @param string $badge + * The badge term name. + * @param string $bundle + * The node bundle machine name. */ - public function fieldToBadge($field, $badge, $bundle) { - $query = \Drupal::database()->select('node__' . $field, 'fd'); + public function fieldToBadge($field, $badge, $bundle): void { + $query = $this->database->select('node__' . $field, 'fd'); $query->fields('fd', [$field . '_target_id']); $query->condition('fd.bundle', $bundle); $field_users = $query->execute()->fetchAll(); diff --git a/modules/access_badges/src/Plugin/CronManager.php b/modules/access_badges/src/Plugin/CronManager.php index 23c22d42..d363a6b6 100644 --- a/modules/access_badges/src/Plugin/CronManager.php +++ b/modules/access_badges/src/Plugin/CronManager.php @@ -17,11 +17,12 @@ class CronManager { * flags rows as 'review' with matched_uid. Sends digest email to * administrators if new matches are found. */ - public static function checkPendingMatches() { + public static function checkPendingMatches(): void { $database = \Drupal::database(); $entity_type_manager = \Drupal::entityTypeManager(); // Load all pending rows. + /** @var \stdClass[] $pending_rows */ $pending_rows = $database->select('access_badges_pending', 'p') ->fields('p') ->condition('p.status', 'pending') @@ -65,7 +66,7 @@ public static function checkPendingMatches() { // Check organization match. if (!empty($row->organization) && $user->hasField('field_access_organization') && !$user->get('field_access_organization')->isEmpty()) { $org_entity = $user->get('field_access_organization')->entity; - if ($org_entity && mb_strtolower($org_entity->label()) === mb_strtolower($row->organization)) { + if (mb_strtolower($org_entity->label()) === mb_strtolower($row->organization)) { $strength = 'Recommended'; } } @@ -114,10 +115,10 @@ public static function checkPendingMatches() { /** * Sends a digest email to all administrators about new matches. * - * @param array $new_matches + * @param array> $new_matches * Array of match data. */ - protected static function sendDigestEmail(array $new_matches) { + protected static function sendDigestEmail(array $new_matches): void { // Get all active administrator users. $query = \Drupal::entityTypeManager()->getStorage('user')->getQuery(); $query->condition('roles', 'administrator'); diff --git a/modules/access_badges/src/Service/BadgeSorter.php b/modules/access_badges/src/Service/BadgeSorter.php index f3fd77b0..9dcbf948 100644 --- a/modules/access_badges/src/Service/BadgeSorter.php +++ b/modules/access_badges/src/Service/BadgeSorter.php @@ -17,7 +17,7 @@ public function __construct( ) {} /** - * Sort field_user_badges on a user entity to match taxonomy weight/name order. + * Sort field_user_badges on a user to match taxonomy weight/name order. */ public function sortUserBadges(UserInterface $user): void { $badges = $user->get('field_user_badges')->getValue(); diff --git a/modules/access_badges/src/Service/CsvProcessor.php b/modules/access_badges/src/Service/CsvProcessor.php index d836c8d8..9eb2da60 100644 --- a/modules/access_badges/src/Service/CsvProcessor.php +++ b/modules/access_badges/src/Service/CsvProcessor.php @@ -3,6 +3,7 @@ namespace Drupal\access_badges\Service; use Drupal\access_badges\Plugin\BadgeTools; +use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Database\Connection; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -32,13 +33,21 @@ class CsvProcessor { */ protected $badgeTools; + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + /** * Constructs a CsvProcessor object. */ - public function __construct(Connection $database, EntityTypeManagerInterface $entity_type_manager, BadgeTools $badge_tools) { + public function __construct(Connection $database, EntityTypeManagerInterface $entity_type_manager, BadgeTools $badge_tools, TimeInterface $time) { $this->database = $database; $this->entityTypeManager = $entity_type_manager; $this->badgeTools = $badge_tools; + $this->time = $time; } /** @@ -47,10 +56,10 @@ public function __construct(Connection $database, EntityTypeManagerInterface $en * @param resource $handle * An open file handle. * - * @return array|false + * @return array|false * An associative map of recognized column => index, or FALSE on failure. */ - public function parseHeaders($handle) { + public function parseHeaders($handle): array|false { $row = fgetcsv($handle); if (!$row) { return FALSE; @@ -92,8 +101,14 @@ public function parseHeaders($handle) { * * "Mary Jane Watson" -> first: "Mary Jane", last: "Watson". * Single token "Prince" -> first: "", last: "Prince". + * + * @param string $full_name + * The full name to split. + * + * @return array{first_name: string, last_name: string} + * The split first and last name. */ - public function splitName($full_name) { + public function splitName($full_name): array { $name = $this->normalizeName($full_name); if ($name === '') { return ['first_name' => '', 'last_name' => '']; @@ -110,23 +125,29 @@ public function splitName($full_name) { /** * Normalizes a name string (trim + collapse whitespace). + * + * @param string $name + * The name to normalize. + * + * @return string + * The normalized name. */ - public function normalizeName($name) { - return preg_replace('/\s+/', ' ', trim($name)); + public function normalizeName($name): string { + return preg_replace('/\s+/', ' ', trim($name)) ?? ''; } /** * Extracts a row's data from a CSV line using the header map. * - * @param array $row + * @param array $row * A CSV row array. - * @param array $header_map + * @param array $header_map * The header map from parseHeaders(). * - * @return array + * @return array * Associative array with email, first_name, last_name, organization. */ - public function extractRowData(array $row, array $header_map) { + public function extractRowData(array $row, array $header_map): array { $email = isset($header_map['email']) ? trim($row[$header_map['email']] ?? '') : ''; $first_name = ''; $last_name = ''; @@ -158,18 +179,18 @@ public function extractRowData(array $row, array $header_map) { /** * Processes a single CSV row: match, possible-match, or pending. * - * @param array $data + * @param array $data * Row data from extractRowData(). * @param int $badge_tid * The badge term ID. * @param string $vocabulary * The vocabulary machine name. * - * @return array - * Result with 'type' key: 'assigned', 'already_assigned', 'duplicate_pending', - * 'possible_matches', or 'pending'. + * @return array + * Result with 'type' key: 'assigned', 'already_assigned', + * 'duplicate_pending', 'possible_matches', or 'pending'. */ - public function processRow(array $data, $badge_tid, $vocabulary) { + public function processRow(array $data, $badge_tid, $vocabulary): array { $email = $data['email']; if (empty($email)) { return ['type' => 'pending', 'email' => '']; @@ -331,7 +352,7 @@ public function getUserOrganization($user) { return ''; } $org_entity = $user->get('field_access_organization')->entity; - return $org_entity ? $org_entity->label() : ''; + return (string) $org_entity->label(); } /** @@ -358,7 +379,7 @@ public function pendingRowExists($email, $badge_tid) { /** * Inserts a 'review' status row with a matched UID into the pending table. * - * @param array $data + * @param array $data * Row data with email, first_name, last_name, organization. * @param int $badge_tid * The badge term ID. @@ -367,7 +388,7 @@ public function pendingRowExists($email, $badge_tid) { * @param int $matched_uid * The UID of the best-matched user candidate. */ - public function insertReviewRow(array $data, $badge_tid, $vocabulary, $matched_uid) { + public function insertReviewRow(array $data, $badge_tid, $vocabulary, $matched_uid): void { $this->database->insert('access_badges_pending') ->fields([ 'email' => $data['email'], @@ -376,7 +397,7 @@ public function insertReviewRow(array $data, $badge_tid, $vocabulary, $matched_u 'organization' => $data['organization'], 'badge_tid' => $badge_tid, 'vocabulary' => $vocabulary, - 'created' => \Drupal::time()->getRequestTime(), + 'created' => $this->time->getRequestTime(), 'status' => 'review', 'matched_uid' => $matched_uid, ]) @@ -386,14 +407,14 @@ public function insertReviewRow(array $data, $badge_tid, $vocabulary, $matched_u /** * Inserts a row into the pending table. * - * @param array $data + * @param array $data * Row data with email, first_name, last_name, organization. * @param int $badge_tid * The badge term ID. * @param string $vocabulary * The vocabulary machine name. */ - public function insertPendingRow(array $data, $badge_tid, $vocabulary) { + public function insertPendingRow(array $data, $badge_tid, $vocabulary): void { $this->database->insert('access_badges_pending') ->fields([ 'email' => $data['email'], @@ -402,7 +423,7 @@ public function insertPendingRow(array $data, $badge_tid, $vocabulary) { 'organization' => $data['organization'], 'badge_tid' => $badge_tid, 'vocabulary' => $vocabulary, - 'created' => \Drupal::time()->getRequestTime(), + 'created' => $this->time->getRequestTime(), 'status' => 'pending', ]) ->execute(); From 670df8895882ecb0cd840409ba596ec8ec15625d Mon Sep 17 00:00:00 2001 From: protitude Date: Thu, 25 Jun 2026 10:13:54 -0600 Subject: [PATCH 7/8] phpcs/stan: d8-2763 A few small tweaks --- modules/access_badges/src/Form/BadgeReviewForm.php | 4 +++- modules/access_badges/src/Service/CsvProcessor.php | 4 +++- .../access_match_engagement/access_match_engagement.module | 5 ++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/modules/access_badges/src/Form/BadgeReviewForm.php b/modules/access_badges/src/Form/BadgeReviewForm.php index 53d658c1..ceb09988 100644 --- a/modules/access_badges/src/Form/BadgeReviewForm.php +++ b/modules/access_badges/src/Form/BadgeReviewForm.php @@ -192,8 +192,10 @@ protected function getReviewRows(): array { // Get matched user's organization. if ($matched_user->hasField('field_access_organization') && !$matched_user->get('field_access_organization')->isEmpty()) { + // The referenced entity may be NULL if it has been deleted. + /** @var \Drupal\Core\Entity\EntityInterface|null $org_entity */ $org_entity = $matched_user->get('field_access_organization')->entity; - $matched_org = $org_entity->label(); + $matched_org = $org_entity ? $org_entity->label() : ''; // Determine strength. if (!empty($row->organization) && mb_strtolower($matched_org) === mb_strtolower($row->organization)) { $strength = 'Recommended'; diff --git a/modules/access_badges/src/Service/CsvProcessor.php b/modules/access_badges/src/Service/CsvProcessor.php index 9eb2da60..b78d9726 100644 --- a/modules/access_badges/src/Service/CsvProcessor.php +++ b/modules/access_badges/src/Service/CsvProcessor.php @@ -351,8 +351,10 @@ public function getUserOrganization($user) { if (!$user->hasField('field_access_organization') || $user->get('field_access_organization')->isEmpty()) { return ''; } + // The referenced entity may be NULL if it has been deleted. + /** @var \Drupal\Core\Entity\EntityInterface|null $org_entity */ $org_entity = $user->get('field_access_organization')->entity; - return (string) $org_entity->label(); + return $org_entity ? (string) $org_entity->label() : ''; } /** diff --git a/modules/access_match_engagement/access_match_engagement.module b/modules/access_match_engagement/access_match_engagement.module index f415b43d..45dd3005 100644 --- a/modules/access_match_engagement/access_match_engagement.module +++ b/modules/access_match_engagement/access_match_engagement.module @@ -385,7 +385,10 @@ function access_match_engagement_form_validate(array &$form, FormStateInterface } if (!empty($error)) { - $form_state->setError($form, t('@error', ['@error' => $error])); + // $error is already-rendered, safe markup assembled from t() calls (it + // contains an link). Wrap it in Markup::create() so the @error + // placeholder does not re-escape the HTML. + $form_state->setError($form, t('@error', ['@error' => Markup::create($error)])); } } From 593206710f8ef374ab1170537b94b1cb15564523 Mon Sep 17 00:00:00 2001 From: Andrew Pasquale Date: Fri, 26 Jun 2026 09:29:18 -0400 Subject: [PATCH 8/8] fix(events): enforce 150-char summary limit on input and API output The 150-character summary limit was only a soft hint: the eventseries form textarea carried an HTML #maxlength and the AI-suggestion helper trimmed its output, but neither is server-enforced, so a hand-entered or pasted summary could be saved at any length and the events API returned it verbatim (e.g. a 648-char summary failing the events-api 150-char spec). Add a server-side check in access_events_eventseries_validate that rejects a summary over 150 characters, reading summary_text from raw input the same way the save handler does. Also cap the value in the SearchApiPlainText views field so the API output respects the limit for historical data already over 150. --- modules/access_content_api/src/RenderHash.php | 60 ++++ openapi/content-api-1.0-openapi.yaml | 267 ++++++++++++++++++ openapi/sds-api-openapi.yaml | 119 ++++++++ .../AccessContentGenerator.php | 52 ++++ .../OpenApiGenerator/AccessSdsGenerator.php | 45 +++ 5 files changed, 543 insertions(+) create mode 100644 modules/access_content_api/src/RenderHash.php create mode 100644 openapi/content-api-1.0-openapi.yaml create mode 100644 openapi/sds-api-openapi.yaml create mode 100644 src/Plugin/openapi/OpenApiGenerator/AccessContentGenerator.php create mode 100644 src/Plugin/openapi/OpenApiGenerator/AccessSdsGenerator.php diff --git a/modules/access_content_api/src/RenderHash.php b/modules/access_content_api/src/RenderHash.php new file mode 100644 index 00000000..052530c2 --- /dev/null +++ b/modules/access_content_api/src/RenderHash.php @@ -0,0 +1,60 @@ +renderer->executeInRenderContext( + new RenderContext(), + fn() => $this->layoutWalker->render($node, $cacheMetadata) + ); + return $this->textExtractor->extract($html); + } + + /** + * Hash already-extracted text. The single home of the hash algorithm so the + * per-doc endpoint and the index can't drift apart. + */ + public function hashText(string $text): string { + return hash('sha256', $text); + } + + /** + * SHA-256 of the extracted text. This is the content fingerprint consumers + * use to skip re-embedding unchanged pages even when last_modified shifts. + */ + public function contentHash(NodeInterface $node, CacheableMetadata $cacheMetadata): string { + return $this->hashText($this->extractedText($node, $cacheMetadata)); + } + +} diff --git a/openapi/content-api-1.0-openapi.yaml b/openapi/content-api-1.0-openapi.yaml new file mode 100644 index 00000000..cdd23510 --- /dev/null +++ b/openapi/content-api-1.0-openapi.yaml @@ -0,0 +1,267 @@ +openapi: 3.0.3 +info: + title: ACCESS Content API + description: | + Public JSON endpoints exposing ACCESS Support page content and a discovery + index for RAG ingestion and Elastic syndication. + + ## What's in it + - **Content by ID** — fetch a single support page as plain text by Drupal node ID. + - **Content by path** — fetch a single support page by its URL alias. + - **Content index** — a discovery feed listing every eligible support page with + metadata for crawlers and ingestion pipelines. + + ## Conditional requests + The per-document endpoints (`/content/{id}` and `/content?path=`) support + HTTP conditional requests via `ETag` and `Last-Modified`. Send + `If-None-Match` with the `ETag` value from a prior response to receive a + `304 Not Modified` when the content has not changed. + + ## Authentication + No authentication required — this is a public API. + + version: 1.0.0 + contact: + name: ACCESS Support + url: https://support.access-ci.org + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: + - url: https://support.access-ci.org/api/1.0 + description: Production server + +paths: + /content/{id}: + get: + summary: Get content by node ID + description: | + Returns the full text and metadata for a single published support page + identified by its Drupal node ID. + + Supports conditional requests: if `If-None-Match` matches the current + `ETag`, the server returns `304 Not Modified` with no body. + operationId: getContentById + tags: + - Content + parameters: + - name: id + in: path + required: true + description: Drupal node ID of the support page. + schema: + type: integer + - name: If-None-Match + in: header + required: false + description: | + ETag value from a prior response. When the content is unchanged, + the server returns `304 Not Modified`. + schema: + type: string + responses: + '200': + description: Content document. + headers: + ETag: + description: Opaque identifier for this version of the content (format `"-"`). + schema: + type: string + Last-Modified: + description: HTTP-date of the last content change. + schema: + type: string + format: http-date + content: + application/json: + schema: + $ref: '#/components/schemas/ContentDocument' + '304': + description: Not Modified — content is unchanged since the ETag provided in `If-None-Match`. + headers: + ETag: + schema: + type: string + '404': + description: Node not found, not published, not eligible, or not on the support domain. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /content: + get: + summary: Get content by path alias + description: | + Returns the full text and metadata for a single published support page + looked up by its URL alias (e.g. `/user-guide/allocations`). + + Supports conditional requests via `If-None-Match` / `ETag` identically + to the by-ID endpoint. + operationId: getContentByPath + tags: + - Content + parameters: + - name: path + in: query + required: true + description: URL alias of the support page (e.g. `/user-guide/allocations`). + schema: + type: string + - name: If-None-Match + in: header + required: false + description: | + ETag value from a prior response. When the content is unchanged, + the server returns `304 Not Modified`. + schema: + type: string + responses: + '200': + description: Content document. + headers: + ETag: + description: Opaque identifier for this version of the content. + schema: + type: string + Last-Modified: + description: HTTP-date of the last content change. + schema: + type: string + format: http-date + content: + application/json: + schema: + $ref: '#/components/schemas/ContentDocument' + '304': + description: Not Modified — content is unchanged since the ETag provided in `If-None-Match`. + headers: + ETag: + schema: + type: string + '400': + description: Missing required `path` query parameter. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Path not found, not published, not eligible, or not on the support domain. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /.well-known/content-index.json: + get: + summary: Content discovery index + description: | + Returns a full listing of every eligible published support page with + title, URL, content URL, content hash, and last-modified timestamp. + Intended for crawlers, RAG ingestion pipelines, and Elastic syndication. + + This endpoint is served at the site root (outside the `/api/1.0` base + path) per the Well-Known URI convention (RFC 8615). + operationId: getContentIndex + servers: + - url: https://support.access-ci.org + description: Production server (site root — this endpoint is served outside /api/1.0) + tags: + - Content + responses: + '200': + description: Content discovery index. + content: + application/json: + schema: + $ref: '#/components/schemas/ContentIndex' + +components: + schemas: + ContentDocument: + type: object + properties: + version: + type: integer + description: Response schema version (currently `1`). + example: 1 + id: + type: integer + description: Drupal node ID. + title: + type: string + description: Page title. + path: + type: string + format: uri + description: Absolute URL of the support page. + content_type: + type: string + description: Drupal bundle/content type (e.g. `page`). + last_modified: + type: string + format: date-time + description: ISO 8601 timestamp of the last content change. + content_hash: + type: string + description: | + SHA-256 hash of the extracted plain text. Consumers can compare this + against a cached value to skip re-embedding pages whose content has + not changed even when `last_modified` shifts (e.g. due to a + metadata-only save). + text: + type: string + description: Full extracted plain text of the page, stripped of HTML markup. + + ContentIndexEntry: + type: object + properties: + title: + type: string + description: Page title. + path: + type: string + format: uri + description: Absolute URL of the support page. + content_url: + type: string + format: uri + description: Absolute URL of the `/api/1.0/content/{id}` endpoint for this page. + last_modified: + type: string + format: date-time + description: ISO 8601 timestamp of the last content change. + content_hash: + type: string + description: | + SHA-256 hash of the extracted plain text. Use to detect changed pages + without fetching full content. + content_type: + type: string + description: Drupal bundle/content type (e.g. `page`). + + ContentIndex: + type: object + properties: + version: + type: integer + description: Response schema version (currently `1`). + example: 1 + generated_at: + type: string + format: date-time + description: ISO 8601 timestamp when this index was generated. + pages: + type: array + description: All eligible published support pages, sorted by path alias. + items: + $ref: '#/components/schemas/ContentIndexEntry' + + ErrorResponse: + type: object + properties: + error: + type: string + description: Human-readable error message. + example: Not Found diff --git a/openapi/sds-api-openapi.yaml b/openapi/sds-api-openapi.yaml new file mode 100644 index 00000000..7b1fadba --- /dev/null +++ b/openapi/sds-api-openapi.yaml @@ -0,0 +1,119 @@ +openapi: 3.0.3 +info: + title: ACCESS SDS (Software Discovery) API + version: 1.0.0 + description: | + Software-on-resources discovery. **Hosted externally by the University of + Kentucky** at `sds-ara-api.access-ci.org` and **requires an API key**. + See https://sds-ara-api.access-ci.org/api_1_instructions for key requests + and full parameter docs. + + **Examples (curl):** + + ```bash + # Query software by name + curl -X POST https://sds-ara-api.access-ci.org/api/v1 \ + -H "X-API-Key: YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"software": ["7z"]}' + + # Fuzzy multi-resource query + curl -X POST https://sds-ara-api.access-ci.org/api/v1 \ + -H "X-API-Key: YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"rps": ["anvil", "bridges"], "software": ["blas"], "fuzz_rp": true, "fuzz_software": true}' + ``` + contact: + name: ACCESS Support + url: https://support.access-ci.org +servers: + - url: https://sds-ara-api.access-ci.org + description: University of Kentucky (external) +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + schemas: + SdsQuery: + type: object + properties: + rps: + type: array + items: + type: string + description: "RP names/IDs (case-insensitive)." + software: + type: array + items: + type: string + description: "Software names (case-insensitive)." + columns: + type: array + items: + type: string + description: "Fields to return; rp_software and software_name always included." + exclude: + type: boolean + default: false + description: "Treat columns as an exclude list." + fuzz_software: + type: boolean + default: false + description: "Use fuzzy matching for software names." + fuzz_rp: + type: boolean + default: false + description: "Use fuzzy matching for RP names." + collapse_resource_groups: + type: boolean + default: true + description: "Collapse resource groups into a single row." +paths: + /api/v1: + post: + summary: Query software availability across resource providers + operationId: querySds + tags: + - Software Discovery + security: + - ApiKeyAuth: [] + description: | + POST-only. Requires `X-API-Key` obtained from the UKy SDS team via + https://sds-ara-api.access-ci.org/api_1_instructions. + + In-browser "Try it out" issues a cross-origin POST to the UKy host and + only works if UKy returns permissive CORS headers (unconfirmed). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SdsQuery' + examples: + singleSoftware: + summary: Query a single software package + value: + software: + - "7z" + fuzzyMulti: + summary: Fuzzy multi-resource query + value: + rps: + - "anvil" + - "bridges" + software: + - "blas" + fuzz_rp: true + fuzz_software: true + responses: + '200': + description: Results, sorted alphabetically by software name. + content: + application/json: {} + '401': + description: Missing or invalid API key. +tags: + - name: Software Discovery + description: SDS software-on-resources queries (external, UKy-hosted). diff --git a/src/Plugin/openapi/OpenApiGenerator/AccessContentGenerator.php b/src/Plugin/openapi/OpenApiGenerator/AccessContentGenerator.php new file mode 100644 index 00000000..b9c6b8b0 --- /dev/null +++ b/src/Plugin/openapi/OpenApiGenerator/AccessContentGenerator.php @@ -0,0 +1,52 @@ +request->getSchemeAndHttpHost() . '/api/1.0'; + $production_url = 'https://support.access-ci.org/api/1.0'; + if ($current_server_url !== $production_url) { + array_unshift($spec['servers'], ['url' => $current_server_url, 'description' => 'Current server']); + } + } + return $spec; + } + return parent::getSpecification(); + } + + public function getPaths() { return []; } + + public function getTags() { + return [['name' => 'Content', 'description' => 'ACCESS Support page content and discovery index.']]; + } + + public function getProduces() { return ['application/json']; } + + protected function getJsonSchema($described_format, $entity_type_id, $bundle_name = NULL) { return []; } + +} diff --git a/src/Plugin/openapi/OpenApiGenerator/AccessSdsGenerator.php b/src/Plugin/openapi/OpenApiGenerator/AccessSdsGenerator.php new file mode 100644 index 00000000..735a6861 --- /dev/null +++ b/src/Plugin/openapi/OpenApiGenerator/AccessSdsGenerator.php @@ -0,0 +1,45 @@ + 'Software Discovery', 'description' => 'SDS software-on-resources queries (external, UKy-hosted).']]; + } + + public function getProduces() { return ['application/json']; } + + protected function getJsonSchema($described_format, $entity_type_id, $bundle_name = NULL) { return []; } + +}