launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #24905
[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