diff --git a/resources/sql/autopatches/20140210.projcfield.4.memmig.sql b/resources/sql/autopatches/20140210.projcfield.4.memmig.sql new file mode 100644 index 0000000000..dde525bd29 --- /dev/null +++ b/resources/sql/autopatches/20140210.projcfield.4.memmig.sql @@ -0,0 +1,8 @@ +/* These are here so `grep` will find them if we ever change things: */ + +/* PhabricatorEdgeConfig::TYPE_PROJ_MEMBER = 13 */ +/* PhabricatorEdgeConfig::TYPE_OBJECT_HAS_SUBSCRIBER = 21 */ + +INSERT IGNORE INTO {$NAMESPACE}_project.edge (src, type, dst, dateCreated) + SELECT src, 21, dst, dateCreated FROM {$NAMESPACE}_project.edge + WHERE type = 13; diff --git a/src/applications/metamta/query/PhabricatorMetaMTAMemberQuery.php b/src/applications/metamta/query/PhabricatorMetaMTAMemberQuery.php index b0be1ab34e..f270f0089d 100644 --- a/src/applications/metamta/query/PhabricatorMetaMTAMemberQuery.php +++ b/src/applications/metamta/query/PhabricatorMetaMTAMemberQuery.php @@ -1,69 +1,69 @@ viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function execute() { $phids = array_fuse($this->phids); $actors = array(); $type_map = array(); foreach ($phids as $phid) { $type_map[phid_get_type($phid)][] = $phid; } // TODO: Generalize this somewhere else. $results = array(); foreach ($type_map as $type => $phids) { switch ($type) { case PhabricatorProjectPHIDTypeProject::TYPECONST: - // TODO: For now, project members are always on the "mailing list" - // implied by the project, but we should differentiate members and - // subscribers (i.e., allow you to unsubscribe from mail about - // a project). + // NOTE: We're loading the projects here in order to respect policies. $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) - ->needMembers(true) ->withPHIDs($phids) ->execute(); + $subscribers = id(new PhabricatorSubscribersQuery()) + ->withObjectPHIDs($phids) + ->execute(); + $projects = mpull($projects, null, 'getPHID'); foreach ($phids as $phid) { $project = idx($projects, $phid); if (!$project) { $results[$phid] = array(); } else { - $results[$phid] = $project->getMemberPHIDs(); + $results[$phid] = idx($subscribers, $phid, array()); } } break; default: break; } } return $results; } } diff --git a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php index 43cc9924a8..4af23f781e 100644 --- a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php @@ -1,230 +1,256 @@ getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: return $object->getName(); case PhabricatorProjectTransaction::TYPE_STATUS: return $object->getStatus(); case PhabricatorProjectTransaction::TYPE_IMAGE: return $object->getProfileImagePHID(); } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: return $xaction->getNewValue(); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: $object->setName($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_STATUS: $object->setStatus($xaction->getNewValue()); return; case PhabricatorProjectTransaction::TYPE_IMAGE: $object->setProfileImagePHID($xaction->getNewValue()); return; case PhabricatorTransactions::TYPE_EDGE: return; case PhabricatorTransactions::TYPE_VIEW_POLICY: $object->setViewPolicy($xaction->getNewValue()); return; case PhabricatorTransactions::TYPE_EDIT_POLICY: $object->setEditPolicy($xaction->getNewValue()); return; case PhabricatorTransactions::TYPE_JOIN_POLICY: $object->setJoinPolicy($xaction->getNewValue()); return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: $old_slug = $object->getFullPhrictionSlug(); $object->setPhrictionSlug($xaction->getNewValue()); $changed_slug = $old_slug != $object->getFullPhrictionSlug(); if ($xaction->getOldValue() && $changed_slug) { $old_document = id(new PhrictionDocument()) ->loadOneWhere( 'slug = %s', $old_slug); if ($old_document && $old_document->getStatus() == PhrictionDocumentStatus::STATUS_EXISTS) { $content = id(new PhrictionContent()) ->load($old_document->getContentID()); $from_editor = id(PhrictionDocumentEditor::newForSlug($old_slug)) ->setActor($this->getActor()) ->setTitle($content->getTitle()) ->setContent($content->getContent()) ->setDescription($content->getDescription()); $target_editor = id(PhrictionDocumentEditor::newForSlug( $object->getFullPhrictionSlug())) ->setActor($this->getActor()) ->setTitle($content->getTitle()) ->setContent($content->getContent()) ->setDescription($content->getDescription()) ->moveHere($old_document->getID(), $old_document->getPHID()); $target_document = $target_editor->getDocument(); $from_editor->moveAway($target_document->getID()); } } return; case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: - case PhabricatorTransactions::TYPE_EDGE: case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: return; + case PhabricatorTransactions::TYPE_EDGE: + switch ($xaction->getMetadataValue('edge:type')) { + case PhabricatorEdgeConfig::TYPE_PROJ_MEMBER: + // When project members are added or removed, add or remove their + // subscriptions. + $old = $xaction->getOldValue(); + $new = $xaction->getNewValue(); + $add = array_keys(array_diff_key($new, $old)); + $rem = array_keys(array_diff_key($old, $new)); + + // NOTE: The subscribe is "explicit" because there's no implicit + // unsubscribe, so Join -> Leave -> Join doesn't resubscribe you + // if we use an implicit subscribe, even though you never willfully + // unsubscribed. Not sure if adding implicit unsubscribe (which + // would not write the unsubscribe row) is justified to deal with + // this, which is a fairly weird edge case and pretty arguable both + // ways. + + id(new PhabricatorSubscriptionsEditor()) + ->setActor($this->requireActor()) + ->setObject($object) + ->subscribeExplicit($add) + ->unsubscribe($rem) + ->save(); + break; + } + return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case PhabricatorProjectTransaction::TYPE_NAME: $missing = $this->validateIsEmptyTextField( $object->getName(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Project name is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } break; } return $errors; } protected function requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_NAME: case PhabricatorProjectTransaction::TYPE_STATUS: case PhabricatorProjectTransaction::TYPE_IMAGE: PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); return; case PhabricatorTransactions::TYPE_EDGE: switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorEdgeConfig::TYPE_PROJ_MEMBER: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $actor_phid = $this->requireActor()->getPHID(); $is_join = (($add === array($actor_phid)) && !$rem); $is_leave = (($rem === array($actor_phid)) && !$add); if ($is_join) { // You need CAN_JOIN to join a project. PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_JOIN); } else if ($is_leave) { // You don't need any capabilities to leave a project. } else { // You need CAN_EDIT to change members other than yourself. PhabricatorPolicyFilter::requireCapability( $this->requireActor(), $object, PhabricatorPolicyCapability::CAN_EDIT); } return; } break; } return parent::requireCapabilities($object, $xaction); } protected function supportsSearch() { return true; } protected function extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorProjectTransaction::TYPE_IMAGE: $new = $xaction->getNewValue(); if ($new) { return array($new); } break; } return parent::extractFilePHIDsFromCustomTransaction($object, $xaction); } } diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php index 560f2e0797..c0979186cb 100644 --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -1,199 +1,199 @@ setName('') ->setAuthorPHID($actor->getPHID()) ->setViewPolicy(PhabricatorPolicies::POLICY_USER) ->setEditPolicy(PhabricatorPolicies::POLICY_USER) ->setJoinPolicy(PhabricatorPolicies::POLICY_USER) ->attachMemberPHIDs(array()); } public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, PhabricatorPolicyCapability::CAN_JOIN, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getEditPolicy(); case PhabricatorPolicyCapability::CAN_JOIN: return $this->getJoinPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if ($this->isUserMember($viewer->getPHID())) { // Project members can always view a project. return true; } break; case PhabricatorPolicyCapability::CAN_EDIT: break; case PhabricatorPolicyCapability::CAN_JOIN: $can_edit = PhabricatorPolicyCapability::CAN_EDIT; if (PhabricatorPolicyFilter::hasCapability($viewer, $this, $can_edit)) { // Project editors can always join a project. return true; } break; } return false; } public function describeAutomaticCapability($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht("Members of a project can always view it."); case PhabricatorPolicyCapability::CAN_JOIN: return pht("Users who can edit a project can always join it."); } return null; } public function isUserMember($user_phid) { if ($this->memberPHIDs !== self::ATTACHABLE) { return in_array($user_phid, $this->memberPHIDs); } return $this->assertAttachedKey($this->sparseMembers, $user_phid); } public function setIsUserMember($user_phid, $is_member) { if ($this->sparseMembers === self::ATTACHABLE) { $this->sparseMembers = array(); } $this->sparseMembers[$user_phid] = $is_member; return $this; } public function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'subprojectPHIDs' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorProjectPHIDTypeProject::TYPECONST); } public function attachMemberPHIDs(array $phids) { $this->memberPHIDs = $phids; return $this; } public function getMemberPHIDs() { return $this->assertAttached($this->memberPHIDs); } public function setPhrictionSlug($slug) { // NOTE: We're doing a little magic here and stripping out '/' so that // project pages always appear at top level under projects/ even if the // display name is "Hack / Slash" or similar (it will become // 'hack_slash' instead of 'hack/slash'). $slug = str_replace('/', ' ', $slug); $slug = PhabricatorSlug::normalize($slug); $this->phrictionSlug = $slug; return $this; } public function getFullPhrictionSlug() { $slug = $this->getPhrictionSlug(); return 'projects/'.$slug; } public function isArchived() { return ($this->getStatus() == PhabricatorProjectStatus::STATUS_ARCHIVED); } public function getProfileImageURI() { return $this->getProfileImageFile()->getBestURI(); } public function attachProfileImageFile(PhabricatorFile $file) { $this->profileImageFile = $file; return $this; } public function getProfileImageFile() { return $this->assertAttached($this->profileImageFile); } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return false; } public function shouldShowSubscribersProperty() { return false; } public function shouldAllowSubscription($phid) { - return false; + return $this->isUserMember($phid); } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('projects.fields'); } public function getCustomFieldBaseClass() { return 'PhabricatorProjectCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } }