Merge ~cjwatson/launchpad:oci-recipe-push-rules-add into launchpad:master

Proposed by Colin Watson
Status: Merged
Approved by: Colin Watson
Approved revision: f2ce0ac900cac32a41f01a37ec77242c3d2bb45e
Merge reported by: Otto Co-Pilot
Merged at revision: not available
Proposed branch: ~cjwatson/launchpad:oci-recipe-push-rules-add
Merge into: launchpad:master
Diff against target: 960 lines (+696/-56)
6 files modified
lib/lp/oci/browser/ocirecipe.py (+203/-32)
lib/lp/oci/browser/tests/test_ocirecipe.py (+267/-5)
lib/lp/oci/javascript/ocirecipe.edit.js (+38/-0)
lib/lp/oci/templates/ocirecipe-edit-push-rules.pt (+126/-19)
lib/lp/oci/vocabularies.py (+52/-0)
lib/lp/oci/vocabularies.zcml (+10/-0)
Reviewer Review Type Date Requested Status
Thiago F. Pappacena (community) Approve
Review via email: mp+389875@code.launchpad.net

Commit message

Allow adding push rules on OCIRecipe:+edit-push-rules

Description of the change

To post a comment you must log in.
Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

LGTM (and nice visual!). I've added just a couple of comments.

review: Approve
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index 3dd764c..c2dceb2 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -25,12 +25,18 @@ from lazr.restful.interface import (
25 )25 )
26from zope.component import getUtility26from zope.component import getUtility
27from zope.formlib.form import FormFields27from zope.formlib.form import FormFields
28from zope.formlib.widget import (
29 DisplayWidget,
30 renderElement,
31 )
28from zope.interface import Interface32from zope.interface import Interface
29from zope.schema import (33from zope.schema import (
30 Bool,34 Bool,
31 Choice,35 Choice,
32 List,36 List,
37 Password,
33 TextLine,38 TextLine,
39 ValidationError,
34 )40 )
3541
36from lp.app.browser.launchpadform import (42from lp.app.browser.launchpadform import (
@@ -44,7 +50,10 @@ from lp.app.errors import UnexpectedFormData
44from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget50from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
45from lp.buildmaster.interfaces.processor import IProcessorSet51from lp.buildmaster.interfaces.processor import IProcessorSet
46from lp.code.browser.widgets.gitref import GitRefWidget52from lp.code.browser.widgets.gitref import GitRefWidget
47from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet53from lp.oci.interfaces.ocipushrule import (
54 IOCIPushRuleSet,
55 OCIPushRuleAlreadyExists,
56 )
48from lp.oci.interfaces.ocirecipe import (57from lp.oci.interfaces.ocirecipe import (
49 IOCIRecipe,58 IOCIRecipe,
50 IOCIRecipeSet,59 IOCIRecipeSet,
@@ -56,6 +65,8 @@ from lp.oci.interfaces.ocirecipe import (
56 )65 )
57from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet66from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
58from lp.oci.interfaces.ociregistrycredentials import (67from lp.oci.interfaces.ociregistrycredentials import (
68 IOCIRegistryCredentialsSet,
69 OCIRegistryCredentialsAlreadyExist,
59 user_can_edit_credentials_for_owner,70 user_can_edit_credentials_for_owner,
60 )71 )
61from lp.services.features import getFeatureFlag72from lp.services.features import getFeatureFlag
@@ -72,6 +83,7 @@ from lp.services.webapp import (
72 stepthrough,83 stepthrough,
73 structured,84 structured,
74 )85 )
86from lp.services.webapp.authorization import check_permission
75from lp.services.webapp.batching import BatchNavigator87from lp.services.webapp.batching import BatchNavigator
76from lp.services.webapp.breadcrumb import NameBreadcrumb88from lp.services.webapp.breadcrumb import NameBreadcrumb
77from lp.services.webhooks.browser import WebhookTargetNavigationMixin89from lp.services.webhooks.browser import WebhookTargetNavigationMixin
@@ -264,7 +276,17 @@ def new_builds_notification_text(builds, already_pending=None):
264 return builds_text276 return builds_text
265277
266278
267class OCIRecipeEditPushRulesView(LaunchpadEditFormView):279class InvisibleCredentialsWidget(DisplayWidget):
280 """A widget that just displays a private icon.
281
282 This indicates invisible credentials.
283 """
284
285 def __call__(self):
286 return renderElement("span", id=self.name, cssClass="sprite private")
287
288
289class OCIRecipeEditPushRulesView(LaunchpadFormView):
268 """View for +ocirecipe-edit-push-rules.pt."""290 """View for +ocirecipe-edit-push-rules.pt."""
269291
270 class schema(Interface):292 class schema(Interface):
@@ -311,41 +333,103 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
311 return field_type, rule_id333 return field_type, rule_id
312334
313 def setUpFields(self):335 def setUpFields(self):
314 """See `LaunchpadEditFormView`."""336 super(OCIRecipeEditPushRulesView, self).setUpFields()
315 LaunchpadEditFormView.setUpFields(self)337 image_name_fields = []
316338 url_fields = []
317 image_fields = []339 private_url_fields = []
340 username_fields = []
341 private_username_fields = []
342 password_fields = []
318 delete_fields = []343 delete_fields = []
319 creds = []
320 for elem in list(self.context.push_rules):344 for elem in list(self.context.push_rules):
321 image_fields.append(345 image_name_fields.append(
322 TextLine(346 TextLine(
323 title=u'Image name',
324 __name__=self._getFieldName('image_name', elem.id),347 __name__=self._getFieldName('image_name', elem.id),
325 default=elem.image_name,348 default=elem.image_name,
326 required=True, readonly=False))349 required=True, readonly=False))
350 if check_permission('launchpad.View', elem.registry_credentials):
351 url_fields.append(
352 TextLine(
353 __name__=self._getFieldName('url', elem.id),
354 default=elem.registry_credentials.url,
355 required=True, readonly=True))
356 username_fields.append(
357 TextLine(
358 __name__=self._getFieldName('username', elem.id),
359 default=elem.registry_credentials.username,
360 required=True, readonly=True))
361 else:
362 # XXX cjwatson 2020-08-27: Ideally we'd be able to just show
363 # the URL, and maybe the username too, but the
364 # launchpad.View security adapter for OCIRegistryCredentials
365 # doesn't currently allow that in some cases (e.g. a
366 # team-owned recipe with a push rule using credentials owned
367 # by another team member). In future it might make sense to
368 # add a launchpad.LimitedView adapter that grants access if
369 # the credentials are used by a push rule on one of the
370 # viewer's recipes.
371 private_url_fields.append(
372 TextLine(
373 __name__=self._getFieldName('url', elem.id),
374 default='', required=True, readonly=True))
375 private_username_fields.append(
376 TextLine(
377 __name__=self._getFieldName('username', elem.id),
378 default='', required=True, readonly=True))
327 delete_fields.append(379 delete_fields.append(
328 Bool(380 Bool(
329 title=u'Delete',
330 __name__=self._getFieldName('delete', elem.id),381 __name__=self._getFieldName('delete', elem.id),
331 default=False,382 default=False,
332 required=True, readonly=False))383 required=True, readonly=False))
333 creds.append(384 image_name_fields.append(
334 TextLine(385 TextLine(
335 title=u'Username',386 __name__=u'add_image_name',
336 __name__=self._getFieldName('username', elem.id),387 required=False, readonly=False))
337 default=elem.registry_credentials.username,388 add_credentials = Choice(
338 required=True, readonly=True))389 __name__='add_credentials',
339 creds.append(390 default='existing', values=('existing', 'new'),
340 TextLine(391 required=False, readonly=False)
341 title=u'Registry URL',392 existing_credentials = Choice(
342 __name__=self._getFieldName('url', elem.id),393 vocabulary='OCIRegistryCredentials',
343 default=elem.registry_credentials.url,394 required=False,
344 required=True, readonly=True))395 __name__=u'existing_credentials')
345396 url_fields.append(
346 self.form_fields += FormFields(*image_fields)397 TextLine(
347 self.form_fields += FormFields(*creds)398 __name__=u'add_url',
348 self.form_fields += FormFields(*delete_fields)399 required=False, readonly=False))
400 username_fields.append(
401 TextLine(
402 __name__=u'add_username',
403 required=False, readonly=False))
404 password_fields.append(
405 Password(
406 __name__=u'add_password',
407 required=False, readonly=False))
408 password_fields.append(
409 Password(
410 __name__=u'add_confirm_password',
411 required=False, readonly=False))
412
413 self.form_fields = (
414 FormFields(*image_name_fields) +
415 FormFields(*url_fields) +
416 FormFields(
417 *private_url_fields,
418 custom_widget=InvisibleCredentialsWidget) +
419 FormFields(*username_fields) +
420 FormFields(
421 *private_username_fields,
422 custom_widget=InvisibleCredentialsWidget) +
423 FormFields(*password_fields) +
424 FormFields(*delete_fields) +
425 FormFields(add_credentials, existing_credentials))
426
427 def setUpWidgets(self, context=None):
428 """See `LaunchpadFormView`."""
429 super(OCIRecipeEditPushRulesView, self).setUpWidgets(context=context)
430 for widget in self.widgets:
431 widget.display_label = False
432 widget.hint = None
349433
350 @property434 @property
351 def label(self):435 def label(self):
@@ -357,28 +441,60 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
357 def cancel_url(self):441 def cancel_url(self):
358 return canonical_url(self.context)442 return canonical_url(self.context)
359443
360 def getRulesWidgets(self, rule):444 def getRuleWidgets(self, rule):
361
362 widgets_by_name = {widget.name: widget for widget in self.widgets}445 widgets_by_name = {widget.name: widget for widget in self.widgets}
446 url_field_name = (
447 "field." + self._getFieldName("url", rule.id))
363 image_field_name = (448 image_field_name = (
364 "field." + self._getFieldName("image_name", rule.id))449 "field." + self._getFieldName("image_name", rule.id))
365 username_field_name = (450 username_field_name = (
366 "field." + self._getFieldName("username", rule.id))451 "field." + self._getFieldName("username", rule.id))
367 url_field_name = (
368 "field." + self._getFieldName("url", rule.id))
369 delete_field_name = (452 delete_field_name = (
370 "field." + self._getFieldName("delete", rule.id))453 "field." + self._getFieldName("delete", rule.id))
371 return {454 return {
455 "url": widgets_by_name[url_field_name],
372 "image_name": widgets_by_name[image_field_name],456 "image_name": widgets_by_name[image_field_name],
373 "username": widgets_by_name[username_field_name],457 "username": widgets_by_name[username_field_name],
374 "url": widgets_by_name[url_field_name],
375 "delete": widgets_by_name[delete_field_name],458 "delete": widgets_by_name[delete_field_name],
376 }459 }
377460
461 def getNewRuleWidgets(self):
462 widgets_by_name = {widget.name: widget for widget in self.widgets}
463 return {
464 "image_name": widgets_by_name["field.add_image_name"],
465 "existing_credentials":
466 widgets_by_name["field.existing_credentials"],
467 "url": widgets_by_name["field.add_url"],
468 "username": widgets_by_name["field.add_username"],
469 "password": widgets_by_name["field.add_password"],
470 "confirm_password": widgets_by_name["field.add_confirm_password"],
471 }
472
378 def parseData(self, data):473 def parseData(self, data):
379 """Rearrange form data to make it easier to process."""474 """Rearrange form data to make it easier to process."""
380 parsed_data = {}475 parsed_data = {}
381476 add_image_name = data.get("add_image_name")
477 add_url = data.get("add_url")
478 add_username = data.get("add_username")
479 add_password = data.get("add_password")
480 add_confirm_password = data.get("add_confirm_password")
481 add_existing_credentials = data.get("existing_credentials")
482
483 # parse data from the Add new rule section of the form
484 if (add_url or add_username or add_password or
485 add_confirm_password or add_image_name or
486 add_existing_credentials):
487 parsed_data.setdefault(None, {
488 "image_name": add_image_name,
489 "url": add_url,
490 "username": add_username,
491 "password": add_password,
492 "confirm_password": add_confirm_password,
493 "existing_credentials": data["existing_credentials"],
494 "add_credentials": data["add_credentials"],
495 "action": "add",
496 })
497 # parse data from the Edit existing rule section of the form
382 for field_name in sorted(498 for field_name in sorted(
383 name for name in data if name.split(".")[0] == "image_name"):499 name for name in data if name.split(".")[0] == "image_name"):
384 _, rule_id = self._parseFieldName(field_name)500 _, rule_id = self._parseFieldName(field_name)
@@ -395,6 +511,59 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
395511
396 return parsed_data512 return parsed_data
397513
514 def addNewRule(self, parsed_data):
515 add_data = parsed_data[None]
516 image_name = add_data.get("image_name")
517 url = add_data.get("url")
518 password = add_data.get("password")
519 confirm_password = add_data.get("confirm_password")
520 username = add_data.get("username")
521 existing_credentials = add_data.get("existing_credentials")
522 add_credentials = add_data.get("add_credentials")
523
524 if not image_name:
525 self.setFieldError("add_image_name", "Image name must be set.")
526 return
527
528 if add_credentials == "existing":
529 if not existing_credentials:
530 return
531
532 credentials = existing_credentials
533
534 elif add_credentials == "new":
535 if not url:
536 self.setFieldError("add_url", "Registry URL must be set.")
537 return
538 if password != confirm_password:
539 self.setFieldError(
540 "add_confirm_password", "Passwords do not match.")
541 return
542
543 credentials_set = getUtility(IOCIRegistryCredentialsSet)
544 try:
545 credentials = credentials_set.getOrCreate(
546 owner=self.context.owner, url=url,
547 credentials={'username': username, 'password': password})
548 except OCIRegistryCredentialsAlreadyExist:
549 self.setFieldError(
550 "add_url",
551 "Credentials already exist with the same URL and "
552 "username.")
553 return
554 except ValidationError:
555 self.setFieldError("add_url", "Not a valid URL.")
556 return
557
558 try:
559 getUtility(IOCIPushRuleSet).new(
560 self.context, credentials, image_name)
561 except OCIPushRuleAlreadyExists:
562 self.setFieldError(
563 "add_image_name",
564 "A push rule already exists with the same URL, image name, "
565 "and credentials.")
566
398 def updatePushRulesFromData(self, parsed_data):567 def updatePushRulesFromData(self, parsed_data):
399 rules_map = {568 rules_map = {
400 rule.id: rule569 rule.id: rule
@@ -415,6 +584,8 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
415 rule.setNewImageName(image_name)584 rule.setNewImageName(image_name)
416 elif action == "delete":585 elif action == "delete":
417 rule.destroySelf()586 rule.destroySelf()
587 elif action == "add":
588 self.addNewRule(parsed_data)
418 else:589 else:
419 raise AssertionError("unknown action: %s" % action)590 raise AssertionError("unknown action: %s" % action)
420591
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index e2cdd77..8b17168 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -17,9 +17,12 @@ import re
1717
18from fixtures import FakeLogger18from fixtures import FakeLogger
19import pytz19import pytz
20from six.moves.urllib.parse import quote
20import soupmatchers21import soupmatchers
22from storm.locals import Store
21from testtools.matchers import (23from testtools.matchers import (
22 Equals,24 Equals,
25 Is,
23 MatchesDict,26 MatchesDict,
24 MatchesSetwise,27 MatchesSetwise,
25 MatchesStructure,28 MatchesStructure,
@@ -887,12 +890,20 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
887 "oci.build_series.%s" % self.distroseries.distribution.name:890 "oci.build_series.%s" % self.distroseries.distribution.name:
888 self.distroseries.name,891 self.distroseries.name,
889 }))892 }))
890 oci_project = self.factory.makeOCIProject(893 self.oci_project = self.factory.makeOCIProject(
891 pillar=self.distroseries.distribution,894 pillar=self.distroseries.distribution,
892 ociprojectname="oci-project-name")895 ociprojectname="oci-project-name")
896
897 self.member = self.factory.makePerson()
898 self.team = self.factory.makeTeam(members=[self.person, self.member])
899
893 self.recipe = self.factory.makeOCIRecipe(900 self.recipe = self.factory.makeOCIRecipe(
894 name="recipe-name", registrant=self.person, owner=self.person,901 name="recipe-name", registrant=self.person, owner=self.person,
895 oci_project=oci_project)902 oci_project=self.oci_project)
903
904 self.team_owned_recipe = self.factory.makeOCIRecipe(
905 name="recipe-name", registrant=self.person, owner=self.team,
906 oci_project=self.oci_project)
896907
897 self.setConfig()908 self.setConfig()
898909
@@ -1013,7 +1024,7 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
1013 "Image name", "td", text=image_name))))1024 "Image name", "td", text=image_name))))
10141025
1015 def test_edit_oci_push_rules(self):1026 def test_edit_oci_push_rules(self):
1016 url = unicode(self.factory.getUniqueURL())1027 url = self.factory.getUniqueURL()
1017 credentials = {'username': 'foo', 'password': 'bar'}1028 credentials = {'username': 'foo', 'password': 'bar'}
1018 registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(1029 registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
1019 owner=self.person,1030 owner=self.person,
@@ -1077,8 +1088,52 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
1077 self.assertRaises(OCIPushRuleAlreadyExists,1088 self.assertRaises(OCIPushRuleAlreadyExists,
1078 browser.getControl("Save").click)1089 browser.getControl("Save").click)
10791090
1091 def test_edit_oci_push_rules_non_owner_of_credentials(self):
1092 url = self.factory.getUniqueURL()
1093 credentials = {'username': 'foo', 'password': 'bar'}
1094 registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
1095 owner=self.person,
1096 url=url,
1097 credentials=credentials)
1098 image_names = [self.factory.getUniqueUnicode() for _ in range(2)]
1099 push_rules = [
1100 getUtility(IOCIPushRuleSet).new(
1101 recipe=self.team_owned_recipe,
1102 registry_credentials=registry_credentials,
1103 image_name=image_name)
1104 for image_name in image_names]
1105 Store.of(push_rules[-1]).flush()
1106 push_rule_ids = [push_rule.id for push_rule in push_rules]
1107 browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
1108 browser.getLink("Edit push rules").click()
1109 row = soupmatchers.Tag(
1110 "push rule row", "tr", attrs={"class": "push-rule"})
1111 self.assertThat(browser.contents, soupmatchers.HTMLContains(
1112 soupmatchers.Within(
1113 row,
1114 soupmatchers.Tag(
1115 "username widget", "span",
1116 attrs={
1117 "id": "field.username.%d" % push_rule_ids[0],
1118 "class": "sprite private",
1119 })),
1120 soupmatchers.Within(
1121 row,
1122 soupmatchers.Tag(
1123 "url widget", "span",
1124 attrs={
1125 "id": "field.url.%d" % push_rule_ids[0],
1126 "class": "sprite private",
1127 }))))
1128 browser.getControl(
1129 name="field.image_name.%d" % push_rule_ids[0]).value = "image1"
1130 browser.getControl("Save").click()
1131 with person_logged_in(self.member):
1132 self.assertEqual("image1", push_rules[0].image_name)
1133 self.assertEqual(image_names[1], push_rules[1].image_name)
1134
1080 def test_delete_oci_push_rules(self):1135 def test_delete_oci_push_rules(self):
1081 url = unicode(self.factory.getUniqueURL())1136 url = self.factory.getUniqueURL()
1082 credentials = {'username': 'foo', 'password': 'bar'}1137 credentials = {'username': 'foo', 'password': 'bar'}
1083 registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(1138 registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
1084 owner=self.person,1139 owner=self.person,
@@ -1100,8 +1155,215 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
1100 self.assertIsNone(1155 self.assertIsNone(
1101 getUtility(IOCIPushRuleSet).getByID(push_rule.id))1156 getUtility(IOCIPushRuleSet).getByID(push_rule.id))
11021157
1158 def test_add_oci_push_rules_validations(self):
1159 # Add new rule works when there are no rules in the DB.
1160 browser = self.getViewBrowser(self.recipe, user=self.person)
1161 browser.getLink("Edit push rules").click()
1162
1163 # Save does not error if there are no changes on the form.
1164 browser.getControl("Save").click()
1165 self.assertIn("Saved push rules", browser.contents)
1166
1167 # If only an image name is given but no registry URL, we fail with
1168 # "Registry URL must be set".
1169 browser.getLink("Edit push rules").click()
1170 browser.getControl(name="field.add_image_name").value = "imagename1"
1171 browser.getControl(name="field.add_credentials").value = "new"
1172 browser.getControl("Save").click()
1173 self.assertIn("Registry URL must be set", browser.contents)
1174
1175 # No image name entered on the form. We assume user is only editing
1176 # and we allow saving the form.
1177 browser.getControl(name="field.add_image_name").value = ""
1178 browser.getControl("Save").click()
1179 self.assertIn("Saved push rules", browser.contents)
1180
1181 def test_add_oci_push_rules_new_empty_credentials(self):
1182 # Supplying an image name and registry URL creates a credentials
1183 # object without username or password, and a valid push rule based
1184 # on that credentials object.
1185 url = self.factory.getUniqueURL()
1186 browser = self.getViewBrowser(self.recipe, user=self.person)
1187 browser.getLink("Edit push rules").click()
1188 browser.getControl(name="field.add_credentials").value = "new"
1189 browser.getControl(name="field.add_image_name").value = "imagename1"
1190 browser.getControl(name="field.add_url").value = url
1191 browser.getControl("Save").click()
1192 with person_logged_in(self.person):
1193 rules = list(removeSecurityProxy(
1194 getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
1195 self.assertEqual(len(rules), 1)
1196 rule = rules[0]
1197 self.assertThat(rule, MatchesStructure(
1198 image_name=Equals("imagename1"),
1199 registry_url=Equals(url),
1200 registry_credentials=MatchesStructure(
1201 url=Equals(url),
1202 username=Is(None))))
1203
1204 with person_logged_in(self.person):
1205 self.assertEqual(
1206 {"password": None}, rule.registry_credentials.getCredentials())
1207
1208 def test_add_oci_push_rules_new_username_password(self):
1209 # Supplying an image name, registry URL, username, and password
1210 # creates a credentials object with the given username or password,
1211 # and a valid push rule based on that credentials object.
1212 url = self.factory.getUniqueURL()
1213 browser = self.getViewBrowser(self.recipe, user=self.person)
1214 browser.getLink("Edit push rules").click()
1215 browser.getControl(name="field.add_credentials").value = "new"
1216 browser.getControl(name="field.add_image_name").value = "imagename3"
1217 browser.getControl(name="field.add_url").value = url
1218 browser.getControl(name="field.add_username").value = "username"
1219 browser.getControl(name="field.add_password").value = "password"
1220 browser.getControl(
1221 name="field.add_confirm_password").value = "password"
1222 browser.getControl("Save").click()
1223 with person_logged_in(self.person):
1224 rules = list(removeSecurityProxy(
1225 getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
1226 self.assertEqual(len(rules), 1)
1227 rule = rules[0]
1228 self.assertThat(rule, MatchesStructure(
1229 image_name=Equals("imagename3"),
1230 registry_url=Equals(url),
1231 registry_credentials=MatchesStructure.byEquality(
1232 url=url,
1233 username="username")))
1234 with person_logged_in(self.person):
1235 self.assertEqual(
1236 {"username": "username", "password": "password"},
1237 rule.registry_credentials.getCredentials())
1238
1239 def test_add_oci_push_rules_existing_credentials_duplicate(self):
1240 # Adding a new push rule using existing credentials fails if a rule
1241 # with the same image name already exists.
1242 existing_rule = self.factory.makeOCIPushRule(
1243 recipe=self.recipe,
1244 registry_credentials=self.factory.makeOCIRegistryCredentials(
1245 owner=self.recipe.owner))
1246 existing_image_name = existing_rule.image_name
1247 existing_registry_url = existing_rule.registry_url
1248 existing_username = existing_rule.username
1249 browser = self.getViewBrowser(self.recipe, user=self.person)
1250 browser.getLink("Edit push rules").click()
1251 browser.getControl(name="field.add_credentials").value = "existing"
1252 browser.getControl(name="field.add_image_name").value = (
1253 existing_image_name)
1254 browser.getControl(name="field.existing_credentials").value = (
1255 "%s %s" % (quote(existing_registry_url), quote(existing_username)))
1256 browser.getControl("Save").click()
1257 self.assertIn(
1258 "A push rule already exists with the same URL, "
1259 "image name, and credentials.", browser.contents)
1260
1261 def test_add_oci_push_rules_existing_credentials(self):
1262 # Previously added registry credentials can be chosen from the radio
1263 # widget when adding a new rule.
1264 # We correctly display the radio buttons widget when the
1265 # username is empty in registry credentials and
1266 # allow correctly adding new rule based on it
1267 existing_rule = self.factory.makeOCIPushRule(
1268 recipe=self.recipe,
1269 registry_credentials=self.factory.makeOCIRegistryCredentials(
1270 owner=self.recipe.owner, credentials={}))
1271 existing_registry_url = existing_rule.registry_url
1272 browser = self.getViewBrowser(self.recipe, user=self.person)
1273 browser.getLink("Edit push rules").click()
1274 browser.getControl(name="field.add_credentials").value = "existing"
1275 browser.getControl(name="field.add_image_name").value = "imagename2"
1276 browser.getControl(name="field.existing_credentials").value = (
1277 quote(existing_registry_url))
1278 browser.getControl("Save").click()
1279 with person_logged_in(self.person):
1280 rules = list(removeSecurityProxy(
1281 getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
1282 self.assertEqual(len(rules), 2)
1283 rule = rules[1]
1284 self.assertThat(rule, MatchesStructure(
1285 image_name=Equals("imagename2"),
1286 registry_url=Equals(existing_registry_url),
1287 registry_credentials=MatchesStructure(
1288 url=Equals(existing_registry_url),
1289 username=Is(None))))
1290 with person_logged_in(self.person):
1291 self.assertEqual({}, rule.registry_credentials.getCredentials())
1292
1293 def test_add_oci_push_rules_team_owned(self):
1294 url = self.factory.getUniqueURL()
1295 browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
1296 browser.getLink("Edit push rules").click()
1297 browser.getControl(
1298 name="field.add_image_name").value = "imagename1"
1299 browser.getControl(
1300 name="field.add_url").value = url
1301 browser.getControl(name="field.add_credentials").value = "new"
1302 browser.getControl("Save").click()
1303
1304 with person_logged_in(self.member):
1305 rules = list(removeSecurityProxy(
1306 getUtility(IOCIPushRuleSet).findByRecipe(
1307 self.team_owned_recipe)))
1308 self.assertEqual(len(rules), 1)
1309 rule = rules[0]
1310 self.assertThat(rule, MatchesStructure(
1311 image_name=Equals(u'imagename1'),
1312 registry_url=Equals(url),
1313 registry_credentials=MatchesStructure(
1314 url=Equals(url),
1315 username=Is(None))))
1316
1317 with person_logged_in(self.member):
1318 self.assertThat(
1319 rule.registry_credentials.getCredentials(),
1320 MatchesDict(
1321 {"password": Equals(None)}))
1322
1323 def test_edit_oci_push_rules_team_owned(self):
1324 url = self.factory.getUniqueURL()
1325 browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
1326 browser.getLink("Edit push rules").click()
1327 browser.getControl(
1328 name="field.add_image_name").value = "imagename1"
1329 browser.getControl(
1330 name="field.add_url").value = url
1331 browser.getControl(name="field.add_credentials").value = "new"
1332 browser.getControl("Save").click()
1333
1334 # push rules created by another team member (self.member)
1335 # can be edited by self.person
1336 browser = self.getViewBrowser(self.team_owned_recipe, user=self.person)
1337 browser.getLink("Edit push rules").click()
1338 with person_logged_in(self.person):
1339 rules = list(removeSecurityProxy(
1340 getUtility(IOCIPushRuleSet).findByRecipe(
1341 self.team_owned_recipe)))
1342 self.assertEqual(len(rules), 1)
1343 rule = rules[0]
1344 self.assertEqual("imagename1", browser.getControl(
1345 name="field.image_name.%d" % rule.id).value)
1346
1347 # set image name to valid string
1348 with person_logged_in(self.person):
1349 browser.getControl(
1350 name="field.image_name.%d" % rule.id).value = "image1"
1351 browser.getControl("Save").click()
1352
1353 # and assert model changed
1354 with person_logged_in(self.member):
1355 self.assertEqual(
1356 rule.image_name, "image1")
1357
1358 # self.member will see the new image name
1359 browser = self.getViewBrowser(self.team_owned_recipe, user=self.member)
1360 browser.getLink("Edit push rules").click()
1361 with person_logged_in(self.member):
1362 self.assertEqual("image1", browser.getControl(
1363 name="field.image_name.%d" % rule.id).value)
1364
1103 def test_edit_oci_registry_creds(self):1365 def test_edit_oci_registry_creds(self):
1104 url = unicode(self.factory.getUniqueURL())1366 url = self.factory.getUniqueURL()
1105 credentials = {'username': 'foo', 'password': 'bar'}1367 credentials = {'username': 'foo', 'password': 'bar'}
1106 image_name = self.factory.getUniqueUnicode()1368 image_name = self.factory.getUniqueUnicode()
1107 registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(1369 registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
diff --git a/lib/lp/oci/javascript/ocirecipe.edit.js b/lib/lp/oci/javascript/ocirecipe.edit.js
1108new file mode 1006441370new file mode 100644
index 0000000..8b3d82d
--- /dev/null
+++ b/lib/lp/oci/javascript/ocirecipe.edit.js
@@ -0,0 +1,38 @@
1/* Copyright 2015-2020 Canonical Ltd. This software is licensed under the
2 * GNU Affero General Public License version 3 (see the file LICENSE).
3 *
4 * @module Y.lp.oci.ocirecipe.edit
5 * @requires node, DOM
6 */
7YUI.add('lp.oci.ocirecipe.edit', function(Y) {
8 var module = Y.namespace('lp.oci.ocirecipe.edit');
9
10 module.set_enabled = function(field_id, is_enabled) {
11 var field = Y.DOM.byId(field_id);
12 if (field !== null) {
13 field.disabled = !is_enabled;
14 }
15 };
16
17 module.onclick_add_credentials = function(e) {
18 var value = '';
19 Y.all('input[name="field.add_credentials"]').each(function(node) {
20 if (node.get('checked')) {
21 value = node.get('value');
22 }
23 });
24 module.set_enabled('field.existing_credentials', value === 'existing');
25 module.set_enabled('field.add_url', value === 'new');
26 module.set_enabled('field.add_username', value === 'new');
27 module.set_enabled('field.add_password', value === 'new');
28 module.set_enabled('field.add_confirm_password', value === 'new');
29 };
30
31 module.setup = function() {
32 Y.all('input[name="field.add_credentials"]').on(
33 'click', module.onclick_add_credentials);
34
35 // Set the initial state.
36 module.onclick_add_credentials();
37 };
38}, '0.1', {'requires': ['node', 'DOM']});
diff --git a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
index ccfe716..eee7db7 100644
--- a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
+++ b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
@@ -7,35 +7,142 @@
7 i18n:domain="launchpad">7 i18n:domain="launchpad">
8<body>8<body>
99
10<metal:block fill-slot="head_epilogue">
11 <style type="text/css">
12 table.push-rules-table {
13 max-width: 60%;
14 margin-bottom: 1em;
15 }
16 table.push-rules-table tr.even {
17 background-color: #eee;
18 }
19 /* These add up to 100%. */
20 tr .push-rule-url {
21 width: 35%;
22 }
23 tr .push-rule-image-name {
24 width: 20%;
25 }
26 tr .push-rule-username {
27 width: 10%;
28 }
29 tr .push-rule-password, tr .push-rule-confirm-password {
30 width: 15%;
31 }
32 tr .push-rule-delete {
33 width: 5%;
34 }
35 </style>
36</metal:block>
37
10<div metal:fill-slot="main">38<div metal:fill-slot="main">
11 <div metal:use-macro="context/@@launchpad_form/form">39 <div metal:use-macro="context/@@launchpad_form/form">
12 <metal:formbody fill-slot="widgets">40 <metal:formbody fill-slot="widgets">
13 <p condition="view/can_edit_credentials">41 <p condition="view/can_edit_credentials">
14 <a class="sprite edit" tal:attributes="href context/owner/fmt:url/+edit-oci-registry-credentials">Edit OCI registry credentials</a>42 <a class="sprite edit" tal:attributes="href context/owner/fmt:url/+edit-oci-registry-credentials">Edit OCI registry credentials</a>
15 </p>43 </p>
16 <table class="form">44 <table class="listing push-rules-table">
17 <tr tal:repeat="rule context/push_rules">45 <thead>
18 <tal:rules_widgets46 <tr>
19 define="rules_widgets python:view.getRulesWidgets(rule);47 <th class="push-rule-image-name">Image name</th>
20 parity python:'even' if repeat['rule'].even() else 'odd'">48 <th class="push-rule-url">Registry URL</th>
21 <td tal:define="widget nocall:rules_widgets/image_name">49 <th class="push-rule-username">Username</th>
22 <metal:widget use-macro="context/@@launchpad_form/widget_div" />50 <th class="push-rule-password">Password</th>
23 </td>51 <th class="push-rule-confirm-password">Confirm password</th>
24 <td tal:define="widget nocall:rules_widgets/username">52 <th class="push-rule-delete">Delete?</th>
25 <metal:widget use-macro="context/@@launchpad_form/widget_div" />53 </tr>
26 </td>54 </thead>
27 <td tal:define="widget nocall:rules_widgets/url">55 <tbody>
28 <metal:widget use-macro="context/@@launchpad_form/widget_div" />56 <tal:rule repeat="rule view/push_rules">
29 </td>57 <tal:rule_widgets
30 <td tal:define="widget nocall:rules_widgets/delete">58 define="rule_widgets python:view.getRuleWidgets(rule);
31 <metal:widget use-macro="context/@@launchpad_form/widget_div" />59 parity python:'even' if repeat['rule'].even() else 'odd'">
32 </td>60 <tr tal:attributes="class string:push-rule ${parity}">
33 </tal:rules_widgets>61 <td class="push-rule-image-name"
34 </tr>62 tal:define="widget nocall:rule_widgets/image_name">
63 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
64 </td>
65 <td class="push-rule-url"
66 tal:define="widget nocall:rule_widgets/url">
67 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
68 </td>
69 <td class="push-rule-username"
70 tal:define="widget nocall:rule_widgets/username">
71 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
72 </td>
73 <td colspan="2" />
74 <td class="push-rule-delete"
75 tal:define="widget nocall:rule_widgets/delete">
76 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
77 </td>
78 </tr>
79 </tal:rule_widgets>
80 </tal:rule>
81 <tal:new-rule
82 define="new_rule_widgets python:view.getNewRuleWidgets();
83 parity python:'odd' if len(view.push_rules) % 2
84 else 'even'">
85 <tr tal:attributes="class parity">
86 <td class="push-rule-image-name"
87 tal:define="widget nocall:new_rule_widgets/image_name">
88 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
89 </td>
90 <td colspan="5" />
91 </tr>
92 <tr tal:attributes="class parity">
93 <td>
94 <label>
95 <input type="radio" name="field.add_credentials"
96 value="existing" checked="checked" />
97 Use existing credentials:
98 </label>
99 </td>
100 <td colspan="4"
101 tal:define="widget nocall:new_rule_widgets/existing_credentials">
102 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
103 </td>
104 <td />
105 </tr>
106 <tr tal:attributes="class parity">
107 <td>
108 <label>
109 <input type="radio" name="field.add_credentials"
110 value="new" />
111 Add new credentials:
112 </label>
113 </td>
114 <td class="push-rule-url"
115 tal:define="widget nocall:new_rule_widgets/url">
116 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
117 </td>
118 <td class="push-rule-username"
119 tal:define="widget nocall:new_rule_widgets/username">
120 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
121 </td>
122 <td class="push-rule-password"
123 tal:define="widget nocall:new_rule_widgets/password">
124 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
125 </td>
126 <td class="push-rule-confirm-password"
127 tal:define="widget nocall:new_rule_widgets/confirm_password">
128 <metal:widget use-macro="context/@@launchpad_form/widget_div" />
129 </td>
130 <td />
131 </tr>
132 </tal:new-rule>
133 </tbody>
35 </table>134 </table>
36 </metal:formbody>135 </metal:formbody>
37</div>136</div>
38137
138 <script type="text/javascript">
139 LPJS.use('lp.oci.ocirecipe.edit', function(Y) {
140 Y.on('domready', function(e) {
141 Y.lp.oci.ocirecipe.edit.setup();
142 }, window);
143 });
144 </script>
145
39</div>146</div>
40</body>147</body>
41</html>148</html>
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
index aa1fae3..e29fcd9 100644
--- a/lib/lp/oci/vocabularies.py
+++ b/lib/lp/oci/vocabularies.py
@@ -8,10 +8,19 @@ from __future__ import absolute_import, print_function, unicode_literals
8__metaclass__ = type8__metaclass__ = type
9__all__ = []9__all__ = []
1010
11from six.moves.urllib.parse import (
12 quote,
13 unquote,
14 )
15from zope.component import getUtility
11from zope.interface import implementer16from zope.interface import implementer
12from zope.schema.vocabulary import SimpleTerm17from zope.schema.vocabulary import SimpleTerm
1318
19from lp.oci.interfaces.ociregistrycredentials import (
20 IOCIRegistryCredentialsSet,
21 )
14from lp.oci.model.ocirecipe import OCIRecipe22from lp.oci.model.ocirecipe import OCIRecipe
23from lp.oci.model.ociregistrycredentials import OCIRegistryCredentials
15from lp.services.webapp.vocabulary import (24from lp.services.webapp.vocabulary import (
16 IHugeVocabulary,25 IHugeVocabulary,
17 StormVocabularyBase,26 StormVocabularyBase,
@@ -35,6 +44,49 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
35 return len(self.context.getAllowedArchitectures())44 return len(self.context.getAllowedArchitectures())
3645
3746
47class OCIRegistryCredentialsVocabulary(StormVocabularyBase):
48
49 _table = OCIRegistryCredentials
50
51 def toTerm(self, obj):
52 if obj.username:
53 token = "%s %s" % (quote(obj.url), quote(obj.username))
54 title = "%s (%s)" % (obj.url, obj.username)
55 else:
56 token = quote(obj.url)
57 title = obj.url
58
59 return SimpleTerm(obj, token, title)
60
61 @property
62 def _entries(self):
63 return list(getUtility(
64 IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
65
66 def __contains__(self, value):
67 """See `IVocabulary`."""
68 return value in self._entries
69
70 def __len__(self):
71 return len(self._entries)
72
73 def getTermByToken(self, token):
74 """See `IVocabularyTokenized`."""
75 try:
76 if ' ' in token:
77 url, username = token.split(' ', 1)
78 url = unquote(url)
79 username = unquote(username)
80 else:
81 username = None
82 url = unquote(token)
83 for obj in self._entries:
84 if obj.url == url and obj.username == username:
85 return self.toTerm(obj)
86 except ValueError:
87 raise LookupError(token)
88
89
38@implementer(IHugeVocabulary)90@implementer(IHugeVocabulary)
39class OCIRecipeVocabulary(StormVocabularyBase):91class OCIRecipeVocabulary(StormVocabularyBase):
40 """All OCI Recipes of a given OCI project."""92 """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 @@
16 </class>16 </class>
1717
18 <securedutility18 <securedutility
19 name="OCIRegistryCredentials"
20 component="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary"
21 provides="zope.schema.interfaces.IVocabularyFactory">
22 <allow interface="zope.schema.interfaces.IVocabularyFactory" />
23 </securedutility>
24
25 <class class="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary">
26 <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
27 </class>
28 <securedutility
19 name="OCIRecipe"29 name="OCIRecipe"
20 component="lp.oci.vocabularies.OCIRecipeVocabulary"30 component="lp.oci.vocabularies.OCIRecipeVocabulary"
21 provides="zope.schema.interfaces.IVocabularyFactory">31 provides="zope.schema.interfaces.IVocabularyFactory">

Subscribers

People subscribed via source and target branches

to status/vote changes: