launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #18703
[Merge] lp:~cjwatson/launchpad/git-repository-ui-edit-basics into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/git-repository-ui-edit-basics into lp:launchpad.
Commit message:
Add some basic edit views (name, information_type, and reviewer) to GitRepository.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-repository-ui-edit-basics/+merge/261117
Add some basic edit views (name, information_type, and reviewer) to GitRepository, in line with Branch. Changing the owner, target, and the various default fields is more complicated, and will come in subsequent branches.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-repository-ui-edit-basics into lp:launchpad.
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml 2015-06-02 08:57:02 +0000
+++ lib/lp/code/browser/configure.zcml 2015-06-04 17:01:40 +0000
@@ -762,6 +762,9 @@
name="+index"
template="../templates/gitrepository-index.pt"/>
<browser:page
+ name="+portlet-privacy"
+ template="../templates/gitrepository-portlet-privacy.pt"/>
+ <browser:page
name="++repository-information"
template="../templates/gitrepository-information.pt"/>
<browser:page
@@ -781,6 +784,24 @@
template="../templates/gitrepository-portlet-subscribers-content.pt"/>
<browser:page
for="lp.code.interfaces.gitrepository.IGitRepository"
+ class="lp.code.browser.gitrepository.GitRepositoryEditInformationTypeView"
+ permission="launchpad.Edit"
+ name="+edit-information-type"
+ template="../../app/templates/generic-edit.pt"/>
+ <browser:page
+ for="lp.code.interfaces.gitrepository.IGitRepository"
+ class="lp.code.browser.gitrepository.GitRepositoryEditReviewerView"
+ permission="launchpad.Edit"
+ name="+reviewer"
+ template="../../app/templates/generic-edit.pt"/>
+ <browser:page
+ for="lp.code.interfaces.gitrepository.IGitRepository"
+ class="lp.code.browser.gitrepository.GitRepositoryEditView"
+ permission="launchpad.Edit"
+ name="+edit"
+ template="../../app/templates/generic-edit.pt"/>
+ <browser:page
+ for="lp.code.interfaces.gitrepository.IGitRepository"
class="lp.code.browser.gitrepository.GitRepositoryDeletionView"
permission="launchpad.Edit"
name="+delete"
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2015-05-26 12:18:12 +0000
+++ lib/lp/code/browser/gitrepository.py 2015-06-04 17:01:40 +0000
@@ -10,24 +10,41 @@
'GitRepositoryBreadcrumb',
'GitRepositoryContextMenu',
'GitRepositoryDeletionView',
+ 'GitRepositoryEditInformationTypeView',
'GitRepositoryEditMenu',
+ 'GitRepositoryEditReviewerView',
+ 'GitRepositoryEditView',
'GitRepositoryNavigation',
'GitRepositoryURL',
'GitRepositoryView',
]
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
+from lazr.restful.interface import copy_field
from storm.expr import Desc
-from zope.interface import implements
+from zope.event import notify
+from zope.interface import (
+ implements,
+ Interface,
+ providedBy,
+ )
from lp.app.browser.informationtype import InformationTypePortletMixin
from lp.app.browser.launchpadform import (
action,
+ custom_widget,
+ LaunchpadEditFormView,
LaunchpadFormView,
)
from lp.app.errors import NotFoundError
+from lp.app.vocabularies import InformationTypeVocabulary
+from lp.app.widgets.itemswidgets import LaunchpadRadioWidgetWithDescription
+from lp.code.errors import GitRepositoryExists
from lp.code.interfaces.gitref import IGitRefBatchNavigator
from lp.code.interfaces.gitrepository import IGitRepository
from lp.services.config import config
+from lp.services.database.constants import UTC_NOW
from lp.services.propertycache import cachedproperty
from lp.services.webapp import (
canonical_url,
@@ -46,6 +63,7 @@
)
from lp.services.webapp.batching import TableBatchNavigator
from lp.services.webapp.breadcrumb import NameBreadcrumb
+from lp.services.webapp.escaping import structured
from lp.services.webapp.interfaces import ICanonicalUrlData
@@ -106,7 +124,17 @@
usedfor = IGitRepository
facet = "branches"
title = "Edit Git repository"
- links = ["delete"]
+ links = ["edit", "reviewer", "delete"]
+
+ @enabled_with_permission("launchpad.Edit")
+ def edit(self):
+ text = "Change repository details"
+ return Link("+edit", text, icon="edit")
+
+ @enabled_with_permission("launchpad.Edit")
+ def reviewer(self):
+ text = "Set repository reviewer"
+ return Link("+reviewer", text, icon="edit")
@enabled_with_permission("launchpad.Edit")
def delete(self):
@@ -119,7 +147,7 @@
usedfor = IGitRepository
facet = "branches"
- links = ["add_subscriber", "source", "subscription"]
+ links = ["add_subscriber", "source", "subscription", "visibility"]
@enabled_with_permission("launchpad.AnyPerson")
def subscription(self):
@@ -144,6 +172,12 @@
url = self.context.getCodebrowseUrl()
return Link(url, text, icon="info")
+ @enabled_with_permission("launchpad.Edit")
+ def visibility(self):
+ """Return the "Change information type" Link."""
+ text = "Change information type"
+ return Link("+edit-information-type", text)
+
class GitRefBatchNavigator(TableBatchNavigator):
"""Batch up the branch listings."""
@@ -201,6 +235,160 @@
return GitRefBatchNavigator(self, self.context)
+class GitRepositoryEditFormView(LaunchpadEditFormView):
+ """Base class for forms that edit a Git repository."""
+
+ field_names = None
+
+ def getInformationTypesToShow(self):
+ """Get the information types to display on the edit form.
+
+ We display a customised set of information types: anything allowed
+ by the repository's model, plus the current type.
+ """
+ allowed_types = set(self.context.getAllowedInformationTypes(self.user))
+ allowed_types.add(self.context.information_type)
+ return allowed_types
+
+ @cachedproperty
+ def schema(self):
+ info_types = self.getInformationTypesToShow()
+
+ class GitRepositoryEditSchema(Interface):
+ """Defines the fields for the edit form.
+
+ This is necessary to make various fields editable that are not
+ normally editable through the interface.
+ """
+ information_type = copy_field(
+ IGitRepository["information_type"], readonly=False,
+ vocabulary=InformationTypeVocabulary(types=info_types))
+ name = copy_field(IGitRepository["name"], readonly=False)
+ reviewer = copy_field(IGitRepository["reviewer"], required=True)
+
+ return GitRepositoryEditSchema
+
+ @property
+ def page_title(self):
+ return "Edit %s" % self.context.display_name
+
+ @property
+ def label(self):
+ return self.page_title
+
+ @property
+ def adapters(self):
+ """See `LaunchpadFormView`."""
+ return {self.schema: self.context}
+
+ @action("Change Git Repository", name="change",
+ failure=LaunchpadFormView.ajax_failure_handler)
+ def change_action(self, action, data):
+ # If the owner has changed, add an explicit notification. We take
+ # our own snapshot here to make sure that the snapshot records
+ # changes to the owner, and we notify the listeners explicitly below
+ # rather than the notification that would normally be sent in
+ # updateContextFromData.
+ changed = False
+ repository_before_modification = Snapshot(
+ self.context, providing=providedBy(self.context))
+ if "name" in data:
+ name = data.pop("name")
+ if name != self.context.name:
+ self.context.setName(name, self.user)
+ changed = True
+ if "information_type" in data:
+ information_type = data.pop("information_type")
+ self.context.transitionToInformationType(
+ information_type, self.user)
+ if "reviewer" in data:
+ reviewer = data.pop("reviewer")
+ if reviewer != self.context.code_reviewer:
+ if reviewer == self.context.owner:
+ # Clear the reviewer if set to the same as the owner.
+ self.context.reviewer = None
+ else:
+ self.context.reviewer = reviewer
+ changed = True
+
+ if self.updateContextFromData(data, notify_modified=False):
+ changed = True
+
+ if changed:
+ # Notify that the object has changed with the snapshot that was
+ # taken earlier.
+ field_names = [
+ form_field.__name__ for form_field in self.form_fields]
+ notify(ObjectModifiedEvent(
+ self.context, repository_before_modification, field_names))
+ # Only specify that the context was modified if there
+ # was in fact a change.
+ self.context.date_last_modified = UTC_NOW
+
+ if self.request.is_ajax:
+ return ""
+
+ @property
+ def next_url(self):
+ """Return the next URL to call when this call completes."""
+ if not self.request.is_ajax and not self.errors:
+ return self.cancel_url
+ return None
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+
+
+class GitRepositoryEditInformationTypeView(GitRepositoryEditFormView):
+ """A view to set the information type."""
+
+ field_names = ["information_type"]
+
+
+class GitRepositoryEditReviewerView(GitRepositoryEditFormView):
+ """A view to set the review team."""
+
+ field_names = ["reviewer"]
+
+ @property
+ def initial_values(self):
+ return {"reviewer": self.context.code_reviewer}
+
+
+class GitRepositoryEditView(GitRepositoryEditFormView):
+ """The main view for editing repository attributes."""
+
+ field_names = [
+ "name",
+ "information_type",
+ ]
+
+ custom_widget("information_type", LaunchpadRadioWidgetWithDescription)
+
+ def _setRepositoryExists(self, existing_repository, field_name="name"):
+ owner = existing_repository.owner
+ if owner == self.user:
+ prefix = "You already have"
+ else:
+ prefix = "%s already has" % owner.displayname
+ message = structured(
+ "%s a repository for <em>%s</em> called <em>%s</em>.",
+ prefix, existing_repository.target.displayname,
+ existing_repository.name)
+ self.setFieldError(field_name, message)
+
+ def validate(self, data):
+ if "name" in data:
+ name = data["name"]
+ if name != self.context.name:
+ namespace = self.context.namespace
+ try:
+ namespace.validateMove(self.context, self.user, name=name)
+ except GitRepositoryExists as e:
+ self._setRepositoryExists(e.existing_repository)
+
+
class GitRepositoryDeletionView(LaunchpadFormView):
schema = IGitRepository
=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py 2015-05-19 11:31:59 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py 2015-06-04 17:01:40 +0000
@@ -20,10 +20,14 @@
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.app.interfaces.services import IService
from lp.code.interfaces.revision import IRevisionSet
+from lp.registry.enums import BranchSharingPolicy
from lp.registry.interfaces.person import PersonVisibility
+from lp.services.database.constants import UTC_NOW
from lp.services.webapp.publisher import canonical_url
+from lp.services.webapp.servers import LaunchpadTestRequest
from lp.testing import (
admin_logged_in,
BrowserTestCase,
@@ -41,7 +45,10 @@
setupBrowserForUser,
)
from lp.testing.publication import test_traverse
-from lp.testing.views import create_initialized_view
+from lp.testing.views import (
+ create_initialized_view,
+ create_view,
+ )
class TestGitRepositoryNavigation(TestCaseWithFactory):
@@ -186,6 +193,185 @@
self.assertThat(recorder2, HasQueryCount(Equals(recorder1.count)))
+class TestGitRepositoryEditReviewerView(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_initial_reviewer_not_set(self):
+ # If the reviewer is not set, the field is populated with the owner
+ # of the repository.
+ repository = self.factory.makeGitRepository()
+ self.assertIsNone(repository.reviewer)
+ view = create_view(repository, "+reviewer")
+ self.assertEqual(repository.owner, view.initial_values["reviewer"])
+
+ def test_initial_reviewer_set(self):
+ # If the reviewer has been set, it is shown as the initial value.
+ repository = self.factory.makeGitRepository()
+ login_person(repository.owner)
+ repository.reviewer = self.factory.makePerson()
+ view = create_view(repository, "+reviewer")
+ self.assertEqual(repository.reviewer, view.initial_values["reviewer"])
+
+ def test_set_reviewer(self):
+ # Test setting the reviewer.
+ repository = self.factory.makeGitRepository()
+ reviewer = self.factory.makePerson()
+ login_person(repository.owner)
+ view = create_initialized_view(repository, "+reviewer")
+ view.change_action.success({"reviewer": reviewer})
+ self.assertEqual(reviewer, repository.reviewer)
+ # Last modified has been updated.
+ self.assertSqlAttributeEqualsDate(
+ repository, "date_last_modified", UTC_NOW)
+
+ def test_set_reviewer_as_owner_clears_reviewer(self):
+ # If the reviewer is set to be the repository owner, the review
+ # field is cleared in the database.
+ repository = self.factory.makeGitRepository()
+ login_person(repository.owner)
+ repository.reviewer = self.factory.makePerson()
+ view = create_initialized_view(repository, "+reviewer")
+ view.change_action.success({"reviewer": repository.owner})
+ self.assertIsNone(repository.reviewer)
+ # Last modified has been updated.
+ self.assertSqlAttributeEqualsDate(
+ repository, "date_last_modified", UTC_NOW)
+
+ def test_set_reviewer_to_same_does_not_update_last_modified(self):
+ # If the user has set the reviewer to be same and clicked on save,
+ # then the underlying object hasn't really been changed, so the last
+ # modified is not updated.
+ modified_date = datetime(2007, 1, 1, tzinfo=pytz.UTC)
+ repository = self.factory.makeGitRepository(date_created=modified_date)
+ view = create_initialized_view(repository, "+reviewer")
+ view.change_action.success({"reviewer": repository.owner})
+ self.assertIsNone(repository.reviewer)
+ # Last modified has not been updated.
+ self.assertEqual(modified_date, repository.date_last_modified)
+
+
+class TestGitRepositoryEditView(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_rename(self):
+ # The name of a repository can be changed via the UI by an
+ # authorised user.
+ person = self.factory.makePerson()
+ repository = self.factory.makeGitRepository(owner=person, name=u"foo")
+ browser = self.getUserBrowser(
+ canonical_url(repository) + "/+edit", user=person)
+ browser.getControl(name="field.name").value = u"bar"
+ browser.getControl("Change Git Repository").click()
+ with person_logged_in(person):
+ self.assertEqual(u"bar", repository.name)
+
+ def test_information_type_in_ui(self):
+ # The information_type of a repository can be changed via the UI by
+ # an authorised user.
+ person = self.factory.makePerson()
+ repository = self.factory.makeGitRepository(owner=person)
+ admin = getUtility(ILaunchpadCelebrities).admin.teamowner
+ browser = self.getUserBrowser(
+ canonical_url(repository) + "/+edit", user=admin)
+ browser.getControl("Private", index=1).click()
+ browser.getControl("Change Git Repository").click()
+ with person_logged_in(person):
+ self.assertEqual(
+ InformationType.USERDATA, repository.information_type)
+
+ def test_edit_view_ajax_render(self):
+ # An information type change request is processed as expected when
+ # an XHR request is made to the view.
+ person = self.factory.makePerson()
+ repository = self.factory.makeGitRepository(owner=person)
+
+ extra = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"}
+ request = LaunchpadTestRequest(
+ method="POST", form={
+ "field.actions.change": "Change Git Repository",
+ "field.information_type": "PUBLICSECURITY"},
+ **extra)
+ with person_logged_in(person):
+ view = create_initialized_view(
+ repository, name="+edit-information-type",
+ request=request, principal=person)
+ request.traversed_objects = [
+ person, repository.target, repository, view]
+ result = view.render()
+ self.assertEqual("", result)
+ self.assertEqual(
+ repository.information_type, InformationType.PUBLICSECURITY)
+
+
+class TestGitRepositoryEditViewInformationTypes(TestCaseWithFactory):
+ """Tests for GitRepositoryEditView.getInformationTypesToShow."""
+
+ layer = DatabaseFunctionalLayer
+
+ def assertShownTypes(self, types, repository, user=None):
+ if user is None:
+ user = removeSecurityProxy(repository).owner
+ with person_logged_in(user):
+ view = create_initialized_view(repository, "+edit", user=user)
+ self.assertContentEqual(types, view.getInformationTypesToShow())
+
+ def test_public_repository(self):
+ # A normal public repository on a public project can be any
+ # information type except embargoed and proprietary.
+ # The model doesn't enforce this, so it's just a UI thing.
+ repository = self.factory.makeGitRepository(
+ information_type=InformationType.PUBLIC)
+ self.assertShownTypes(
+ [InformationType.PUBLIC, InformationType.PUBLICSECURITY,
+ InformationType.PRIVATESECURITY, InformationType.USERDATA],
+ repository)
+
+ def test_repository_with_disallowed_type(self):
+ # We don't force repositories with a disallowed type (e.g.
+ # Proprietary on a non-commercial project) to change, so the current
+ # type is shown.
+ project = self.factory.makeProduct()
+ self.factory.makeAccessPolicy(pillar=project)
+ repository = self.factory.makeGitRepository(
+ target=project, information_type=InformationType.PROPRIETARY)
+ self.assertShownTypes(
+ [InformationType.PUBLIC, InformationType.PUBLICSECURITY,
+ InformationType.PRIVATESECURITY, InformationType.USERDATA,
+ InformationType.PROPRIETARY], repository)
+
+ def test_repository_for_project_with_embargoed_and_proprietary(self):
+ # Repositories for commercial projects which have a policy of
+ # embargoed or proprietary allow only embargoed and proprietary
+ # types.
+ owner = self.factory.makePerson()
+ project = self.factory.makeProduct(owner=owner)
+ self.factory.makeCommercialSubscription(product=project)
+ with person_logged_in(owner):
+ project.setBranchSharingPolicy(
+ BranchSharingPolicy.EMBARGOED_OR_PROPRIETARY)
+ repository = self.factory.makeGitRepository(
+ owner=owner, target=project,
+ information_type=InformationType.PROPRIETARY)
+ self.assertShownTypes(
+ [InformationType.EMBARGOED, InformationType.PROPRIETARY],
+ repository)
+
+ def test_repository_for_project_with_proprietary(self):
+ # Repositories for commercial projects which have a policy of
+ # proprietary allow only the proprietary type.
+ owner = self.factory.makePerson()
+ product = self.factory.makeProduct(owner=owner)
+ self.factory.makeCommercialSubscription(product=product)
+ with person_logged_in(owner):
+ product.setBranchSharingPolicy(BranchSharingPolicy.PROPRIETARY)
+ repository = self.factory.makeGitRepository(
+ owner=owner, target=product,
+ information_type=InformationType.PROPRIETARY)
+ self.assertShownTypes([InformationType.PROPRIETARY], repository)
+
+
class TestGitRepositoryDeletionView(BrowserTestCase):
layer = DatabaseFunctionalLayer
=== modified file 'lib/lp/code/javascript/tests/test_information_type_choice.js'
--- lib/lp/code/javascript/tests/test_information_type_choice.js 2013-03-20 03:41:40 +0000
+++ lib/lp/code/javascript/tests/test_information_type_choice.js 2015-06-04 17:01:40 +0000
@@ -60,6 +60,7 @@
makeWidget: function() {
this.widget = new ns.BranchInformationTypeWidget({
io_provider: this.mockio,
+ object_type: 'Branch',
use_animation: false
});
this.widget.render();
=== modified file 'lib/lp/code/templates/branch-portlet-privacy.pt'
--- lib/lp/code/templates/branch-portlet-privacy.pt 2012-08-28 01:52:34 +0000
+++ lib/lp/code/templates/branch-portlet-privacy.pt 2015-06-04 17:01:40 +0000
@@ -24,8 +24,11 @@
LPJS.use('lp.code.branch.information_type_choice', function(Y) {
Y.on('domready',
function(e) {
- var widget = new Y.lp.code.branch.information_type_choice.BranchInformationTypeWidget();
- widget.render();
+ var config = {
+ object_type: 'Branch'
+ };
+ var widget = new Y.lp.code.branch.information_type_choice.BranchInformationTypeWidget(config);
+ widget.render();
},
window);
});
=== modified file 'lib/lp/code/templates/gitrepository-index.pt'
--- lib/lp/code/templates/gitrepository-index.pt 2015-04-21 09:31:58 +0000
+++ lib/lp/code/templates/gitrepository-index.pt 2015-06-04 17:01:40 +0000
@@ -18,6 +18,7 @@
<body>
<metal:side fill-slot="side">
+ <div tal:replace="structure context/@@+portlet-privacy" />
<div tal:replace="structure context/@@+global-actions" />
<tal:subscribers replace="structure context/@@+portlet-subscribers" />
</metal:side>
=== added file 'lib/lp/code/templates/gitrepository-portlet-privacy.pt'
--- lib/lp/code/templates/gitrepository-portlet-privacy.pt 1970-01-01 00:00:00 +0000
+++ lib/lp/code/templates/gitrepository-portlet-privacy.pt 2015-06-04 17:01:40 +0000
@@ -0,0 +1,37 @@
+<div
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ id="privacy"
+ tal:attributes="
+ class python: path('context/private') and 'portlet private' or 'portlet public'
+ "
+ tal:define="link context/menu:context/visibility"
+>
+ <span id="information-type-summary"
+ tal:attributes="class view/information_type_css;">This repository
+ contains
+ <strong id="information-type" tal:content="view/information_type"></strong>
+ information</span> <a class="sprite edit action-icon" id="privacy-link"
+ tal:attributes="href link/path" tal:condition="link/enabled"
+ >Edit</a>
+ <div id="information-type-description" style="padding-top: 5px"
+ tal:content="view/information_type_description"></div>
+</div>
+
+<tal:script>
+ <script type="text/javascript">
+ LPJS.use('lp.code.branch.information_type_choice', function(Y) {
+ Y.on('domready',
+ function(e) {
+ var config = {
+ object_type: 'Git Repository'
+ };
+ var widget = new Y.lp.code.branch.information_type_choice.BranchInformationTypeWidget(config);
+ widget.render();
+ },
+ window);
+ });
+ </script>
+</tal:script>
+
Follow ups