← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ilasc/launchpad:oci-recipe-push-rules-edit into launchpad:master

 

Ioana Lasc has proposed merging ~ilasc/launchpad:oci-recipe-push-rules-edit into launchpad:master.

Commit message:
Add Edit screen for OCI Push Rules

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/386371

Add Edit screen for OCI Push Rules
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:oci-recipe-push-rules-edit 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..776d09f 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,19 @@ from lazr.restful.interface import (
     use_template,
     )
 from zope.component import getUtility
+from zope.formlib.boolwidgets import CheckBoxWidget
+from zope.formlib.form import FormFields
+from zope.formlib.textwidgets import TextWidget
+from zope.formlib.widget import CustomWidgetFactory
 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,10 +46,17 @@ from lp.app.browser.launchpadform import (
     )
 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
 from lp.app.browser.tales import format_link
-from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
+from lp.app.errors import UnexpectedFormData
+from lp.app.widgets.itemswidgets import (
+    LabeledMultiCheckBoxWidget,
+    LaunchpadRadioWidget,
+    )
 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,
@@ -50,6 +67,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 +159,18 @@ 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 +270,613 @@ 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(LaunchpadFormView):
+    """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 setUpWidgets(self):
+        LaunchpadFormView.setUpWidgets(self)
+
+    def setUpFields(self):
+        """See `LaunchpadFormView`."""
+        LaunchpadFormView.setUpFields(self)
+        image_fields = []
+        checkbox_fields = []
+        username_fields = []
+        password_fields = []
+        url_fields = []
+        delete_fields = []
+        existing_credentials = []
+        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))
+        url_fields.append(
+            TextLine(
+                title=u'Registry URL',
+                __name__=u'add_url',
+                required=False, readonly=False))
+        image_fields.append(
+            TextLine(
+                title=u'Image name',
+                __name__=u'add_image_name',
+                required=False, readonly=False))
+        username_fields.append(
+            TextLine(
+                title=u'Username',
+                __name__=u'add_username',
+                required=False, readonly=False))
+        password_fields.append(
+            Password(
+                title=u'Password',
+                __name__=u'add_password',
+                required=False, readonly=False))
+        password_fields.append(
+            Password(
+                title=u'Confirm password',
+                __name__=u'add_confirm_password',
+                required=False, readonly=False))
+        existing_credentials.append(
+            Choice(
+                vocabulary='OCIRegistryCredentials',
+                title='Choose credentials',
+                required=False,
+                __name__=u'existing_credentials'))
+        checkbox_fields.append(
+            Bool(
+                    title=u'Add new credentials',
+                    __name__=u'add_new_credentials',
+                    default=False,
+                    readonly=False))
+        checkbox_fields.append(
+            Bool(
+                    title=u'Use existing credentials',
+                    __name__=u'use_existing_credentials',
+                    default=False,
+                    readonly=False))
+        self.form_fields += FormFields(*image_fields)
+        self.form_fields += FormFields(*delete_fields)
+        self.form_fields += FormFields(*url_fields)
+        self.form_fields += FormFields(*checkbox_fields)
+        self.form_fields += FormFields(*username_fields)
+        self.form_fields += FormFields(*password_fields)
+        self.form_fields += FormFields(*existing_credentials)
+
+    custom_widget_use_existing_credentials = CustomWidgetFactory(
+        CheckBoxWidget)
+    custom_widget_existing_credentials = CustomWidgetFactory(
+        LaunchpadRadioWidget,
+        widget_class='field subordinate')
+
+    custom_widget_add_new_credentials = CustomWidgetFactory(
+        CheckBoxWidget)
+    custom_widget_add_url = CustomWidgetFactory(
+        TextWidget,
+        widget_class='field subordinate')
+    custom_widget_add_username = CustomWidgetFactory(
+        TextWidget,
+        widget_class='field subordinate')
+    custom_widget_add_password = CustomWidgetFactory(
+        TextWidget,
+        widget_class='field subordinate')
+    custom_widget_add_confirm_password = CustomWidgetFactory(
+        TextWidget,
+        widget_class='field subordinate')
+
+    @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))
+        delete_field_name = (
+                "field." + self._getFieldName("delete", rule.id))
+        return {
+            "image_name": widgets_by_name[image_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_image_name = data["add_image_name"]
+        add_url = data["add_url"]
+        add_username = data["add_username"]
+        add_password = data["add_password"]
+        add_confirm_password = data["add_confirm_password"]
+        add_existing_credentials = data["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_new_credentials": data["add_new_credentials"],
+                "use_existing_credentials": data["use_existing_credentials"],
+                "action": "add",
+            })
+
+        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()
+            elif action == "add":
+                add_data = parsed_data[None]
+                image_name = add_data["image_name"]
+                url = add_data["url"]
+                password = add_data["password"]
+                confirm_password = add_data["confirm_password"]
+                username = add_data["username"]
+                existing_credentials = add_data["existing_credentials"]
+                checked_use_existing_credentials = add_data["use_existing_credentials"]
+                checked_add_new_credentials = add_data["add_new_credentials"]
+
+                if image_name:
+                    if not (checked_add_new_credentials
+                            or checked_use_existing_credentials):
+                        self.setFieldError("existing_credentials",
+                                           "You must check either use existing"
+                                           " or introduce new credentials for "
+                                           "this rule.")
+                        return
+                    if checked_add_new_credentials and checked_use_existing_credentials:
+                        self.setFieldError("existing_credentials",
+                                           "You can either use existing "
+                                           "credentials or introduce new ones "
+                                           "for this rule but not both.")
+                        return
+                    if checked_use_existing_credentials:
+                        if existing_credentials:
+                            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.")
+                                return
+                    if checked_add_new_credentials:
+                        if url:
+                            if password == confirm_password:
+                                credentials = {
+                                    'username': username,
+                                    'password': password}
+                                try:
+                                    creds = getUtility(
+                                        IOCIRegistryCredentialsSet
+                                    ).getOrCreate(
+                                        owner=self.context.owner,
+                                        url=url,
+                                        credentials=credentials)
+                                except OCIRegistryCredentialsAlreadyExist:
+                                    self.setFieldError(
+                                        "add_url",
+                                        "Credentials already exist with"
+                                        " the same URL and username.")
+                                    return
+                                try:
+                                    getUtility(IOCIPushRuleSet).new(
+                                        self.context, creds, image_name)
+                                except OCIPushRuleAlreadyExists:
+                                    self.setFieldError(
+                                        "add_image_name",
+                                        "A push rule already exists with the "
+                                        "same URL, image name, and "
+                                        "credentials.")
+                                    return
+                            else:
+                                self.setFieldError(
+                                    "password",
+                                    "Please make sure the new password matches"
+                                    " the confirm password field.")
+                        else:
+                            self.setFieldError(
+                                "add_url",
+                                "Registry URL must be set.")
+                else:
+                    self.setFieldError(
+                        "add_image_name",
+                        "Image name must be set.")
+            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 +956,7 @@ class IOCIRecipeEditSchema(Interface):
         "build_file",
         "build_daily",
         "require_virtualized",
+        "push_rules",
         ])
 
 
@@ -453,6 +1088,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..bd651d6 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -19,12 +19,15 @@ from fixtures import FakeLogger
 import pytz
 import soupmatchers
 from testtools.matchers import (
+    Equals,
+    MatchesDict,
     MatchesSetwise,
     MatchesStructure,
     )
 from zope.component import getUtility
 from zope.publisher.interfaces import NotFound
 from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
 from zope.testbrowser.browser import LinkNotFoundError
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -35,11 +38,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 +839,288 @@ 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
+        self.assertEqual(
+            removeSecurityProxy(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(removeSecurityProxy(
+                getUtility(IOCIPushRuleSet).getByID(push_rule.id)))
+
+    def test_add_oci_push_rules_validations(self):
+        # Test 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)
+
+        # Only with image name 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_new_credentials").value = True
+        browser.getControl("Save").click()
+        self.assertIn("Registry URL must be set", browser.contents)
+
+        # Either Use existing credentials or Add new
+        # must be checked
+        browser.getControl(
+            name="field.add_new_credentials").value = False
+        browser.getControl(
+            name="field.use_existing_credentials").value = False
+        browser.getControl("Save").click()
+        self.assertIn("You must check either use existing or introduce"
+                      " new credentials for this rule.", browser.contents)
+
+        # Both Use existing credentials And Add new
+        # are checked, we notify the user to check only one
+        browser.getControl(
+            name="field.add_new_credentials").value = True
+        browser.getControl(
+            name="field.use_existing_credentials").value = True
+        browser.getControl("Save").click()
+        self.assertIn("You can either use existing credentials or introduce"
+                      " new ones for this rule but not both.",
+                      browser.contents)
+
+        # No image name entered on the form
+        # we assume user is only editing and we allow
+        # Save on 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(self):
+        # Image name and Registry URL will create an empty Credentials object
+        # and a valid push rule based on the empty creds
+
+        url = unicode(self.factory.getUniqueURL())
+        browser = self.getViewBrowser(self.recipe, user=self.person)
+        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_new_credentials").value = True
+        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.assertIsNotNone(rule.registry_credentials)
+        self.assertEqual(u'imagename1', rule.image_name)
+        self.assertEqual(url, rule.registry_url)
+        self.assertEqual(url,
+                         rule.registry_credentials.url)
+        self.assertEqual(
+            None,
+            rule.registry_credentials.username)
+        with person_logged_in(self.person):
+            self.assertThat(
+                rule.registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"password": Equals(None)}))
+
+        # Previously added registry credentials can now be chosen
+        # from the radio widget when adding a new rule
+        browser.getLink("Edit push rules").click()
+
+        browser.getControl(
+            name="field.use_existing_credentials").value = True
+        browser.getControl(
+            name="field.add_image_name").value = "imagename1"
+        browser.getControl(
+            name="field.existing_credentials").value = (
+            browser.getControl(
+                name="field.existing_credentials").options[1].strip())
+        browser.getControl("Save").click()
+        self.assertIn(
+            "A push rule already exists with the same URL, "
+            "image name and credentials.", browser.contents)
+
+        # We display correctly the radio buttons widget when
+        # username is empty in registry credentials and
+        # allow correctly adding new rule based on it
+        browser.getControl(
+            name="field.use_existing_credentials").value = True
+        browser.getControl(
+            name="field.add_image_name").value = "imagename2"
+        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.assertIsNotNone(rule.registry_credentials)
+        self.assertEqual(u'imagename2', rule.image_name)
+        self.assertEqual(url, rule.registry_url)
+        self.assertEqual(
+            url,
+            rule.registry_credentials.url)
+        self.assertEqual(
+            None,
+            rule.registry_credentials.username)
+        with person_logged_in(self.person):
+            self.assertThat(
+                rule.registry_credentials.getCredentials(),
+                MatchesDict({
+                    "password": Equals(None)}))
+
+        browser.getLink("Edit push rules").click()
+        browser.getControl(
+            name="field.add_new_credentials").value = True
+        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), 3)
+        rule = rules[2]
+        self.assertIsNotNone(rule.registry_credentials)
+        self.assertEqual("imagename3", rule.image_name)
+        self.assertEqual(url, rule.registry_url)
+        self.assertEqual(url,
+                         rule.registry_credentials.url)
+        self.assertEqual(
+            "username",
+            rule.registry_credentials.username)
+        with person_logged_in(self.person):
+            self.assertThat(
+                rule.registry_credentials.getCredentials(),
+                MatchesDict(
+                    {"username": Equals("username"),
+                     "password": Equals("password")}))
+
+    def test_add_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 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(
+                removeSecurityProxy(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..bcccdb9
--- /dev/null
+++ b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
@@ -0,0 +1,73 @@
+<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>
+                <td><label>OCI credentials</label></td>
+                <td><label>Image name</label></td>
+                <td><label>Delete rule</label></td>
+            </tr>
+            <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:editable condition="context/required:launchpad.Edit">
+                          <a tal:attributes="href context/fmt:url/+edit-credentials">Edit credentials</a>
+                        </tal:editable>
+                    </td>
+                    <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/delete">
+                        <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                    </td>
+                </tal:rules_widgets>
+            </tr>
+            <tr>
+                <td tal:define="widget nocall:view/widgets/add_image_name">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+            </tr>
+            <tr>
+                <td tal:define="widget nocall:view/widgets/use_existing_credentials">
+                    <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+                </td>
+                <td>
+                    <tal:widget define="widget nocall:view/widgets/existing_credentials">
+                        <metal:block use-macro="context/@@launchpad_form/widget_row" />
+                    </tal:widget>
+                </td>
+            </tr>
+            <tr>
+                <td tal:define="widget nocall:view/widgets/add_new_credentials">
+                    <metal:row use-macro="context/@@launchpad_form/widget_div" />
+                 </td>
+                <td tal:define="widget nocall:view/widgets/add_url">
+                    <metal:row use-macro="context/@@launchpad_form/widget_row" />
+                </td>
+                <td tal:define="widget nocall:view/widgets/add_username">
+                    <metal:row use-macro="context/@@launchpad_form/widget_row" />
+                </td>
+                <td tal:define="widget nocall:view/widgets/add_password">
+                    <metal:row use-macro="context/@@launchpad_form/widget_row" />
+                </td>
+                <td tal:define="widget nocall:view/widgets/add_confirm_password">
+                    <metal:row use-macro="context/@@launchpad_form/widget_row" />
+                </td>
+            </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>
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
index aa1fae3..65209e1 100644
--- a/lib/lp/oci/vocabularies.py
+++ b/lib/lp/oci/vocabularies.py
@@ -8,10 +8,13 @@ from __future__ import absolute_import, print_function, unicode_literals
 __metaclass__ = type
 __all__ = []
 
+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 +38,51 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
         return len(self.context.getAllowedArchitectures())
 
 
+class OCIRegistryCredentialsVocabulary(StormVocabularyBase):
+
+    _table = OCIRegistryCredentials
+
+    def toTerm(self, obj):
+        if obj.username:
+            token = "%s %s" % (
+                obj.url,
+                obj.username)
+        else:
+            token = obj.url
+
+        return SimpleTerm(obj, token)
+
+    @property
+    def _entries(self):
+        return list(getUtility(
+            IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
+
+    def __contains__(self, value):
+        """See `IVocabulary`."""
+        return value in self._entries
+
+    def __iter__(self):
+        for obj in self._entries:
+            yield self.toTerm(obj)
+
+    def __len__(self):
+        return len(self._entries)
+
+    def getTermByToken(self, token):
+        """See `IVocabularyTokenized`."""
+        try:
+            if ' ' in token:
+                url, username = token.split(' ')
+            else:
+                username = None
+                url = 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">

Follow ups