diff --git a/rl.routing.yml b/rl.routing.yml
index 98e6e53..401e8fa 100644
--- a/rl.routing.yml
+++ b/rl.routing.yml
@@ -1,11 +1,20 @@
rl.reports.experiments:
path: '/admin/reports/rl'
defaults:
- _controller: '\Drupal\rl\Controller\ReportsController::experimentsOverview'
+ _form: '\Drupal\rl\Form\ExperimentsOverviewForm'
_title: 'RL: A/B Testing'
requirements:
_permission: 'view rl reports'
+rl.experiment.delete_multiple:
+ path: '/admin/reports/rl/delete/{selection}'
+ defaults:
+ _form: '\Drupal\rl\Form\ExperimentDeleteMultipleForm'
+ _title: 'Delete experiments'
+ requirements:
+ _permission: 'administer rl experiments'
+ selection: '[a-zA-Z0-9_-]+'
+
rl.reports.experiment_detail:
path: '/admin/reports/rl/experiment/{experiment_id}'
defaults:
diff --git a/rl.services.yml b/rl.services.yml
index ba7d82f..5dfaac4 100644
--- a/rl.services.yml
+++ b/rl.services.yml
@@ -1,4 +1,8 @@
services:
+ rl.experiment_deletion:
+ class: Drupal\rl\Service\ExperimentDeletion
+ arguments: ['@database']
+
logger.channel.rl:
parent: logger.channel_base
arguments: ['rl']
diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php
index 8c1e68a..911c283 100644
--- a/src/Controller/ReportsController.php
+++ b/src/Controller/ReportsController.php
@@ -5,11 +5,11 @@
use Drupal\Component\Utility\Html;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Datetime\DateFormatterInterface;
-use Drupal\Core\Link;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Url;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\rl\Decorator\ExperimentDecoratorManager;
+use Drupal\rl\Form\ExperimentsOverviewForm;
use Drupal\rl\Registry\ExperimentRegistryInterface;
use Drupal\rl\Service\ArmDataValidator;
use Drupal\rl\Storage\ExperimentDataStorageInterface;
@@ -129,103 +129,7 @@ public static function create(ContainerInterface $container): static {
* A render array.
*/
public function experimentsOverview() {
- $header = [
- $this->t('Operations'),
- $this->t('Experiment'),
- $this->t('Source'),
- $this->t('Impressions'),
- $this->t('Conversions'),
- $this->t('Variants'),
- $this->t('Created'),
- $this->t('Last Activity'),
- ];
-
- $rows = [];
-
- // Get all experiments with their statistics from storage.
- $experiments = $this->experimentStorage->getExperimentsWithStats();
-
- foreach ($experiments as $experiment) {
- $arms_count = $experiment->arm_count;
- $total_rewards = $experiment->total_rewards;
-
- $operations = [];
-
- $detail_url = Url::fromRoute('rl.reports.experiment_detail', [
- 'experiment_id' => $experiment->experiment_id,
- ]);
- $operations[] = Link::fromTextAndUrl($this->t('View'), $detail_url);
-
- if ($this->currentUser()->hasPermission('administer rl experiments')) {
- $delete_url = Url::fromRoute('rl.experiment.delete', [
- 'experiment_id' => $experiment->experiment_id,
- ]);
- $operations[] = Link::fromTextAndUrl($this->t('Delete'), $delete_url);
- }
-
- $operations_markup = implode(' | ', array_map(function ($link) {
- return $link->toString();
- }, $operations));
-
- // Format last activity timestamp - use totals_updated if available,
- // otherwise registered_at.
- $last_activity_timestamp = $experiment->totals_updated ?: $experiment->registered_at;
- $last_activity = $last_activity_timestamp > 0
- ? $this->dateFormatter->format($last_activity_timestamp, 'short')
- : $this->t('Never');
-
- $created = $experiment->registered_at > 0
- ? $this->dateFormatter->format($experiment->registered_at, 'short')
- : $this->t('Never');
-
- // Use experiment name from registry or fallback to experiment ID.
- $experiment_name = $experiment->experiment_name ?: $experiment->experiment_id;
-
- $rows[] = [
- ['data' => ['#markup' => $operations_markup]],
- $experiment_name,
- $experiment->module,
- $experiment->total_turns ?: 0,
- $total_rewards,
- $arms_count,
- $created,
- $last_activity,
- ];
- }
-
- $build = [];
-
- $actions = [];
-
- if ($this->currentUser()->hasPermission('administer rl experiments')) {
- $add_url = Url::fromRoute('rl.experiment.add');
- $actions[] = [
- '#type' => 'link',
- '#title' => $this->t('Add experiment'),
- '#url' => $add_url,
- '#attributes' => ['class' => ['button', 'button--primary']],
- ];
- }
-
- if (!empty($actions)) {
- $build['actions'] = [
- '#type' => 'container',
- '#attributes' => ['class' => ['rl-actions']],
- 'links' => $actions,
- '#suffix' => '
',
- ];
- }
-
- $build['table'] = [
- '#theme' => 'table',
- '#header' => $header,
- '#rows' => $rows,
- '#empty' => $this->t('No experiments found.'),
- ];
-
- $build['#prefix'] = '
' . $this->t('Tip: Deleting an experiment resets its data. Experiments auto-recreate on next render.') . '
'; - - return $build; + return $this->formBuilder()->getForm(ExperimentsOverviewForm::class); } /** diff --git a/src/Form/ExperimentDeleteForm.php b/src/Form/ExperimentDeleteForm.php index 0e56c48..628c2e6 100644 --- a/src/Form/ExperimentDeleteForm.php +++ b/src/Form/ExperimentDeleteForm.php @@ -6,6 +6,7 @@ use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Database\Connection; use Drupal\Core\Url; +use Drupal\rl\Service\ExperimentDeletion; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -30,7 +31,7 @@ class ExperimentDeleteForm extends ConfirmFormBase { /** * Constructs an ExperimentDeleteForm object. */ - public function __construct(Connection $database) { + public function __construct(Connection $database, protected ExperimentDeletion $experimentDeletion) { $this->database = $database; } @@ -40,7 +41,8 @@ public function __construct(Connection $database) { public static function create(ContainerInterface $container): static { // @phpstan-ignore new.static return new static( - $container->get('database') + $container->get('database'), + $container->get('rl.experiment_deletion') ); } @@ -99,31 +101,14 @@ public function getCancelUrl() { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { - $transaction = $this->database->startTransaction(); - try { - $this->database->delete('rl_arm_data') - ->condition('experiment_id', $this->experimentId) - ->execute(); - - $this->database->delete('rl_experiment_totals') - ->condition('experiment_id', $this->experimentId) - ->execute(); - - $this->database->delete('rl_arm_snapshots') - ->condition('experiment_id', $this->experimentId) - ->execute(); - - $this->database->delete('rl_experiment_registry') - ->condition('experiment_id', $this->experimentId) - ->execute(); + $this->experimentDeletion->deleteMultiple([$this->experimentId]); $this->messenger()->addStatus($this->t('Experiment %id has been deleted.', [ '%id' => $this->experimentId, ])); } catch (\Exception $e) { - $transaction->rollBack(); $this->messenger()->addError($this->t('An error occurred while deleting the experiment.')); $this->getLogger('rl')->error('Error deleting experiment @id: @message', [ '@id' => $this->experimentId, diff --git a/src/Form/ExperimentDeleteMultipleForm.php b/src/Form/ExperimentDeleteMultipleForm.php new file mode 100644 index 0000000..8493f02 --- /dev/null +++ b/src/Form/ExperimentDeleteMultipleForm.php @@ -0,0 +1,136 @@ +get('tempstore.private'), + $container->get('rl.experiment_deletion'), + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'rl_experiment_delete_multiple_confirm'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return $this->formatPlural(count($this->selection), 'Are you sure you want to delete this experiment?', 'Are you sure you want to delete these @count experiments?'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return $this->t('This will permanently delete the selected experiments and all their data (turns, rewards, totals, snapshots). This action cannot be undone. Experiments auto-recreate on next render.'); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl(): Url { + return Url::fromRoute('rl.reports.experiments', [], [ + 'query' => array_intersect_key($this->getRequest()->query->all(), array_flip(['order', 'sort', 'page'])), + ]); + } + + /** + * {@inheritdoc} + * + * @return array|\Symfony\Component\HttpFoundation\RedirectResponse + * The confirmation form, or a redirect when the selection has expired. + */ + public function buildForm(array $form, FormStateInterface $form_state, ?string $selection = NULL) { + $this->selectionToken = $selection ?? ''; + $this->selection = $this->tempStoreFactory->get('rl_experiment_delete_multiple')->get($this->selectionToken) ?? []; + if (!$this->selection) { + return new RedirectResponse($this->getCancelUrl()->toString()); + } + $items = []; + foreach ($this->selection as $experiment) { + $items[] = $this->t('@name (@id)', ['@name' => $experiment['name'], '@id' => $experiment['id']]); + } + $form['experiments'] = ['#theme' => 'item_list', '#items' => $items]; + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + if (!$this->currentUser()->hasPermission('administer rl experiments')) { + throw new AccessDeniedHttpException(); + } + $store = $this->tempStoreFactory->get('rl_experiment_delete_multiple'); + // Expired or already consumed confirmations must not delete anything. + if ($store->get($this->selectionToken) !== $this->selection) { + $form_state->setRedirectUrl($this->getCancelUrl()); + return; + } + try { + $count = $this->experimentDeletion->deleteMultiple(array_column($this->selection, 'id')); + $store->delete($this->selectionToken); + $this->messenger()->addStatus($this->formatPlural($count, 'Deleted @count experiment.', 'Deleted @count experiments.')); + } + catch (\Exception $e) { + $this->messenger()->addError($this->t('An error occurred while deleting the experiments.')); + $this->getLogger('rl')->error('Error deleting experiments: @message', ['@message' => $e->getMessage()]); + } + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/src/Form/ExperimentsOverviewForm.php b/src/Form/ExperimentsOverviewForm.php new file mode 100644 index 0000000..ab9ca7a --- /dev/null +++ b/src/Form/ExperimentsOverviewForm.php @@ -0,0 +1,227 @@ +get('rl.experiment_data_storage'), + $container->get('date.formatter'), + $container->get('tempstore.private'), + $container->get('pager.manager'), + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'rl_experiments_overview'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $can_administer = $this->currentUser()->hasPermission('administer rl experiments'); + $header = [ + 'experiment_name' => ['data' => $this->t('Experiment'), 'field' => 'experiment_name'], + 'module' => ['data' => $this->t('Source'), 'field' => 'module', 'class' => [RESPONSIVE_PRIORITY_LOW]], + 'total_turns' => ['data' => $this->t('Impressions'), 'field' => 'total_turns'], + 'total_rewards' => ['data' => $this->t('Conversions'), 'field' => 'total_rewards'], + 'arm_count' => ['data' => $this->t('Variants'), 'field' => 'arm_count', 'class' => [RESPONSIVE_PRIORITY_MEDIUM]], + 'registered_at' => [ + 'data' => $this->t('Created'), + 'field' => 'registered_at', + 'sort' => 'desc', + 'class' => [RESPONSIVE_PRIORITY_LOW], + ], + 'last_activity' => [ + 'data' => $this->t('Last Activity'), + 'field' => 'last_activity', + 'class' => [RESPONSIVE_PRIORITY_LOW], + ], + ]; + + $experiments = []; + foreach ($this->experimentStorage->getExperimentsWithStats() as $experiment) { + // Sort the values users see, including names and activity fallbacks. + $experiment->experiment_name = $experiment->experiment_name ?: $experiment->experiment_id; + $experiment->last_activity = $experiment->totals_updated ?: $experiment->registered_at; + // IDs may contain brackets or be "0"; use safe, non-numeric form keys. + $experiments['experiment_' . hash('sha256', $experiment->experiment_id)] = $experiment; + } + $sort = TableSort::getContextFromRequest($header, $this->getRequest()); + $field = $sort['sql']; + uasort($experiments, static function (object $a, object $b) use ($field, $sort): int { + $comparison = in_array($field, ['experiment_name', 'module'], TRUE) + ? strnatcasecmp($a->$field, $b->$field) + : (int) $a->$field <=> (int) $b->$field; + // Keep ties stable across pages and requests. + return ($sort['sort'] === 'desc' ? -$comparison : $comparison) + ?: strcmp($a->experiment_id, $b->experiment_id); + }); + $pager = $this->pagerManager->createPager(count($experiments), 50); + $page = array_slice($experiments, $pager->getCurrentPage() * 50, 50, TRUE); + // Live statistics can move checked rows to another page between GET and + // POST. Retain submitted choices that still exist in the registry so core + // tableselect validation can process them. Never trust submitted labels. + $submitted = $form_state->getUserInput()['experiments'] ?? []; + if ($can_administer && $this->getRequest()->isMethod('POST') && is_array($submitted)) { + $page += array_intersect_key($experiments, $submitted); + } + $experiments = $page; + + $form['add'] = [ + '#type' => 'link', + '#title' => $this->t('Add experiment'), + '#url' => Url::fromRoute('rl.experiment.add'), + '#attributes' => ['class' => ['button', 'button--primary']], + '#access' => $can_administer, + ]; + $form['description'] = [ + '#markup' => '' . $this->t('Deleting an experiment resets its data. Experiments auto-recreate on next render.') . '
', + ]; + $form['selection_help'] = [ + '#markup' => '' . $this->t('Select experiments on this page to apply an action.') . '
', + '#access' => $can_administer && !empty($experiments), + ]; + $form['options'] = [ + '#type' => 'details', + '#title' => $this->t('Update options'), + '#open' => TRUE, + '#attributes' => ['class' => ['container-inline']], + '#access' => $can_administer && !empty($experiments), + ]; + $form['options']['operation'] = [ + '#type' => 'select', + '#title' => $this->t('Action'), + '#options' => ['delete' => $this->t('Delete selected experiments')], + ]; + $form['options']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Apply to selected items'), + ]; + + $rows = []; + $form['#experiments'] = []; + foreach ($experiments as $key => $experiment) { + $id = $experiment->experiment_id; + $form['#experiments'][$key] = ['id' => $id, 'name' => $experiment->experiment_name]; + $name = [ + 'view' => [ + '#type' => 'container', + 'link' => [ + '#type' => 'link', + '#title' => $experiment->experiment_name, + '#url' => Url::fromRoute('rl.reports.experiment_detail', ['experiment_id' => $id]), + ], + ], + 'operations' => [ + '#type' => 'operations', + '#links' => [ + 'delete' => [ + 'title' => $this->t('Delete'), + 'url' => Url::fromRoute('rl.experiment.delete', ['experiment_id' => $id], ['query' => $this->getDestinationArray()]), + ], + ], + '#access' => $can_administer, + ], + ]; + $rows[$key] = [ + 'experiment_name' => ['data' => $name], + 'module' => $experiment->module, + 'total_turns' => $experiment->total_turns ?: 0, + 'total_rewards' => $experiment->total_rewards, + 'arm_count' => $experiment->arm_count, + 'registered_at' => $experiment->registered_at > 0 ? $this->dateFormatter->format($experiment->registered_at, 'short') : $this->t('Never'), + 'last_activity' => $experiment->last_activity > 0 ? $this->dateFormatter->format($experiment->last_activity, 'short') : $this->t('Never'), + ]; + } + $form['experiments'] = [ + '#type' => $can_administer ? 'tableselect' : 'table', + '#header' => $header, + '#empty' => $this->t('No experiments found.'), + ]; + if ($can_administer) { + // Core tableselect uses this title for accessible checkbox labels. + foreach ($rows as $key => &$row) { + // A markup object also keeps a literal "0" from being treated as empty. + $row['title']['data']['#title'] = $this->t('@name', ['@name' => $form['#experiments'][$key]['name']]); + } + $form['experiments']['#options'] = $rows; + } + else { + $form['experiments']['#rows'] = $rows; + } + $form['pager'] = ['#type' => 'pager']; + $form['#cache']['max-age'] = 0; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + if (!$this->currentUser()->hasPermission('administer rl experiments')) { + throw new AccessDeniedHttpException(); + } + $selected = array_filter($form_state->getValue('experiments', [])); + $selection = array_intersect_key($form['#experiments'], $selected); + if (!$selection) { + $form_state->setErrorByName('experiments', $this->t('Select one or more experiments.')); + } + $form_state->set('selection', array_values($selection)); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + if (!$this->currentUser()->hasPermission('administer rl experiments')) { + throw new AccessDeniedHttpException(); + } + // Separate tokens prevent another tab from replacing this selection. + $token = Crypt::randomBytesBase64(); + $this->tempStoreFactory->get('rl_experiment_delete_multiple')->set($token, $form_state->get('selection')); + // Defer the destination until after confirmation, as in a multi-step form. + $form_state->setIgnoreDestination(); + $query = array_intersect_key($this->getRequest()->query->all(), array_flip(['order', 'sort', 'page', 'destination'])); + $form_state->setRedirect('rl.experiment.delete_multiple', ['selection' => $token], [ + 'query' => $query, + ]); + } + +} diff --git a/src/Service/ExperimentDeletion.php b/src/Service/ExperimentDeletion.php new file mode 100644 index 0000000..ad8ba84 --- /dev/null +++ b/src/Service/ExperimentDeletion.php @@ -0,0 +1,47 @@ +database->startTransaction(); + try { + foreach (['rl_arm_data', 'rl_experiment_totals', 'rl_arm_snapshots'] as $table) { + $this->database->delete($table) + ->condition('experiment_id', $experiment_ids, 'IN') + ->execute(); + } + return (int) $this->database->delete('rl_experiment_registry') + ->condition('experiment_id', $experiment_ids, 'IN') + ->execute(); + } + catch (\Exception $e) { + $transaction->rollBack(); + throw $e; + } + } + +}