launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25224
[Merge] ~cjwatson/launchpad:oci-recipe-push-rules-add into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:oci-recipe-push-rules-add into launchpad:master.
Commit message:
Allow adding push rules on OCIRecipe:+edit-push-rules
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/389875
This is https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/388458, but with a few bugs fixed and with an improved visual layout.
Screenshot: https://people.canonical.com/~cjwatson/tmp/oci-recipe-edit-push-rules.png
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:oci-recipe-push-rules-add into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 3dd764c..530bbf6 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -25,12 +25,18 @@ from lazr.restful.interface import (
)
from zope.component import getUtility
from zope.formlib.form import FormFields
+from zope.formlib.widget import (
+ DisplayWidget,
+ renderElement,
+ )
from zope.interface import Interface
from zope.schema import (
Bool,
Choice,
List,
+ Password,
TextLine,
+ ValidationError,
)
from lp.app.browser.launchpadform import (
@@ -44,7 +50,10 @@ from lp.app.errors import UnexpectedFormData
from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
from lp.buildmaster.interfaces.processor import IProcessorSet
from lp.code.browser.widgets.gitref import GitRefWidget
-from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
+from lp.oci.interfaces.ocipushrule import (
+ IOCIPushRuleSet,
+ OCIPushRuleAlreadyExists,
+ )
from lp.oci.interfaces.ocirecipe import (
IOCIRecipe,
IOCIRecipeSet,
@@ -56,6 +65,8 @@ from lp.oci.interfaces.ocirecipe import (
)
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
from lp.oci.interfaces.ociregistrycredentials import (
+ IOCIRegistryCredentialsSet,
+ OCIRegistryCredentialsAlreadyExist,
user_can_edit_credentials_for_owner,
)
from lp.services.features import getFeatureFlag
@@ -72,6 +83,7 @@ from lp.services.webapp import (
stepthrough,
structured,
)
+from lp.services.webapp.authorization import check_permission
from lp.services.webapp.batching import BatchNavigator
from lp.services.webapp.breadcrumb import NameBreadcrumb
from lp.services.webhooks.browser import WebhookTargetNavigationMixin
@@ -264,7 +276,17 @@ def new_builds_notification_text(builds, already_pending=None):
return builds_text
-class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
+class InvisibleCredentialsWidget(DisplayWidget):
+ """A widget that just displays a private icon.
+
+ This indicates invisible credentials.
+ """
+
+ def __call__(self):
+ return renderElement("span", id=self.name, cssClass="sprite private")
+
+
+class OCIRecipeEditPushRulesView(LaunchpadFormView):
"""View for +ocirecipe-edit-push-rules.pt."""
class schema(Interface):
@@ -311,41 +333,94 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
return field_type, rule_id
def setUpFields(self):
- """See `LaunchpadEditFormView`."""
- LaunchpadEditFormView.setUpFields(self)
-
- image_fields = []
+ super(OCIRecipeEditPushRulesView, self).setUpFields()
+ image_name_fields = []
+ url_fields = []
+ private_url_fields = []
+ username_fields = []
+ private_username_fields = []
+ password_fields = []
delete_fields = []
- creds = []
for elem in list(self.context.push_rules):
- image_fields.append(
+ image_name_fields.append(
TextLine(
- title=u'Image name',
__name__=self._getFieldName('image_name', elem.id),
default=elem.image_name,
required=True, readonly=False))
+ if check_permission('launchpad.View', elem.registry_credentials):
+ url_fields.append(
+ TextLine(
+ __name__=self._getFieldName('url', elem.id),
+ default=elem.registry_credentials.url,
+ required=True, readonly=True))
+ username_fields.append(
+ TextLine(
+ __name__=self._getFieldName('username', elem.id),
+ default=elem.registry_credentials.username,
+ required=True, readonly=True))
+ else:
+ private_url_fields.append(
+ TextLine(
+ __name__=self._getFieldName('url', elem.id),
+ default='', required=True, readonly=True))
+ private_username_fields.append(
+ TextLine(
+ __name__=self._getFieldName('username', elem.id),
+ default='', required=True, readonly=True))
delete_fields.append(
Bool(
- title=u'Delete',
__name__=self._getFieldName('delete', elem.id),
default=False,
required=True, readonly=False))
- creds.append(
- TextLine(
- title=u'Username',
- __name__=self._getFieldName('username', elem.id),
- default=elem.registry_credentials.username,
- required=True, readonly=True))
- creds.append(
- TextLine(
- title=u'Registry URL',
- __name__=self._getFieldName('url', elem.id),
- default=elem.registry_credentials.url,
- required=True, readonly=True))
-
- self.form_fields += FormFields(*image_fields)
- self.form_fields += FormFields(*creds)
- self.form_fields += FormFields(*delete_fields)
+ image_name_fields.append(
+ TextLine(
+ __name__=u'add_image_name',
+ required=False, readonly=False))
+ add_credentials = Choice(
+ __name__='add_credentials',
+ default='existing', values=('existing', 'new'),
+ required=False, readonly=False)
+ existing_credentials = Choice(
+ vocabulary='OCIRegistryCredentials',
+ required=False,
+ __name__=u'existing_credentials')
+ url_fields.append(
+ TextLine(
+ __name__=u'add_url',
+ required=False, readonly=False))
+ username_fields.append(
+ TextLine(
+ __name__=u'add_username',
+ required=False, readonly=False))
+ password_fields.append(
+ Password(
+ __name__=u'add_password',
+ required=False, readonly=False))
+ password_fields.append(
+ Password(
+ __name__=u'add_confirm_password',
+ required=False, readonly=False))
+
+ self.form_fields = (
+ FormFields(*image_name_fields) +
+ FormFields(*url_fields) +
+ FormFields(
+ *private_url_fields,
+ custom_widget=InvisibleCredentialsWidget) +
+ FormFields(*username_fields) +
+ FormFields(
+ *private_username_fields,
+ custom_widget=InvisibleCredentialsWidget) +
+ FormFields(*password_fields) +
+ FormFields(*delete_fields) +
+ FormFields(add_credentials, existing_credentials))
+
+ def setUpWidgets(self, context=None):
+ """See `LaunchpadFormView`."""
+ super(OCIRecipeEditPushRulesView, self).setUpWidgets(context=context)
+ for widget in self.widgets:
+ widget.display_label = False
+ widget.hint = None
@property
def label(self):
@@ -357,28 +432,60 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
def cancel_url(self):
return canonical_url(self.context)
- def getRulesWidgets(self, rule):
-
+ def getRuleWidgets(self, rule):
widgets_by_name = {widget.name: widget for widget in self.widgets}
+ url_field_name = (
+ "field." + self._getFieldName("url", rule.id))
image_field_name = (
"field." + self._getFieldName("image_name", rule.id))
username_field_name = (
"field." + self._getFieldName("username", rule.id))
- url_field_name = (
- "field." + self._getFieldName("url", rule.id))
delete_field_name = (
"field." + self._getFieldName("delete", rule.id))
return {
+ "url": widgets_by_name[url_field_name],
"image_name": widgets_by_name[image_field_name],
"username": widgets_by_name[username_field_name],
- "url": widgets_by_name[url_field_name],
"delete": widgets_by_name[delete_field_name],
}
+ def getNewRuleWidgets(self):
+ widgets_by_name = {widget.name: widget for widget in self.widgets}
+ return {
+ "image_name": widgets_by_name["field.add_image_name"],
+ "existing_credentials":
+ widgets_by_name["field.existing_credentials"],
+ "url": widgets_by_name["field.add_url"],
+ "username": widgets_by_name["field.add_username"],
+ "password": widgets_by_name["field.add_password"],
+ "confirm_password": widgets_by_name["field.add_confirm_password"],
+ }
+
def parseData(self, data):
"""Rearrange form data to make it easier to process."""
parsed_data = {}
-
+ add_image_name = data.get("add_image_name")
+ add_url = data.get("add_url")
+ add_username = data.get("add_username")
+ add_password = data.get("add_password")
+ add_confirm_password = data.get("add_confirm_password")
+ add_existing_credentials = data.get("existing_credentials")
+
+ # parse data from the Add new rule section of the form
+ if (add_url or add_username or add_password or
+ add_confirm_password or add_image_name or
+ add_existing_credentials):
+ parsed_data.setdefault(None, {
+ "image_name": add_image_name,
+ "url": add_url,
+ "username": add_username,
+ "password": add_password,
+ "confirm_password": add_confirm_password,
+ "existing_credentials": data["existing_credentials"],
+ "add_credentials": data["add_credentials"],
+ "action": "add",
+ })
+ # parse data from the Edit existing rule section of the form
for field_name in sorted(
name for name in data if name.split(".")[0] == "image_name"):
_, rule_id = self._parseFieldName(field_name)
@@ -395,6 +502,68 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
return parsed_data
+ def addNewRule(self, parsed_data):
+ add_data = parsed_data[None]
+ image_name = add_data.get("image_name")
+ url = add_data.get("url")
+ password = add_data.get("password")
+ confirm_password = add_data.get("confirm_password")
+ username = add_data.get("username")
+ existing_credentials = add_data.get("existing_credentials")
+ add_credentials = add_data.get("add_credentials")
+
+ if not image_name:
+ self.setFieldError("add_image_name", "Image name must be set.")
+ return
+
+ if add_credentials == "existing":
+ if not existing_credentials:
+ return
+
+ try:
+ getUtility(IOCIPushRuleSet).new(
+ self.context, existing_credentials, image_name)
+ except OCIPushRuleAlreadyExists:
+ self.setFieldError(
+ "add_image_name",
+ "A push rule already exists with the same URL, image name "
+ "and credentials.")
+
+ elif add_credentials == "new":
+ if not url:
+ self.setFieldError("add_url", "Registry URL must be set.")
+ return
+ if password != confirm_password:
+ self.setFieldError(
+ "password",
+ "Please make sure the new password matches the confirm "
+ "password field.")
+ return
+
+ credentials_set = getUtility(IOCIRegistryCredentialsSet)
+ try:
+ credentials = credentials_set.getOrCreate(
+ owner=self.context.owner, url=url,
+ credentials={'username': username, 'password': password})
+ except OCIRegistryCredentialsAlreadyExist:
+ self.setFieldError(
+ "add_url",
+ "Credentials already exist with the same URL and "
+ "username.")
+ return
+ except ValidationError:
+ self.setFieldError("add_url", "Not a valid URL.")
+ return
+
+ try:
+ getUtility(IOCIPushRuleSet).new(
+ self.context, credentials, image_name)
+ except OCIPushRuleAlreadyExists:
+ self.setFieldError(
+ "add_image_name",
+ "A push rule already exists with the same URL, image "
+ "name, and credentials.")
+
def updatePushRulesFromData(self, parsed_data):
rules_map = {
rule.id: rule
@@ -415,6 +584,8 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
rule.setNewImageName(image_name)
elif action == "delete":
rule.destroySelf()
+ elif action == "add":
+ self.addNewRule(parsed_data)
else:
raise AssertionError("unknown action: %s" % action)
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index e2cdd77..14e9ac3 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -17,9 +17,12 @@ import re
from fixtures import FakeLogger
import pytz
+from six.moves.urllib.parse import quote
import soupmatchers
+from storm.locals import Store
from testtools.matchers import (
Equals,
+ Is,
MatchesDict,
MatchesSetwise,
MatchesStructure,
@@ -887,12 +890,20 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
"oci.build_series.%s" % self.distroseries.distribution.name:
self.distroseries.name,
}))
- oci_project = self.factory.makeOCIProject(
+ self.oci_project = self.factory.makeOCIProject(
pillar=self.distroseries.distribution,
ociprojectname="oci-project-name")
+
+ self.member = self.factory.makePerson()
+ self.team = self.factory.makeTeam(members=[self.person, self.member])
+
self.recipe = self.factory.makeOCIRecipe(
name="recipe-name", registrant=self.person, owner=self.person,
- oci_project=oci_project)
+ oci_project=self.oci_project)
+
+ self.team_owned_recipe = self.factory.makeOCIRecipe(
+ name="recipe-name", registrant=self.person, owner=self.team,
+ oci_project=self.oci_project)
self.setConfig()
@@ -1013,7 +1024,7 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
"Image name", "td", text=image_name))))
def test_edit_oci_push_rules(self):
- url = unicode(self.factory.getUniqueURL())
+ url = self.factory.getUniqueURL()
credentials = {'username': 'foo', 'password': 'bar'}
registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
owner=self.person,
@@ -1077,8 +1088,52 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
self.assertRaises(OCIPushRuleAlreadyExists,
browser.getControl("Save").click)
+ def test_edit_oci_push_rules_non_owner_of_credentials(self):
+ url = self.factory.getUniqueURL()
+ credentials = {'username': 'foo', 'password': 'bar'}
+ registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+ owner=self.person,
+ url=url,
+ credentials=credentials)
+ image_names = [self.factory.getUniqueUnicode() for _ in range(2)]
+ push_rules = [
+ getUtility(IOCIPushRuleSet).new(
+ recipe=self.team_owned_recipe,
+ registry_credentials=registry_credentials,
+ image_name=image_name)
+ for image_name in image_names]
+ Store.of(push_rules[-1]).flush()
+ push_rule_ids = [push_rule.id for push_rule in push_rules]
+ browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
+ browser.getLink("Edit push rules").click()
+ row = soupmatchers.Tag(
+ "push rule row", "tr", attrs={"class": "push-rule"})
+ self.assertThat(browser.contents, soupmatchers.HTMLContains(
+ soupmatchers.Within(
+ row,
+ soupmatchers.Tag(
+ "username widget", "span",
+ attrs={
+ "id": "field.username.%d" % push_rule_ids[0],
+ "class": "sprite private",
+ })),
+ soupmatchers.Within(
+ row,
+ soupmatchers.Tag(
+ "url widget", "span",
+ attrs={
+ "id": "field.url.%d" % push_rule_ids[0],
+ "class": "sprite private",
+ }))))
+ browser.getControl(
+ name="field.image_name.%d" % push_rule_ids[0]).value = "image1"
+ browser.getControl("Save").click()
+ with person_logged_in(self.member):
+ self.assertEqual("image1", push_rules[0].image_name)
+ self.assertEqual(image_names[1], push_rules[1].image_name)
+
def test_delete_oci_push_rules(self):
- url = unicode(self.factory.getUniqueURL())
+ url = self.factory.getUniqueURL()
credentials = {'username': 'foo', 'password': 'bar'}
registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
owner=self.person,
@@ -1100,8 +1155,215 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
self.assertIsNone(
getUtility(IOCIPushRuleSet).getByID(push_rule.id))
+ def test_add_oci_push_rules_validations(self):
+ # Add new rule works when there are no rules in the DB.
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+
+ # Save does not error if there are no changes on the form.
+ browser.getControl("Save").click()
+ self.assertIn("Saved push rules", browser.contents)
+
+ # If only an image name is given but no registry URL, we fail with
+ # "Registry URL must be set".
+ browser.getLink("Edit push rules").click()
+ browser.getControl(name="field.add_image_name").value = "imagename1"
+ browser.getControl(name="field.add_credentials").value = "new"
+ browser.getControl("Save").click()
+ self.assertIn("Registry URL must be set", browser.contents)
+
+ # No image name entered on the form. We assume user is only editing
+ # and we allow saving the form.
+ browser.getControl(name="field.add_image_name").value = ""
+ browser.getControl("Save").click()
+ self.assertIn("Saved push rules", browser.contents)
+
+ def test_add_oci_push_rules_new_empty_credentials(self):
+ # Supplying an image name and registry URL creates a credentials
+ # object without username or password, and a valid push rule based
+ # on that credentials object.
+ url = self.factory.getUniqueURL()
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ browser.getControl(name="field.add_credentials").value = "new"
+ browser.getControl(name="field.add_image_name").value = "imagename1"
+ browser.getControl(name="field.add_url").value = url
+ browser.getControl("Save").click()
+ with person_logged_in(self.person):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+ self.assertEqual(len(rules), 1)
+ rule = rules[0]
+ self.assertThat(rule, MatchesStructure(
+ image_name=Equals("imagename1"),
+ registry_url=Equals(url),
+ registry_credentials=MatchesStructure(
+ url=Equals(url),
+ username=Is(None))))
+
+ with person_logged_in(self.person):
+ self.assertEqual(
+ {"password": None}, rule.registry_credentials.getCredentials())
+
+ def test_add_oci_push_rules_new_username_password(self):
+ # Supplying an image name, registry URL, username, and password
+ # creates a credentials object with the given username or password,
+ # and a valid push rule based on that credentials object.
+ url = self.factory.getUniqueURL()
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ browser.getControl(name="field.add_credentials").value = "new"
+ browser.getControl(name="field.add_image_name").value = "imagename3"
+ browser.getControl(name="field.add_url").value = url
+ browser.getControl(name="field.add_username").value = "username"
+ browser.getControl(name="field.add_password").value = "password"
+ browser.getControl(
+ name="field.add_confirm_password").value = "password"
+ browser.getControl("Save").click()
+ with person_logged_in(self.person):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+ self.assertEqual(len(rules), 1)
+ rule = rules[0]
+ self.assertThat(rule, MatchesStructure(
+ image_name=Equals("imagename3"),
+ registry_url=Equals(url),
+ registry_credentials=MatchesStructure.byEquality(
+ url=url,
+ username="username")))
+ with person_logged_in(self.person):
+ self.assertEqual(
+ {"username": "username", "password": "password"},
+ rule.registry_credentials.getCredentials())
+
+ def test_add_oci_push_rules_existing_credentials_duplicate(self):
+ # Adding a new push rule using existing credentials fails if a rule
+ # with the same image name already exists.
+ existing_rule = self.factory.makeOCIPushRule(
+ recipe=self.recipe,
+ registry_credentials=self.factory.makeOCIRegistryCredentials(
+ owner=self.recipe.owner))
+ existing_image_name = existing_rule.image_name
+ existing_registry_url = existing_rule.registry_url
+ existing_username = existing_rule.username
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ browser.getControl(name="field.add_credentials").value = "existing"
+ browser.getControl(name="field.add_image_name").value = (
+ existing_image_name)
+ browser.getControl(name="field.existing_credentials").value = (
+ "%s %s" % (quote(existing_registry_url), quote(existing_username)))
+ browser.getControl("Save").click()
+ self.assertIn(
+ "A push rule already exists with the same URL, "
+ "image name and credentials.", browser.contents)
+
+ def test_add_oci_push_rules_existing_credentials(self):
+ # Previously added registry credentials can be chosen from the radio
+ # widget when adding a new rule.
+ # We correctly display the radio buttons widget when the
+ # username is empty in registry credentials and
+ # allow correctly adding new rule based on it
+ existing_rule = self.factory.makeOCIPushRule(
+ recipe=self.recipe,
+ registry_credentials=self.factory.makeOCIRegistryCredentials(
+ owner=self.recipe.owner, credentials={}))
+ existing_registry_url = existing_rule.registry_url
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ browser.getControl(name="field.add_credentials").value = "existing"
+ browser.getControl(name="field.add_image_name").value = "imagename2"
+ browser.getControl(name="field.existing_credentials").value = (
+ quote(existing_registry_url))
+ browser.getControl("Save").click()
+ with person_logged_in(self.person):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+ self.assertEqual(len(rules), 2)
+ rule = rules[1]
+ self.assertThat(rule, MatchesStructure(
+ image_name=Equals("imagename2"),
+ registry_url=Equals(existing_registry_url),
+ registry_credentials=MatchesStructure(
+ url=Equals(existing_registry_url),
+ username=Is(None))))
+ with person_logged_in(self.person):
+ self.assertEqual({}, rule.registry_credentials.getCredentials())
+
+ def test_add_oci_push_rules_team_owned(self):
+ url = self.factory.getUniqueURL()
+ browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
+ browser.getLink("Edit push rules").click()
+ browser.getControl(
+ name="field.add_image_name").value = "imagename1"
+ browser.getControl(
+ name="field.add_url").value = url
+ browser.getControl(name="field.add_credentials").value = "new"
+ browser.getControl("Save").click()
+
+ with person_logged_in(self.member):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(
+ self.team_owned_recipe)))
+ self.assertEqual(len(rules), 1)
+ rule = rules[0]
+ self.assertThat(rule, MatchesStructure(
+ image_name=Equals(u'imagename1'),
+ registry_url=Equals(url),
+ registry_credentials=MatchesStructure(
+ url=Equals(url),
+ username=Is(None))))
+
+ with person_logged_in(self.member):
+ self.assertThat(
+ rule.registry_credentials.getCredentials(),
+ MatchesDict(
+ {"password": Equals(None)}))
+
+ def test_edit_oci_push_rules_team_owned(self):
+ url = self.factory.getUniqueURL()
+ browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
+ browser.getLink("Edit push rules").click()
+ browser.getControl(
+ name="field.add_image_name").value = "imagename1"
+ browser.getControl(
+ name="field.add_url").value = url
+ browser.getControl(name="field.add_credentials").value = "new"
+ browser.getControl("Save").click()
+
+ # push rules created by another team member (self.member)
+ # can be edited by self.person
+ browser = self.getViewBrowser(self.team_owned_recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ with person_logged_in(self.person):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(
+ self.team_owned_recipe)))
+ self.assertEqual(len(rules), 1)
+ rule = rules[0]
+ self.assertEqual("imagename1", browser.getControl(
+ name="field.image_name.%d" % rule.id).value)
+
+ # set image name to valid string
+ with person_logged_in(self.person):
+ browser.getControl(
+ name="field.image_name.%d" % rule.id).value = "image1"
+ browser.getControl("Save").click()
+
+ # and assert model changed
+ with person_logged_in(self.member):
+ self.assertEqual(
+ rule.image_name, "image1")
+
+ # self.member will see the new image name
+ browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
+ browser.getLink("Edit push rules").click()
+ with person_logged_in(self.member):
+ self.assertEqual("image1", browser.getControl(
+ name="field.image_name.%d" % rule.id).value)
+
def test_edit_oci_registry_creds(self):
- url = unicode(self.factory.getUniqueURL())
+ url = self.factory.getUniqueURL()
credentials = {'username': 'foo', 'password': 'bar'}
image_name = self.factory.getUniqueUnicode()
registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
diff --git a/lib/lp/oci/javascript/ocirecipe.edit.js b/lib/lp/oci/javascript/ocirecipe.edit.js
new file mode 100644
index 0000000..8b3d82d
--- /dev/null
+++ b/lib/lp/oci/javascript/ocirecipe.edit.js
@@ -0,0 +1,38 @@
+/* Copyright 2015-2020 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * @module Y.lp.oci.ocirecipe.edit
+ * @requires node, DOM
+ */
+YUI.add('lp.oci.ocirecipe.edit', function(Y) {
+ var module = Y.namespace('lp.oci.ocirecipe.edit');
+
+ module.set_enabled = function(field_id, is_enabled) {
+ var field = Y.DOM.byId(field_id);
+ if (field !== null) {
+ field.disabled = !is_enabled;
+ }
+ };
+
+ module.onclick_add_credentials = function(e) {
+ var value = '';
+ Y.all('input[name="field.add_credentials"]').each(function(node) {
+ if (node.get('checked')) {
+ value = node.get('value');
+ }
+ });
+ module.set_enabled('field.existing_credentials', value === 'existing');
+ module.set_enabled('field.add_url', value === 'new');
+ module.set_enabled('field.add_username', value === 'new');
+ module.set_enabled('field.add_password', value === 'new');
+ module.set_enabled('field.add_confirm_password', value === 'new');
+ };
+
+ module.setup = function() {
+ Y.all('input[name="field.add_credentials"]').on(
+ 'click', module.onclick_add_credentials);
+
+ // Set the initial state.
+ module.onclick_add_credentials();
+ };
+}, '0.1', {'requires': ['node', 'DOM']});
diff --git a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
index ccfe716..eee7db7 100644
--- a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
+++ b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
@@ -7,35 +7,142 @@
i18n:domain="launchpad">
<body>
+<metal:block fill-slot="head_epilogue">
+ <style type="text/css">
+ table.push-rules-table {
+ max-width: 60%;
+ margin-bottom: 1em;
+ }
+ table.push-rules-table tr.even {
+ background-color: #eee;
+ }
+ /* These add up to 100%. */
+ tr .push-rule-url {
+ width: 35%;
+ }
+ tr .push-rule-image-name {
+ width: 20%;
+ }
+ tr .push-rule-username {
+ width: 10%;
+ }
+ tr .push-rule-password, tr .push-rule-confirm-password {
+ width: 15%;
+ }
+ tr .push-rule-delete {
+ width: 5%;
+ }
+ </style>
+</metal:block>
+
<div metal:fill-slot="main">
<div metal:use-macro="context/@@launchpad_form/form">
<metal:formbody fill-slot="widgets">
<p condition="view/can_edit_credentials">
<a class="sprite edit" tal:attributes="href context/owner/fmt:url/+edit-oci-registry-credentials">Edit OCI registry credentials</a>
</p>
- <table class="form">
- <tr tal:repeat="rule context/push_rules">
- <tal:rules_widgets
- define="rules_widgets python:view.getRulesWidgets(rule);
- parity python:'even' if repeat['rule'].even() else 'odd'">
- <td tal:define="widget nocall:rules_widgets/image_name">
- <metal:widget use-macro="context/@@launchpad_form/widget_div" />
- </td>
- <td tal:define="widget nocall:rules_widgets/username">
- <metal:widget use-macro="context/@@launchpad_form/widget_div" />
- </td>
- <td tal:define="widget nocall:rules_widgets/url">
- <metal:widget use-macro="context/@@launchpad_form/widget_div" />
- </td>
- <td tal:define="widget nocall:rules_widgets/delete">
- <metal:widget use-macro="context/@@launchpad_form/widget_div" />
- </td>
- </tal:rules_widgets>
- </tr>
+ <table class="listing push-rules-table">
+ <thead>
+ <tr>
+ <th class="push-rule-image-name">Image name</th>
+ <th class="push-rule-url">Registry URL</th>
+ <th class="push-rule-username">Username</th>
+ <th class="push-rule-password">Password</th>
+ <th class="push-rule-confirm-password">Confirm password</th>
+ <th class="push-rule-delete">Delete?</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tal:rule repeat="rule view/push_rules">
+ <tal:rule_widgets
+ define="rule_widgets python:view.getRuleWidgets(rule);
+ parity python:'even' if repeat['rule'].even() else 'odd'">
+ <tr tal:attributes="class string:push-rule ${parity}">
+ <td class="push-rule-image-name"
+ tal:define="widget nocall:rule_widgets/image_name">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td class="push-rule-url"
+ tal:define="widget nocall:rule_widgets/url">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td class="push-rule-username"
+ tal:define="widget nocall:rule_widgets/username">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td colspan="2" />
+ <td class="push-rule-delete"
+ tal:define="widget nocall:rule_widgets/delete">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ </tr>
+ </tal:rule_widgets>
+ </tal:rule>
+ <tal:new-rule
+ define="new_rule_widgets python:view.getNewRuleWidgets();
+ parity python:'odd' if len(view.push_rules) % 2
+ else 'even'">
+ <tr tal:attributes="class parity">
+ <td class="push-rule-image-name"
+ tal:define="widget nocall:new_rule_widgets/image_name">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td colspan="5" />
+ </tr>
+ <tr tal:attributes="class parity">
+ <td>
+ <label>
+ <input type="radio" name="field.add_credentials"
+ value="existing" checked="checked" />
+ Use existing credentials:
+ </label>
+ </td>
+ <td colspan="4"
+ tal:define="widget nocall:new_rule_widgets/existing_credentials">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td />
+ </tr>
+ <tr tal:attributes="class parity">
+ <td>
+ <label>
+ <input type="radio" name="field.add_credentials"
+ value="new" />
+ Add new credentials:
+ </label>
+ </td>
+ <td class="push-rule-url"
+ tal:define="widget nocall:new_rule_widgets/url">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td class="push-rule-username"
+ tal:define="widget nocall:new_rule_widgets/username">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td class="push-rule-password"
+ tal:define="widget nocall:new_rule_widgets/password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td class="push-rule-confirm-password"
+ tal:define="widget nocall:new_rule_widgets/confirm_password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td />
+ </tr>
+ </tal:new-rule>
+ </tbody>
</table>
</metal:formbody>
</div>
+ <script type="text/javascript">
+ LPJS.use('lp.oci.ocirecipe.edit', function(Y) {
+ Y.on('domready', function(e) {
+ Y.lp.oci.ocirecipe.edit.setup();
+ }, window);
+ });
+ </script>
+
</div>
</body>
</html>
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
index aa1fae3..e29fcd9 100644
--- a/lib/lp/oci/vocabularies.py
+++ b/lib/lp/oci/vocabularies.py
@@ -8,10 +8,19 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = []
+from six.moves.urllib.parse import (
+ quote,
+ unquote,
+ )
+from zope.component import getUtility
from zope.interface import implementer
from zope.schema.vocabulary import SimpleTerm
+from lp.oci.interfaces.ociregistrycredentials import (
+ IOCIRegistryCredentialsSet,
+ )
from lp.oci.model.ocirecipe import OCIRecipe
+from lp.oci.model.ociregistrycredentials import OCIRegistryCredentials
from lp.services.webapp.vocabulary import (
IHugeVocabulary,
StormVocabularyBase,
@@ -35,6 +44,49 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
return len(self.context.getAllowedArchitectures())
+class OCIRegistryCredentialsVocabulary(StormVocabularyBase):
+
+ _table = OCIRegistryCredentials
+
+ def toTerm(self, obj):
+ if obj.username:
+ token = "%s %s" % (quote(obj.url), quote(obj.username))
+ title = "%s (%s)" % (obj.url, obj.username)
+ else:
+ token = quote(obj.url)
+ title = obj.url
+
+ return SimpleTerm(obj, token, title)
+
+ @property
+ def _entries(self):
+ return list(getUtility(
+ IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
+
+ def __contains__(self, value):
+ """See `IVocabulary`."""
+ return value in self._entries
+
+ def __len__(self):
+ return len(self._entries)
+
+ def getTermByToken(self, token):
+ """See `IVocabularyTokenized`."""
+ try:
+ if ' ' in token:
+ url, username = token.split(' ', 1)
+ url = unquote(url)
+ username = unquote(username)
+ else:
+ username = None
+ url = unquote(token)
+ for obj in self._entries:
+ if obj.url == url and obj.username == username:
+ return self.toTerm(obj)
+ except ValueError:
+ raise LookupError(token)
+
+
@implementer(IHugeVocabulary)
class OCIRecipeVocabulary(StormVocabularyBase):
"""All OCI Recipes of a given OCI project."""
diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
index 1a6b75c..24a57d8 100644
--- a/lib/lp/oci/vocabularies.zcml
+++ b/lib/lp/oci/vocabularies.zcml
@@ -16,6 +16,16 @@
</class>
<securedutility
+ name="OCIRegistryCredentials"
+ component="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary"
+ provides="zope.schema.interfaces.IVocabularyFactory">
+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+ </securedutility>
+
+ <class class="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary">
+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+ </class>
+ <securedutility
name="OCIRecipe"
component="lp.oci.vocabularies.OCIRecipeVocabulary"
provides="zope.schema.interfaces.IVocabularyFactory">