Page MenuHomestyx hydra

No OneTemporary

diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php
index d1966cf106..2750adc2ea 100644
--- a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php
+++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php
@@ -1,168 +1,212 @@
<?php
final class PhabricatorProjectTriggerViewController
extends PhabricatorProjectTriggerController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$request = $this->getRequest();
$viewer = $request->getViewer();
$id = $request->getURIData('id');
$trigger = id(new PhabricatorProjectTriggerQuery())
->setViewer($viewer)
->withIDs(array($id))
->executeOne();
if (!$trigger) {
return new Aphront404Response();
}
+ $rules_view = $this->newRulesView($trigger);
$columns_view = $this->newColumnsView($trigger);
$title = $trigger->getObjectName();
$header = id(new PHUIHeaderView())
->setHeader($trigger->getDisplayName());
$timeline = $this->buildTransactionTimeline(
$trigger,
new PhabricatorProjectTriggerTransactionQuery());
$timeline->setShouldTerminate(true);
$curtain = $this->newCurtain($trigger);
$column_view = id(new PHUITwoColumnView())
->setHeader($header)
->setCurtain($curtain)
->setMainColumn(
array(
+ $rules_view,
$columns_view,
$timeline,
));
$crumbs = $this->buildApplicationCrumbs()
->addTextCrumb($trigger->getObjectName())
->setBorder(true);
return $this->newPage()
->setTitle($title)
->setCrumbs($crumbs)
->appendChild($column_view);
}
private function newColumnsView(PhabricatorProjectTrigger $trigger) {
$viewer = $this->getViewer();
// NOTE: When showing columns which use this trigger, we want to represent
// all columns the trigger is used by: even columns the user can't see.
// If we hide columns the viewer can't see, they might think that the
// trigger isn't widely used and is safe to edit, when it may actually
// be in use on workboards they don't have access to.
// Query the columns with the omnipotent viewer first, then pull out their
// PHIDs and throw the actual objects away. Re-query with the real viewer
// so we load only the columns they can actually see, but have a list of
// all the impacted column PHIDs.
$omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
$all_columns = id(new PhabricatorProjectColumnQuery())
->setViewer($omnipotent_viewer)
->withTriggerPHIDs(array($trigger->getPHID()))
->execute();
$column_phids = mpull($all_columns, 'getPHID');
if ($column_phids) {
$visible_columns = id(new PhabricatorProjectColumnQuery())
->setViewer($viewer)
->withPHIDs($column_phids)
->execute();
$visible_columns = mpull($visible_columns, null, 'getPHID');
} else {
$visible_columns = array();
}
$rows = array();
foreach ($column_phids as $column_phid) {
$column = idx($visible_columns, $column_phid);
if ($column) {
$project = $column->getProject();
$project_name = phutil_tag(
'a',
array(
'href' => $project->getURI(),
),
$project->getDisplayName());
$column_name = phutil_tag(
'a',
array(
'href' => $column->getBoardURI(),
),
$column->getDisplayName());
} else {
$project_name = null;
$column_name = phutil_tag('em', array(), pht('Restricted Column'));
}
$rows[] = array(
$project_name,
$column_name,
);
}
$table_view = id(new AphrontTableView($rows))
->setNoDataString(pht('This trigger is not used by any columns.'))
->setHeaders(
array(
pht('Project'),
pht('Column'),
))
->setColumnClasses(
array(
null,
'wide pri',
));
$header_view = id(new PHUIHeaderView())
->setHeader(pht('Used by Columns'));
return id(new PHUIObjectBoxView())
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
->setHeader($header_view)
->setTable($table_view);
}
+ private function newRulesView(PhabricatorProjectTrigger $trigger) {
+ $viewer = $this->getViewer();
+ $rules = $trigger->getTriggerRules();
+
+ $rows = array();
+ foreach ($rules as $rule) {
+ $value = $rule->getRecord()->getValue();
+
+ $rows[] = array(
+ $rule->getRuleViewIcon($value),
+ $rule->getRuleViewLabel(),
+ $rule->getRuleViewDescription($value),
+ );
+ }
+
+ $table_view = id(new AphrontTableView($rows))
+ ->setNoDataString(pht('This trigger has no rules.'))
+ ->setHeaders(
+ array(
+ null,
+ pht('Rule'),
+ pht('Action'),
+ ))
+ ->setColumnClasses(
+ array(
+ null,
+ 'pri',
+ 'wide',
+ ));
+
+ $header_view = id(new PHUIHeaderView())
+ ->setHeader(pht('Trigger Rules'))
+ ->setSubheader(
+ pht(
+ 'When a card is dropped into a column that uses this trigger, '.
+ 'these actions will be taken.'));
+
+ return id(new PHUIObjectBoxView())
+ ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
+ ->setHeader($header_view)
+ ->setTable($table_view);
+ }
private function newCurtain(PhabricatorProjectTrigger $trigger) {
$viewer = $this->getViewer();
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$trigger,
PhabricatorPolicyCapability::CAN_EDIT);
$curtain = $this->newCurtainView($trigger);
$edit_uri = $this->getApplicationURI(
urisprintf(
'trigger/edit/%d/',
$trigger->getID()));
$curtain->addAction(
id(new PhabricatorActionView())
->setName(pht('Edit Trigger'))
->setIcon('fa-pencil')
->setHref($edit_uri)
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit));
return $curtain;
}
}
diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php
index a195e9fc44..5029c2caea 100644
--- a/src/applications/project/storage/PhabricatorProjectTrigger.php
+++ b/src/applications/project/storage/PhabricatorProjectTrigger.php
@@ -1,326 +1,327 @@
<?php
final class PhabricatorProjectTrigger
extends PhabricatorProjectDAO
implements
PhabricatorApplicationTransactionInterface,
PhabricatorPolicyInterface,
PhabricatorDestructibleInterface {
protected $name;
protected $ruleset = array();
protected $editPolicy;
private $triggerRules;
public static function initializeNewTrigger() {
$default_edit = PhabricatorPolicies::POLICY_USER;
return id(new self())
->setName('')
->setEditPolicy($default_edit);
}
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'ruleset' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'name' => 'text255',
),
self::CONFIG_KEY_SCHEMA => array(
),
) + parent::getConfiguration();
}
public function getPHIDType() {
return PhabricatorProjectTriggerPHIDType::TYPECONST;
}
public function getDisplayName() {
$name = $this->getName();
if (strlen($name)) {
return $name;
}
return $this->getDefaultName();
}
public function getDefaultName() {
return pht('Custom Trigger');
}
public function getURI() {
return urisprintf(
'/project/trigger/%d/',
$this->getID());
}
public function getObjectName() {
return pht('Trigger %d', $this->getID());
}
public function setRuleset(array $ruleset) {
// Clear any cached trigger rules, since we're changing the ruleset
// for the trigger.
$this->triggerRules = null;
parent::setRuleset($ruleset);
}
public function getTriggerRules() {
if ($this->triggerRules === null) {
$trigger_rules = self::newTriggerRulesFromRuleSpecifications(
$this->getRuleset(),
$allow_invalid = true);
$this->triggerRules = $trigger_rules;
}
return $this->triggerRules;
}
public static function newTriggerRulesFromRuleSpecifications(
array $list,
$allow_invalid) {
// NOTE: With "$allow_invalid" set, we're trying to preserve the database
// state in the rule structure, even if it includes rule types we don't
// ha ve implementations for, or rules with invalid rule values.
// If an administrator adds or removes extensions which add rules, or
// an upgrade affects rule validity, existing rules may become invalid.
// When they do, we still want the UI to reflect the ruleset state
// accurately and "Edit" + "Save" shouldn't destroy data unless the
// user explicitly modifies the ruleset.
// In this mode, when we run into rules which are structured correctly but
// which have types we don't know about, we replace them with "Unknown
// Rules". If we know about the type of a rule but the value doesn't
// validate, we replace it with "Invalid Rules". These two rule types don't
// take any actions when a card is dropped into the column, but they show
// the user what's wrong with the ruleset and can be saved without causing
// any collateral damage.
$rule_map = PhabricatorProjectTriggerRule::getAllTriggerRules();
// If the stored rule data isn't a list of rules (or we encounter other
// fundamental structural problems, below), there isn't much we can do
// to try to represent the state.
if (!is_array($list)) {
throw new PhabricatorProjectTriggerCorruptionException(
pht(
'Trigger ruleset is corrupt: expected a list of rule '.
'specifications, found "%s".',
phutil_describe_type($list)));
}
$trigger_rules = array();
foreach ($list as $key => $rule) {
if (!is_array($rule)) {
throw new PhabricatorProjectTriggerCorruptionException(
pht(
'Trigger ruleset is corrupt: rule (with key "%s") should be a '.
'rule specification, but is actually "%s".',
$key,
phutil_describe_type($rule)));
}
try {
PhutilTypeSpec::checkMap(
$rule,
array(
'type' => 'string',
'value' => 'wild',
));
} catch (PhutilTypeCheckException $ex) {
throw new PhabricatorProjectTriggerCorruptionException(
pht(
'Trigger ruleset is corrupt: rule (with key "%s") is not a '.
'valid rule specification: %s',
$key,
$ex->getMessage()));
}
$record = id(new PhabricatorProjectTriggerRuleRecord())
->setType(idx($rule, 'type'))
->setValue(idx($rule, 'value'));
if (!isset($rule_map[$record->getType()])) {
if (!$allow_invalid) {
throw new PhabricatorProjectTriggerCorruptionException(
pht(
'Trigger ruleset is corrupt: rule type "%s" is unknown.',
$record->getType()));
}
$rule = new PhabricatorProjectTriggerUnknownRule();
} else {
$rule = clone $rule_map[$record->getType()];
}
try {
$rule->setRecord($record);
} catch (Exception $ex) {
if (!$allow_invalid) {
throw new PhabricatorProjectTriggerCorruptionException(
pht(
'Trigger ruleset is corrupt, rule (of type "%s") does not '.
'validate: %s',
$record->getType(),
$ex->getMessage()));
}
$rule = id(new PhabricatorProjectTriggerInvalidRule())
- ->setRecord($record);
+ ->setRecord($record)
+ ->setException($ex);
}
$trigger_rules[] = $rule;
}
return $trigger_rules;
}
public function getDropEffects() {
$effects = array();
$rules = $this->getTriggerRules();
foreach ($rules as $rule) {
foreach ($rule->getDropEffects() as $effect) {
$effects[] = $effect;
}
}
return $effects;
}
public function getRulesDescription() {
$rules = $this->getTriggerRules();
if (!$rules) {
return pht('Does nothing.');
}
$things = array();
$count = count($rules);
$limit = 3;
if ($count > $limit) {
$show_rules = array_slice($rules, 0, ($limit - 1));
} else {
$show_rules = $rules;
}
foreach ($show_rules as $rule) {
$things[] = $rule->getDescription();
}
if ($count > $limit) {
$things[] = pht(
'(Applies %s more actions.)',
new PhutilNumber($count - $limit));
}
return implode("\n", $things);
}
public function newDropTransactions(
PhabricatorUser $viewer,
PhabricatorProjectColumn $column,
$object) {
$trigger_xactions = array();
foreach ($this->getTriggerRules() as $rule) {
$rule
->setViewer($viewer)
->setTrigger($this)
->setColumn($column)
->setObject($object);
$xactions = $rule->getDropTransactions(
$object,
$rule->getRecord()->getValue());
if (!is_array($xactions)) {
throw new Exception(
pht(
'Expected trigger rule (of class "%s") to return a list of '.
'transactions from "newDropTransactions()", but got "%s".',
get_class($rule),
phutil_describe_type($xactions)));
}
$expect_type = get_class($object->getApplicationTransactionTemplate());
assert_instances_of($xactions, $expect_type);
foreach ($xactions as $xaction) {
$trigger_xactions[] = $xaction;
}
}
return $trigger_xactions;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new PhabricatorProjectTriggerEditor();
}
public function getApplicationTransactionTemplate() {
return new PhabricatorProjectTriggerTransaction();
}
/* -( PhabricatorPolicyInterface )----------------------------------------- */
public function getCapabilities() {
return array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
);
}
public function getPolicy($capability) {
switch ($capability) {
case PhabricatorPolicyCapability::CAN_VIEW:
return PhabricatorPolicies::getMostOpenPolicy();
case PhabricatorPolicyCapability::CAN_EDIT:
return $this->getEditPolicy();
}
}
public function hasAutomaticCapability($capability, PhabricatorUser $viewer) {
return false;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
$this->openTransaction();
$conn = $this->establishConnection('w');
// Remove the reference to this trigger from any columns which use it.
queryfx(
$conn,
'UPDATE %R SET triggerPHID = null WHERE triggerPHID = %s',
new PhabricatorProjectColumn(),
$this->getPHID());
$this->delete();
$this->saveTransaction();
}
}
diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php
index 0f9fe52abb..184d818aa5 100644
--- a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php
+++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php
@@ -1,62 +1,99 @@
<?php
final class PhabricatorProjectTriggerInvalidRule
extends PhabricatorProjectTriggerRule {
const TRIGGERTYPE = 'invalid';
+ private $exception;
+
+ public function setException(Exception $exception) {
+ $this->exception = $exception;
+ return $this;
+ }
+
+ public function getException() {
+ return $this->exception;
+ }
+
public function getDescription() {
return pht(
'Invalid rule (of type "%s").',
$this->getRecord()->getType());
}
public function getSelectControlName() {
return pht('(Invalid Rule)');
}
protected function isSelectableRule() {
return false;
}
protected function assertValidRuleValue($value) {
return;
}
protected function newDropTransactions($object, $value) {
return array();
}
protected function newDropEffects($value) {
return array();
}
protected function isValidRule() {
return false;
}
protected function newInvalidView() {
return array(
id(new PHUIIconView())
->setIcon('fa-exclamation-triangle red'),
' ',
pht(
'This is a trigger rule with a valid type ("%s") but an invalid '.
'value.',
$this->getRecord()->getType()),
);
}
protected function getDefaultValue() {
return null;
}
protected function getPHUIXControlType() {
return null;
}
protected function getPHUIXControlSpecification() {
return null;
}
+ public function getRuleViewLabel() {
+ return pht('Invalid Rule');
+ }
+
+ public function getRuleViewDescription($value) {
+ $record = $this->getRecord();
+ $type = $record->getType();
+
+ $exception = $this->getException();
+ if ($exception) {
+ return pht(
+ 'This rule (of type "%s") is invalid: %s',
+ $type,
+ $exception->getMessage());
+ } else {
+ return pht(
+ 'This rule (of type "%s") is invalid.',
+ $type);
+ }
+ }
+
+ public function getRuleViewIcon($value) {
+ return id(new PHUIIconView())
+ ->setIcon('fa-exclamation-triangle', 'red');
+ }
+
}
diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php
index 2c40563884..5b1ad2db36 100644
--- a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php
+++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php
@@ -1,80 +1,101 @@
<?php
final class PhabricatorProjectTriggerManiphestStatusRule
extends PhabricatorProjectTriggerRule {
const TRIGGERTYPE = 'task.status';
public function getDescription() {
$value = $this->getValue();
return pht(
'Changes status to "%s".',
ManiphestTaskStatus::getTaskStatusName($value));
}
public function getSelectControlName() {
return pht('Change status to');
}
protected function assertValidRuleValue($value) {
if (!is_string($value)) {
throw new Exception(
pht(
'Status rule value should be a string, but is not (value is "%s").',
phutil_describe_type($value)));
}
$map = ManiphestTaskStatus::getTaskStatusMap();
if (!isset($map[$value])) {
throw new Exception(
pht(
'Rule value ("%s") is not a valid task status.',
$value));
}
}
protected function newDropTransactions($object, $value) {
return array(
$this->newTransaction()
->setTransactionType(ManiphestTaskStatusTransaction::TRANSACTIONTYPE)
->setNewValue($value),
);
}
protected function newDropEffects($value) {
$status_name = ManiphestTaskStatus::getTaskStatusName($value);
$status_icon = ManiphestTaskStatus::getStatusIcon($value);
$status_color = ManiphestTaskStatus::getStatusColor($value);
$content = pht(
'Change status to %s.',
phutil_tag('strong', array(), $status_name));
return array(
$this->newEffect()
->setIcon($status_icon)
->setColor($status_color)
->addCondition('status', '!=', $value)
->setContent($content),
);
}
protected function getDefaultValue() {
return head_key(ManiphestTaskStatus::getTaskStatusMap());
}
protected function getPHUIXControlType() {
return 'select';
}
protected function getPHUIXControlSpecification() {
$map = ManiphestTaskStatus::getTaskStatusMap();
return array(
'options' => $map,
'order' => array_keys($map),
);
}
+ public function getRuleViewLabel() {
+ return pht('Change Status');
+ }
+
+ public function getRuleViewDescription($value) {
+ $status_name = ManiphestTaskStatus::getTaskStatusName($value);
+
+ return pht(
+ 'Change task status to %s.',
+ phutil_tag('strong', array(), $status_name));
+ }
+
+ public function getRuleViewIcon($value) {
+ $status_icon = ManiphestTaskStatus::getStatusIcon($value);
+ $status_color = ManiphestTaskStatus::getStatusColor($value);
+
+ return id(new PHUIIconView())
+ ->setIcon($status_icon, $status_color);
+ }
+
+
}
diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php
index 9634086235..49fdbf8a93 100644
--- a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php
+++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php
@@ -1,146 +1,149 @@
<?php
abstract class PhabricatorProjectTriggerRule
extends Phobject {
private $record;
private $viewer;
private $column;
private $trigger;
private $object;
final public function getTriggerType() {
return $this->getPhobjectClassConstant('TRIGGERTYPE', 64);
}
final public static function getAllTriggerRules() {
return id(new PhutilClassMapQuery())
->setAncestorClass(__CLASS__)
->setUniqueMethod('getTriggerType')
->execute();
}
final public function setRecord(PhabricatorProjectTriggerRuleRecord $record) {
$value = $record->getValue();
$this->assertValidRuleValue($value);
$this->record = $record;
return $this;
}
final public function getRecord() {
return $this->record;
}
final protected function getValue() {
return $this->getRecord()->getValue();
}
abstract public function getDescription();
abstract public function getSelectControlName();
+ abstract public function getRuleViewLabel();
+ abstract public function getRuleViewDescription($value);
+ abstract public function getRuleViewIcon($value);
abstract protected function assertValidRuleValue($value);
abstract protected function newDropTransactions($object, $value);
abstract protected function newDropEffects($value);
abstract protected function getDefaultValue();
abstract protected function getPHUIXControlType();
abstract protected function getPHUIXControlSpecification();
protected function isSelectableRule() {
return true;
}
protected function isValidRule() {
return true;
}
protected function newInvalidView() {
return null;
}
final public function getDropTransactions($object, $value) {
return $this->newDropTransactions($object, $value);
}
final public function setViewer(PhabricatorUser $viewer) {
$this->viewer = $viewer;
return $this;
}
final public function getViewer() {
return $this->viewer;
}
final public function setColumn(PhabricatorProjectColumn $column) {
$this->column = $column;
return $this;
}
final public function getColumn() {
return $this->column;
}
final public function setTrigger(PhabricatorProjectTrigger $trigger) {
$this->trigger = $trigger;
return $this;
}
final public function getTrigger() {
return $this->trigger;
}
final public function setObject(
PhabricatorApplicationTransactionInterface $object) {
$this->object = $object;
return $this;
}
final public function getObject() {
return $this->object;
}
final protected function newTransaction() {
return $this->getObject()->getApplicationTransactionTemplate();
}
final public function getDropEffects() {
return $this->newDropEffects($this->getValue());
}
final protected function newEffect() {
return new PhabricatorProjectDropEffect();
}
final public function toDictionary() {
$record = $this->getRecord();
$is_valid = $this->isValidRule();
if (!$is_valid) {
$invalid_view = hsprintf('%s', $this->newInvalidView());
} else {
$invalid_view = null;
}
return array(
'type' => $record->getType(),
'value' => $record->getValue(),
'isValidRule' => $is_valid,
'invalidView' => $invalid_view,
);
}
final public function newTemplate() {
return array(
'type' => $this->getTriggerType(),
'name' => $this->getSelectControlName(),
'selectable' => $this->isSelectableRule(),
'defaultValue' => $this->getDefaultValue(),
'control' => array(
'type' => $this->getPHUIXControlType(),
'specification' => $this->getPHUIXControlSpecification(),
),
);
}
}
diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php
index 008092061d..f71ee44ad7 100644
--- a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php
+++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php
@@ -1,61 +1,77 @@
<?php
final class PhabricatorProjectTriggerUnknownRule
extends PhabricatorProjectTriggerRule {
const TRIGGERTYPE = 'unknown';
public function getDescription() {
return pht(
'Unknown rule (of type "%s").',
$this->getRecord()->getType());
}
public function getSelectControlName() {
return pht('(Unknown Rule)');
}
protected function isSelectableRule() {
return false;
}
protected function assertValidRuleValue($value) {
return;
}
protected function newDropTransactions($object, $value) {
return array();
}
protected function newDropEffects($value) {
return array();
}
protected function isValidRule() {
return false;
}
protected function newInvalidView() {
return array(
id(new PHUIIconView())
->setIcon('fa-exclamation-triangle yellow'),
' ',
pht(
'This is a trigger rule with a unknown type ("%s").',
$this->getRecord()->getType()),
);
}
protected function getDefaultValue() {
return null;
}
protected function getPHUIXControlType() {
return null;
}
protected function getPHUIXControlSpecification() {
return null;
}
+ public function getRuleViewLabel() {
+ return pht('Unknown Rule');
+ }
+
+ public function getRuleViewDescription($value) {
+ return pht(
+ 'This is an unknown rule of type "%s". An administrator may have '.
+ 'edited or removed an extension which implements this rule type.',
+ $this->getRecord()->getType());
+ }
+
+ public function getRuleViewIcon($value) {
+ return id(new PHUIIconView())
+ ->setIcon('fa-question-circle', 'yellow');
+ }
+
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Jul 29, 9:11 AM (3 w, 1 d ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
188429
Default Alt Text
(27 KB)

Event Timeline