launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25012
[Merge] ~ilasc/launchpad:oci-recipe-edit-existing-rules into launchpad:master
Ioana Lasc has proposed merging ~ilasc/launchpad:oci-recipe-edit-existing-rules into launchpad:master.
Commit message:
Edit push rules and credentials on Recipe
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/387293
This branch adds the capability to edit and delete existing push rules on the Recipe page and from there also navigate to a screen where the user can edit & delete existing OCI registry credentials and add new ones.
The capability to add new push rules will follow in a separate MP.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:oci-recipe-edit-existing-rules into launchpad:master.
diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
index b66e509..c531f28 100644
--- a/lib/lp/oci/browser/configure.zcml
+++ b/lib/lp/oci/browser/configure.zcml
@@ -61,6 +61,18 @@
template="../../app/templates/generic-edit.pt" />
<browser:page
for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+ class="lp.oci.browser.ocirecipe.OCIRecipeEditPushRulesView"
+ permission="launchpad.Edit"
+ name="+edit-push-rules"
+ template="../templates/ocirecipe-edit-push-rules.pt" />
+ <browser:page
+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
+ class="lp.oci.browser.ocirecipe.OCIRecipeEditCredentialsView"
+ permission="launchpad.Edit"
+ name="+edit-credentials"
+ template="../templates/ocirecipe-edit-credentials.pt" />
+ <browser:page
+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
class="lp.oci.browser.ocirecipe.OCIRecipeDeleteView"
permission="launchpad.Edit"
name="+delete"
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 356ac1c..36a39dc 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -11,6 +11,8 @@ __all__ = [
'OCIRecipeAdminView',
'OCIRecipeContextMenu',
'OCIRecipeDeleteView',
+ 'OCIRecipeEditCredentialsView',
+ 'OCIRecipeEditPushRulesView',
'OCIRecipeEditView',
'OCIRecipeNavigation',
'OCIRecipeNavigationMenu',
@@ -23,11 +25,16 @@ from lazr.restful.interface import (
use_template,
)
from zope.component import getUtility
+from zope.formlib.form import FormFields
from zope.interface import Interface
from zope.schema import (
+ Bool,
Choice,
List,
+ Password,
+ TextLine,
)
+from zope.security.proxy import removeSecurityProxy
from lp.app.browser.launchpadform import (
action,
@@ -36,6 +43,7 @@ from lp.app.browser.launchpadform import (
)
from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
from lp.app.browser.tales import format_link
+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
@@ -50,6 +58,10 @@ from lp.oci.interfaces.ocirecipe import (
OCIRecipeFeatureDisabled,
)
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.oci.interfaces.ociregistrycredentials import (
+ IOCIRegistryCredentialsSet,
+ OCIRegistryCredentialsAlreadyExist,
+ )
from lp.services.features import getFeatureFlag
from lp.services.helpers import english_list
from lp.services.propertycache import cachedproperty
@@ -138,12 +150,17 @@ class OCIRecipeContextMenu(ContextMenu):
facet = 'overview'
- links = ('request_builds',)
+ links = ('request_builds', 'edit_push_rules')
@enabled_with_permission('launchpad.Edit')
def request_builds(self):
return Link('+request-builds', 'Request builds', icon='add')
+ @enabled_with_permission('launchpad.Edit')
+ def edit_push_rules(self):
+ return Link(
+ '+edit-push-rules', 'Edit push rules', icon='edit')
+
class OCIProjectRecipesView(LaunchpadView):
"""Default view for the list of OCI recipes of an OCI project."""
@@ -243,6 +260,457 @@ def new_builds_notification_text(builds, already_pending=None):
return builds_text
+class OCIRecipeEditCredentialsView(LaunchpadFormView):
+ """View for +ocirecipe-edit-credentials.pt."""
+
+ @cachedproperty
+ def oci_registry_credentials(self):
+ return list(getUtility(
+ IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
+
+ schema = Interface
+
+ def _getFieldName(self, name, credentials_id):
+ """Get the combined field name for an `OCIRegistryCredentials` ID.
+
+ In order to be able to render a table, we encode the credentials ID
+ in the form field name.
+ """
+ return "%s.%d" % (name, credentials_id)
+
+ def getEditFieldsRow(self, credentials=None):
+ id = getattr(credentials, 'id', None)
+ owner = Choice(
+ title=u'Owner',
+ vocabulary=(
+ 'AllUserTeamsParticipationPlusSelfSimpleDisplay'),
+ default=credentials.owner.name,
+ __name__=self._getFieldName('owner', id))
+
+ username = TextLine(
+ title=u'Username',
+ __name__=self._getFieldName('username', id),
+ default=credentials.username,
+ required=False, readonly=False)
+
+ password = Password(
+ title=u'Password',
+ __name__=self._getFieldName('password', id),
+ default=None,
+ required=False, readonly=False)
+
+ confirm_password = Password(
+ title=u'Confirm password',
+ __name__=self._getFieldName('confirm_password', id),
+ default=None,
+ required=False, readonly=False)
+
+ url = TextLine(
+ title=u'Registry URL',
+ __name__=self._getFieldName('url', id),
+ default=credentials.url,
+ required=True, readonly=False)
+
+ delete = Bool(
+ title=u'Delete',
+ __name__=self._getFieldName('delete', id),
+ default=False,
+ required=True, readonly=False)
+
+ return owner, username, password, confirm_password, url, delete
+
+ def getAddFieldsRow(self):
+ add_url = TextLine(
+ title=u'Registry URL',
+ __name__=u'add_url',
+ required=False, readonly=False)
+ add_username = TextLine(
+ title=u'Username',
+ __name__=u'add_username',
+ required=False, readonly=False)
+ add_password = Password(
+ title=u'Password',
+ __name__=u'add_password',
+ required=False, readonly=False)
+ add_confirm_password = Password(
+ title=u'Confirm password',
+ __name__=u'add_confirm_password',
+ required=False, readonly=False)
+
+ return add_url, add_username, add_password, add_confirm_password
+
+ def _parseFieldName(self, field_name):
+ """Parse a combined field name as described in `_getFieldName`.
+
+ :raises UnexpectedFormData: if the field name cannot be parsed or
+ the `OCIRegistryCredentials` cannot be found.
+ """
+ field_bits = field_name.split(".")
+ if len(field_bits) != 2:
+ raise UnexpectedFormData(
+ "Cannot parse field name: %s" % field_name)
+ field_type = field_bits[0]
+ try:
+ credentials_id = int(field_bits[1])
+ except ValueError:
+ raise UnexpectedFormData(
+ "Cannot parse field name: %s" % field_name)
+ return field_type, credentials_id
+
+ def setUpFields(self):
+ """See `LaunchpadFormView`."""
+ LaunchpadFormView.setUpFields(self)
+
+ for elem in self.oci_registry_credentials:
+ fields = self.getEditFieldsRow(elem)
+ self.form_fields += FormFields(*fields)
+
+ add_fields = self.getAddFieldsRow()
+ self.form_fields += FormFields(*add_fields)
+
+ @property
+ def label(self):
+ return 'Edit OCI registry credentials'
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+
+ def getCredentialsWidgets(self, credentials):
+ widgets_by_name = {widget.name: widget for widget in self.widgets}
+ owner_field_name = (
+ "field." + self._getFieldName("owner", credentials.id))
+ username_field_name = (
+ "field." + self._getFieldName("username", credentials.id))
+ password_field_name = (
+ "field." + self._getFieldName("password", credentials.id))
+ confirm_password_field_name = (
+ "field." + self._getFieldName("confirm_password",
+ credentials.id))
+ url_field_name = "field." + self._getFieldName("url", credentials.id)
+ delete_field_name = (
+ "field." + self._getFieldName("delete", credentials.id))
+ return {
+ "owner": widgets_by_name[owner_field_name],
+ "username": widgets_by_name[username_field_name],
+ "password": widgets_by_name[password_field_name],
+ "confirm_password": widgets_by_name[confirm_password_field_name],
+ "url": widgets_by_name[url_field_name],
+ "delete": widgets_by_name[delete_field_name]
+ }
+
+ def parseData(self, data):
+ """Rearrange form data to make it easier to process."""
+ parsed_data = {}
+ add_url = data["add_url"]
+ add_username = data["add_username"]
+ add_password = data["add_password"]
+ add_confirm_password = data["add_confirm_password"]
+ if add_url or add_username or add_password or add_confirm_password:
+ parsed_data.setdefault(None, {
+ "username": add_username,
+ "password": add_password,
+ "confirm_password": add_confirm_password,
+ "url": add_url,
+ "action": "add",
+ })
+ for field_name in (
+ name for name in data if name.split(".")[0] == "owner"):
+ _, credentials_id = self._parseFieldName(field_name)
+ owner_field_name = self._getFieldName(
+ "owner", credentials_id)
+ username_field_name = self._getFieldName(
+ "username", credentials_id)
+ password_field_name = self._getFieldName(
+ "password", credentials_id)
+ confirm_password_field_name = self._getFieldName(
+ "confirm_password", credentials_id)
+ url_field_name = self._getFieldName("url", credentials_id)
+ delete_field_name = self._getFieldName("delete", credentials_id)
+ if data.get(delete_field_name):
+ action = "delete"
+ else:
+ action = "change"
+ parsed_data.setdefault(credentials_id, {
+ "username": data.get(username_field_name),
+ "password": data.get(password_field_name),
+ "confirm_password": data.get(confirm_password_field_name),
+ "url": data.get(url_field_name),
+ "owner": data.get(owner_field_name),
+ "action": action,
+ })
+
+ return parsed_data
+
+ def changeCredentials(self, parsed_credentials, credentials):
+ username = parsed_credentials["username"]
+ password = parsed_credentials["password"]
+ confirm_password = parsed_credentials["confirm_password"]
+ owner = parsed_credentials["owner"]
+ if password or confirm_password:
+ if password != confirm_password:
+ self.setFieldError(
+ self._getFieldName(
+ "confirm_password", credentials.id),
+ "Passwords do not match.")
+ else:
+ credentials.setCredentials(
+ {"username": username,
+ "password": password})
+ credentials.url = parsed_credentials["url"]
+ elif username != credentials.username:
+ removeSecurityProxy(credentials).username = username
+ credentials.url = parsed_credentials["url"]
+ elif parsed_credentials["url"] != credentials.url:
+ credentials.url = parsed_credentials["url"]
+ if owner != credentials.owner:
+ credentials.owner = owner
+
+ def deleteCredentials(self, credentials):
+ push_rule_set = getUtility(IOCIPushRuleSet)
+ if not push_rule_set.findByRegistryCredentials(
+ credentials).is_empty():
+ self.setFieldError(
+ self._getFieldName(
+ "delete", credentials.id),
+ "These credentials cannot be deleted as there are "
+ "push rules defined that still use them.")
+ else:
+ credentials.destroySelf()
+
+ def addCredentials(self, parsed_add_credentials):
+ url = parsed_add_credentials["url"]
+ password = parsed_add_credentials["password"]
+ confirm_password = parsed_add_credentials["confirm_password"]
+ username = parsed_add_credentials["username"]
+ if url:
+ if password or confirm_password:
+ if not password == confirm_password:
+ self.setFieldError(
+ "add_password",
+ "Please make sure the new "
+ "password matches the "
+ "confirm password field.")
+ return
+
+ credentials = {
+ 'username': username,
+ 'password': password}
+ try:
+ getUtility(IOCIRegistryCredentialsSet).new(
+ owner=self.context.owner,
+ url=url,
+ credentials=credentials)
+ except OCIRegistryCredentialsAlreadyExist:
+ self.setFieldError(
+ "add_url",
+ "Credentials already exist "
+ "with the same URL and "
+ "username.")
+ else:
+ credentials = {'username': username}
+ try:
+ getUtility(IOCIRegistryCredentialsSet).new(
+ owner=self.context.owner,
+ url=url,
+ credentials=credentials)
+ except OCIRegistryCredentialsAlreadyExist:
+ self.setFieldError(
+ "add_url",
+ "Credentials already exist "
+ "with the same URL and username.")
+ else:
+ self.setFieldError(
+ "add_url",
+ "Registry URL cannot be empty.")
+
+ def updateCredentialsFromData(self, parsed_data):
+ credentials_map = {
+ credentials.id: credentials
+ for credentials in self.oci_registry_credentials}
+
+ for credentials_id, parsed_credentials in parsed_data.items():
+ credentials = credentials_map.get(credentials_id)
+ action = parsed_credentials["action"]
+
+ if action == "change":
+ self.changeCredentials(parsed_credentials, credentials)
+ elif action == "delete":
+ self.deleteCredentials(credentials)
+ elif action == "add":
+ parsed_add_credentials = parsed_data[credentials]
+ self.addCredentials(parsed_add_credentials)
+ else:
+ raise AssertionError("unknown action: %s" % action)
+
+ @action("Save")
+ def save(self, action, data):
+ parsed_data = self.parseData(data)
+ self.updateCredentialsFromData(parsed_data)
+
+ if not self.errors:
+ self.request.response.addNotification("Saved credentials")
+ self.next_url = canonical_url(self.context)
+
+
+class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
+ """View for +ocirecipe-edit-push-rules.pt."""
+
+ class schema(Interface):
+ """Schema for editing push rules."""
+
+ @cachedproperty
+ def push_rules(self):
+ return list(
+ getUtility(IOCIPushRuleSet).findByRecipe(self.context))
+
+ @property
+ def has_push_rules(self):
+ return len(self.push_rules) > 0
+
+ def _getFieldName(self, name, rule_id):
+ """Get the combined field name for an `OCIPushRule` ID.
+
+ In order to be able to render a table, we encode the rule ID
+ in the form field name.
+ """
+ return "%s.%d" % (name, rule_id)
+
+ def _parseFieldName(self, field_name):
+ """Parse a combined field name as described in `_getFieldName`.
+
+ :raises UnexpectedFormData: if the field name cannot be parsed or
+ the `OCIPushRule` cannot be found.
+ """
+ field_bits = field_name.split(".")
+ if len(field_bits) != 2:
+ raise UnexpectedFormData(
+ "Cannot parse field name: %s" % field_name)
+ field_type = field_bits[0]
+ try:
+ rule_id = int(field_bits[1])
+ except ValueError:
+ raise UnexpectedFormData(
+ "Cannot parse field name: %s" % field_name)
+ return field_type, rule_id
+
+ def setUpFields(self):
+ """See `LaunchpadEditFormView`."""
+ LaunchpadEditFormView.setUpFields(self)
+
+ image_fields = []
+ delete_fields = []
+ creds = []
+ for elem in list(self.context.push_rules):
+ image_fields.append(
+ TextLine(
+ title=u'Image name',
+ __name__=self._getFieldName('image_name', elem.id),
+ default=elem.image_name,
+ required=True, readonly=False))
+ 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)
+
+ @property
+ def label(self):
+ return 'Edit OCI push rules for %s' % self.context.name
+
+ page_title = 'Edit OCI push rules'
+
+ @property
+ def cancel_url(self):
+ return canonical_url(self.context)
+
+ def getRulesWidgets(self, rule):
+
+ widgets_by_name = {widget.name: widget for widget in self.widgets}
+ 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 {
+ "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 parseData(self, data):
+ """Rearrange form data to make it easier to process."""
+ parsed_data = {}
+
+ for field_name in sorted(
+ name for name in data if name.split(".")[0] == "image_name"):
+ _, rule_id = self._parseFieldName(field_name)
+ image_field_name = self._getFieldName("image_name", rule_id)
+ delete_field_name = self._getFieldName("delete", rule_id)
+ if data.get(delete_field_name):
+ action = "delete"
+ else:
+ action = "change"
+ parsed_data.setdefault(rule_id, {
+ "image_name": data.get(image_field_name),
+ "action": action,
+ })
+
+ return parsed_data
+
+ def updatePushRulesFromData(self, parsed_data):
+ rules_map = {
+ rule.id: rule
+ for rule in self.context.push_rules}
+ for rule_id, parsed_rules in parsed_data.items():
+ rule = rules_map.get(rule_id)
+ action = parsed_rules["action"]
+
+ if action == "change":
+ image_name = parsed_rules["image_name"]
+ if not image_name:
+ self.setFieldError(
+ self._getFieldName(
+ "image_name", rule_id),
+ "Image name must be set.")
+ else:
+ removeSecurityProxy(rule).image_name = image_name
+ elif action == "delete":
+ removeSecurityProxy(rule).destroySelf()
+ else:
+ raise AssertionError("unknown action: %s" % action)
+
+ @action("Save")
+ def save(self, action, data):
+ parsed_data = self.parseData(data)
+ self.updatePushRulesFromData(parsed_data)
+
+ if not self.errors:
+ self.request.response.addNotification("Saved push rules")
+ self.next_url = canonical_url(self.context)
+
+
class OCIRecipeRequestBuildsView(LaunchpadFormView):
"""A view for requesting builds of an OCI recipe."""
@@ -322,6 +790,7 @@ class IOCIRecipeEditSchema(Interface):
"build_file",
"build_daily",
"require_virtualized",
+ "push_rules",
])
@@ -453,6 +922,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
"git_ref",
"build_file",
"build_daily",
+ "push_rules",
)
custom_widget_git_ref = GitRefWidget
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index e25d463..40278d4 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -19,6 +19,8 @@ from fixtures import FakeLogger
import pytz
import soupmatchers
from testtools.matchers import (
+ Equals,
+ MatchesDict,
MatchesSetwise,
MatchesStructure,
)
@@ -35,11 +37,16 @@ from lp.oci.browser.ocirecipe import (
OCIRecipeEditView,
OCIRecipeView,
)
+from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
from lp.oci.interfaces.ocirecipe import (
CannotModifyOCIRecipeProcessor,
IOCIRecipeSet,
OCI_RECIPE_ALLOW_CREATE,
)
+from lp.oci.interfaces.ociregistrycredentials import (
+ IOCIRegistryCredentialsSet,
+ )
+from lp.oci.tests.helpers import OCIConfigHelperMixin
from lp.services.database.constants import UTC_NOW
from lp.services.features.testing import FeatureFixture
from lp.services.propertycache import get_property_cache
@@ -831,6 +838,128 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
extract_text(find_main_content(browser.contents)))
+class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
+ BaseTestOCIRecipeView):
+ def setUp(self):
+ super(TestOCIRecipeEditPushRulesView, self).setUp()
+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
+ self.distroseries = self.factory.makeDistroSeries(
+ distribution=self.ubuntu, name="shiny", displayname="Shiny")
+ self.architectures = []
+ for processor, architecture in ("386", "i386"), ("amd64", "amd64"):
+ das = self.factory.makeDistroArchSeries(
+ distroseries=self.distroseries, architecturetag=architecture,
+ processor=getUtility(IProcessorSet).getByName(processor))
+ das.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
+ self.architectures.append(das)
+ self.useFixture(FeatureFixture({
+ OCI_RECIPE_ALLOW_CREATE: "on",
+ "oci.build_series.%s" % self.distroseries.distribution.name:
+ self.distroseries.name,
+ }))
+ oci_project = self.factory.makeOCIProject(
+ pillar=self.distroseries.distribution,
+ ociprojectname="oci-project-name")
+ self.recipe = self.factory.makeOCIRecipe(
+ name="recipe-name", registrant=self.person, owner=self.person,
+ oci_project=oci_project)
+
+ self.setConfig()
+
+ def test_edit_oci_push_rules(self):
+ url = unicode(self.factory.getUniqueURL())
+ credentials = {'username': 'foo', 'password': 'bar'}
+ registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+ owner=self.person,
+ url=url,
+ credentials=credentials)
+ image_name = self.factory.getUniqueUnicode()
+ push_rule = getUtility(IOCIPushRuleSet).new(
+ recipe=self.recipe,
+ registry_credentials=registry_credentials,
+ image_name=image_name)
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ # assert image name is displayed correctly
+ with person_logged_in(self.person):
+ self.assertEqual(image_name, browser.getControl(
+ name="field.image_name.%d" % push_rule.id).value)
+
+ # assert image name is required
+ with person_logged_in(self.person):
+ browser.getControl(
+ name="field.image_name.%d" % push_rule.id).value = ""
+ browser.getControl("Save").click()
+ self.assertIn("Required input is missing", browser.contents)
+
+ # set image name to valid string
+ with person_logged_in(self.person):
+ browser.getControl(
+ name="field.image_name.%d" % push_rule.id).value = "testimage1"
+ browser.getControl("Save").click()
+ # and assert model changed
+ with person_logged_in(self.person):
+ self.assertEqual(
+ push_rule.image_name, "testimage1")
+
+ def test_delete_oci_push_rules(self):
+ url = unicode(self.factory.getUniqueURL())
+ credentials = {'username': 'foo', 'password': 'bar'}
+ registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+ owner=self.person,
+ url=url,
+ credentials=credentials)
+ image_name = self.factory.getUniqueUnicode()
+ push_rule = getUtility(IOCIPushRuleSet).new(
+ recipe=self.recipe,
+ registry_credentials=registry_credentials,
+ image_name=image_name)
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ with person_logged_in(self.person):
+ browser.getControl(
+ name="field.delete.%d" % push_rule.id).value = True
+ browser.getControl("Save").click()
+
+ with person_logged_in(self.person):
+ self.assertIsNone(
+ getUtility(IOCIPushRuleSet).getByID(push_rule.id))
+
+ def test_edit_oci_registry_creds(self):
+ url = unicode(self.factory.getUniqueURL())
+ credentials = {'username': 'foo', 'password': 'bar'}
+ image_name = self.factory.getUniqueUnicode()
+ registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
+ owner=self.person,
+ url=url,
+ credentials=credentials)
+ getUtility(IOCIPushRuleSet).new(
+ recipe=self.recipe,
+ registry_credentials=registry_credentials,
+ image_name=image_name)
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ browser.getLink("Edit OCI registry credentials").click()
+
+ browser.getControl(name="field.add_url").value = url
+ browser.getControl(name="field.add_username").value = "new_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):
+ creds = list(getUtility(
+ IOCIRegistryCredentialsSet).findByOwner(
+ self.person))
+
+ self.assertEqual(url, creds[1].url)
+ self.assertThat(
+ (creds[1]).getCredentials(),
+ MatchesDict({"username": Equals("new_username"),
+ "password": Equals("password")}))
+
+
class TestOCIProjectRecipesView(BaseTestOCIRecipeView):
def setUp(self):
super(TestOCIProjectRecipesView, self).setUp()
diff --git a/lib/lp/oci/templates/ocirecipe-edit-credentials.pt b/lib/lp/oci/templates/ocirecipe-edit-credentials.pt
new file mode 100644
index 0000000..89483da
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-edit-credentials.pt
@@ -0,0 +1,59 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <metal:formbody fill-slot="widgets">
+
+ <table class="form">
+ <tr tal:repeat="credentials view/oci_registry_credentials">
+ <tal:credentials_widgets
+ define="credentials_widgets python:view.getCredentialsWidgets(credentials);
+ parity python:'even' if repeat['credentials'].even() else 'odd'">
+ <td tal:define="widget nocall:credentials_widgets/url">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:credentials_widgets/owner">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:credentials_widgets/username">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:credentials_widgets/password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:credentials_widgets/confirm_password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:credentials_widgets/delete">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ </tal:credentials_widgets>
+ </tr>
+ <tr>
+ <td tal:define="widget nocall:view/widgets/add_url">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/add_username">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/add_password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ <td tal:define="widget nocall:view/widgets/add_confirm_password">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ </tr>
+ </table>
+
+ </metal:formbody>
+ </div>
+</div>
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
new file mode 100644
index 0000000..c885558
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
@@ -0,0 +1,41 @@
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:tal="http://xml.zope.org/namespaces/tal"
+ xmlns:metal="http://xml.zope.org/namespaces/metal"
+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+ metal:use-macro="view/macro:page/main_only"
+ i18n:domain="launchpad">
+<body>
+
+<div metal:fill-slot="main">
+ <div metal:use-macro="context/@@launchpad_form/form">
+ <metal:formbody fill-slot="widgets">
+ <p condition="context/required:launchpad.Edit">
+ <a class="sprite edit" tal:attributes="href context/fmt:url/+edit-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>
+ </metal:formbody>
+</div>
+
+</div>
+</body>
+</html>
diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
index cf7c549..88a95b3 100644
--- a/lib/lp/oci/templates/ocirecipe-index.pt
+++ b/lib/lp/oci/templates/ocirecipe-index.pt
@@ -143,6 +143,11 @@
This OCI recipe has no push rules defined yet.
</p>
+ <div tal:define="link context/menu:context/edit_push_rules"
+ tal:condition="link/enabled">
+ <tal:edit-push-rules replace="structure link/fmt:link"/>
+ </div>
+
</div>
</body>