Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion rl.routing.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
4 changes: 4 additions & 0 deletions rl.services.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
services:
rl.experiment_deletion:
class: Drupal\rl\Service\ExperimentDeletion
arguments: ['@database']

logger.channel.rl:
parent: logger.channel_base
arguments: ['rl']
Expand Down
100 changes: 2 additions & 98 deletions src/Controller/ReportsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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' => '<br><br>',
];
}

$build['table'] = [
'#theme' => 'table',
'#header' => $header,
'#rows' => $rows,
'#empty' => $this->t('No experiments found.'),
];

$build['#prefix'] = '<p>' . $this->t('<strong>Tip:</strong> Deleting an experiment resets its data. Experiments auto-recreate on next render.') . '</p>';

return $build;
return $this->formBuilder()->getForm(ExperimentsOverviewForm::class);
}

/**
Expand Down
25 changes: 5 additions & 20 deletions src/Form/ExperimentDeleteForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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;
}

Expand All @@ -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')
);
}

Expand Down Expand Up @@ -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,
Expand Down
136 changes: 136 additions & 0 deletions src/Form/ExperimentDeleteMultipleForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

namespace Drupal\rl\Form;

use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use Drupal\rl\Service\ExperimentDeletion;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;

/**
* Confirms deletion of the current user's selected experiments.
*
* @see \Drupal\Core\Entity\Form\DeleteMultipleForm
*/
class ExperimentDeleteMultipleForm extends ConfirmFormBase {

/**
* The selected experiments.
*
* @var array
*/
protected array $selection = [];

/**
* The private selection token.
*
* @var string
*/
protected string $selectionToken;

/**
* Constructs a bulk deletion confirmation form.
*/
public function __construct(
protected PrivateTempStoreFactory $tempStoreFactory,
protected ExperimentDeletion $experimentDeletion,
) {}

/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
// @phpstan-ignore new.static
return new static(
$container->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());
}

}
Loading
Loading