'Varchar', 'State' => "Enum('open,published,reverted')", ); private static $has_many = array( 'Changes' => 'ChangeSetItem', ); private static $defaults = array( 'State' => 'open' ); private static $has_one = array( 'Owner' => 'Member', ); /** * Default permission to require for publishers. * Publishers must either be able to use the campaign admin, or have all admin access. * * Also used as default permission for ChangeSetItem default permission. * * @config * @var array */ private static $required_permission = array('CMS_ACCESS_CampaignAdmin', 'CMS_ACCESS_LeftAndMain'); /** * Publish this changeset, then closes it. * * @throws Exception */ public function publish() { // Logical checks prior to publish if($this->State !== static::STATE_OPEN) { throw new BadMethodCallException( "ChangeSet can't be published if it has been already published or reverted." ); } if(!$this->isSynced()) { throw new ValidationException( "ChangeSet does not include all necessary changes and cannot be published." ); } if(!$this->canPublish()) { throw new Exception("The current member does not have permission to publish this ChangeSet."); } DB::get_conn()->withTransaction(function(){ foreach($this->Changes() as $change) { /** @var ChangeSetItem $change */ $change->publish(); } $this->State = static::STATE_PUBLISHED; $this->write(); }); } /** * Add a new change to this changeset. Will automatically include all owned * changes as those are dependencies of this item. * * @param DataObject $object */ public function addObject(DataObject $object) { if(!$this->isInDB()) { throw new BadMethodCallException("ChangeSet must be saved before adding items"); } $references = [ 'ObjectID' => $object->ID, 'ObjectClass' => $object->ClassName ]; // Get existing item in case already added $item = $this->Changes()->filter($references)->first(); if (!$item) { $item = new ChangeSetItem($references); $this->Changes()->add($item); } $item->ReferencedBy()->removeAll(); $item->Added = ChangeSetItem::EXPLICITLY; $item->write(); $this->sync(); } /** * Remove an item from this changeset. Will automatically remove all changes * which own (and thus depend on) the removed item. * * @param DataObject $object */ public function removeObject(DataObject $object) { $item = ChangeSetItem::get()->filter([ 'ObjectID' => $object->ID, 'ObjectClass' => $object->ClassName, 'ChangeSetID' => $this->ID ])->first(); if ($item) { // TODO: Handle case of implicit added item being removed. $item->delete(); } $this->sync(); } protected function implicitKey($item) { if ($item instanceof ChangeSetItem) return $item->ObjectClass.'.'.$item->ObjectID; return $item->ClassName.'.'.$item->ID; } protected function calculateImplicit() { /** @var string[][] $explicit List of all items that have been explicitly added to this ChangeSet */ $explicit = array(); /** @var string[][] $referenced List of all items that are "referenced" by items in $explicit */ $referenced = array(); /** @var string[][] $references List of which explicit items reference each thing in referenced */ $references = array(); foreach ($this->Changes()->filter(['Added' => ChangeSetItem::EXPLICITLY]) as $item) { $explicitKey = $this->implicitKey($item); $explicit[$explicitKey] = true; foreach ($item->findReferenced() as $referee) { $key = $this->implicitKey($referee); $referenced[$key] = [ 'ObjectID' => $referee->ID, 'ObjectClass' => $referee->ClassName ]; $references[$key][] = $item->ID; } } /** @var string[][] $explicit List of all items that are either in $explicit, $referenced or both */ $all = array_merge($referenced, $explicit); /** @var string[][] $implicit Anything that is in $all, but not in $explicit, is an implicit inclusion */ $implicit = array_diff_key($all, $explicit); foreach($implicit as $key => $object) { $implicit[$key]['ReferencedBy'] = $references[$key]; } return $implicit; } /** * Add implicit changes that should be included in this changeset * * When an item is created or changed, all it's owned items which have * changes are implicitly added * * When an item is deleted, it's owner (even if that owner does not have changes) * is implicitly added */ public function sync() { // Start a transaction (if we can) DB::get_conn()->withTransaction(function() { // Get the implicitly included items for this ChangeSet $implicit = $this->calculateImplicit(); // Adjust the existing implicit ChangeSetItems for this ChangeSet foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) { $objectKey = $this->implicitKey($item); // If a ChangeSetItem exists, but isn't in $implicit, it's no longer required, so delete it if (!array_key_exists($objectKey, $implicit)) { $item->delete(); } // Otherwise it is required, so update ReferencedBy and remove from $implicit else { $item->ReferencedBy()->setByIDList($implicit[$objectKey]['ReferencedBy']); unset($implicit[$objectKey]); } } // Now $implicit is all those items that are implicitly included, but don't currently have a ChangeSetItem. // So create new ChangeSetItems to match foreach ($implicit as $key => $props) { $item = new ChangeSetItem($props); $item->Added = ChangeSetItem::IMPLICITLY; $item->ChangeSetID = $this->ID; $item->ReferencedBy()->setByIDList($props['ReferencedBy']); $item->write(); } }); } /** Verify that any objects in this changeset include all owned changes */ public function isSynced() { $implicit = $this->calculateImplicit(); // Check the existing implicit ChangeSetItems for this ChangeSet foreach ($this->Changes()->filter(['Added' => ChangeSetItem::IMPLICITLY]) as $item) { $objectKey = $this->implicitKey($item); // If a ChangeSetItem exists, but isn't in $implicit -> validation failure if (!array_key_exists($objectKey, $implicit)) return false; // Exists, remove from $implicit unset($implicit[$objectKey]); } // If there's anything left in $implicit -> validation failure return empty($implicit); } public function canView($member = null) { return $this->can(__FUNCTION__, $member); } public function canEdit($member = null) { return $this->can(__FUNCTION__, $member); } public function canCreate($member = null, $context = array()) { return $this->can(__FUNCTION__, $member, $context); } public function canDelete($member = null) { return $this->can(__FUNCTION__, $member); } /** * Check if this item is allowed to be published * * @param Member $member * @return bool */ public function canPublish($member = null) { // All changes must be publishable foreach($this->Changes() as $change) { /** @var ChangeSetItem $change */ if(!$change->canPublish($member)) { return false; } } // Default permission return $this->can(__FUNCTION__, $member); } /** * Check if this changeset (if published) can be reverted * * @param Member $member * @return bool */ public function canRevert($member = null) { // All changes must be publishable foreach($this->Changes() as $change) { /** @var ChangeSetItem $change */ if(!$change->canRevert($member)) { return false; } } // Default permission return $this->can(__FUNCTION__, $member); } /** * Default permissions for this changeset * * @param string $perm * @param Member $member * @param array $context * @return bool */ public function can($perm, $member = null, $context = array()) { if(!$member) { $member = Member::currentUser(); } // Allow extensions to bypass default permissions, but only if // each change can be individually published. $extended = $this->extendedCan($perm, $member, $context); if($extended !== null) { return $extended; } // Default permissions return (bool)Permission::checkMember($member, $this->config()->required_permission); } }