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

Proposed by Ioana Lasc
Status: Rejected
Rejected by: Ioana Lasc
Proposed branch: ~ilasc/launchpad:oci-recipe-push-rules-edit
Merge into: launchpad:master
Diff against target: 1434 lines (+1249/-5)
9 files modified
lib/lp/oci/browser/configure.zcml (+12/-0)
lib/lp/oci/browser/ocirecipe.py (+634/-5)
lib/lp/oci/browser/tests/test_ocirecipe.py (+290/-0)
lib/lp/oci/javascript/ocirecipe.edit.js (+77/-0)
lib/lp/oci/templates/ocirecipe-edit-credentials.pt (+59/-0)
lib/lp/oci/templates/ocirecipe-edit-push-rules.pt (+112/-0)
lib/lp/oci/templates/ocirecipe-index.pt (+5/-0)
lib/lp/oci/vocabularies.py (+50/-0)
lib/lp/oci/vocabularies.zcml (+10/-0)
Reviewer Review Type Date Requested Status
Launchpad code reviewers Pending
Review via email: mp+386371@code.launchpad.net

Commit message

Add Edit screen for OCI Push Rules

Description of the change

Add Edit screen for OCI Push Rules

To post a comment you must log in.
6ca31f2... by Ioana Lasc

Format imports

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

I'm adding a few comments about the visual of the edit pages. Since they might (or might not) change the code part, I'll review the code after you take a look on these points (let me know if something doesn't make sense or you don't agree):

- When I enter the "Edit OCI push rules for <recipe>" page, the password fields to add a new credential are not password fields (I can see what I type).

- Shouldn't the "Use existing credentials" and "Add new credentials" be radio buttons? Is it expected to be able to both add and use an existing credential at the same time?

- After adding a push rule, it might be interesting to have a separation between the table of existing rules and the form below to add a new one. Maybe an <h2> tag with "Add a new push rule" before the form would do.

- On the edit push rule table, since we are repeating the field names on each row, maybe we can drop the table header, the same way we have the +edit-credentials table.

- On the edit push rule table, do we really need a link to edit the credentials on each row, given that every link points to the same +edit-credentials page? Maybe we should put this link only once in the page.

1b43a41... by Ioana Lasc

Address code review comments

Revision history for this message
Ioana Lasc (ilasc) wrote :

Thank you Thiago, agreed with all the suggested layout changes:

1: Indeed the password fields to add a new credential were not password fields, that's now corrected

2: They behave like Radio buttons - I implemented the checks on them that way but I the only way I could get the indentation right on the page (make them look like the outer option) was by using check boxes

3: Agreed that separation between Edit and Add should be there - done

4: Agreed and removed headings

5: Agreed and added an Edit Option only once at the top with the Edit sprite

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Thanks for the changes, ioana! They are looking better now. Just a few more points:

- On the UI, visually the checkboxes do not behave that much like radio buttons. I can click and select both boxes at the same time, for example. It might work if you use them as Choice + Vocabulary instead of separated booleans, but I have to admit that I didn't test it. Maybe a simple custom Javascript to at least unselect the items could make this a bit better. Something more or less like this:

```
a = document.getElementById("field.use_existing_credentials");
b = document.getElementById("field.add_new_credentials");
a.onclick = () => { if(a.checked) b.checked = false };
b.onclick = () => { if(b.checked) a.checked = false };
```

- Now that the link to edit credentials is at the top, maybe we could put the "delete" checkbox a bit to the left. It is too separated from the image name now.

- Another thing that I'm missing is the credentials on the edit table. Maybe putting the credential description alongside with the image name would be nice?

- For the existing credentials, we are showing something like `https://registry.dockerhub.com pappacena`. Since the username is usually smaller, putting it after the URL looks a bit like it's part of the URL if you do not pay attention while reading. Maybe we should format it like `"pappacena" user at https://registry.dockerhub.com` or something similar.

Revision history for this message
Thiago F. Pappacena (pappacena) wrote :

Added some inline comments in the code. Really good set of tests! Thank you!

72cac41... by Ioana Lasc

Add javascript for checkboxes

aaf0d39... by Ioana Lasc

Disable/enable subordinate fields on add new push rule

Revision history for this message
Ioana Lasc (ilasc) wrote :

This is now split between https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/387293
and a new MP to follow that will treat the Add new push rule case only to avoid MP size growing again.

Unmerged commits

aaf0d39... by Ioana Lasc

Disable/enable subordinate fields on add new push rule

72cac41... by Ioana Lasc

Add javascript for checkboxes

1b43a41... by Ioana Lasc

Address code review comments

6ca31f2... by Ioana Lasc

Format imports

0c23965... by Ioana Lasc

Add Edit screen for OCI Push Rules

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml
2index b66e509..c531f28 100644
3--- a/lib/lp/oci/browser/configure.zcml
4+++ b/lib/lp/oci/browser/configure.zcml
5@@ -61,6 +61,18 @@
6 template="../../app/templates/generic-edit.pt" />
7 <browser:page
8 for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
9+ class="lp.oci.browser.ocirecipe.OCIRecipeEditPushRulesView"
10+ permission="launchpad.Edit"
11+ name="+edit-push-rules"
12+ template="../templates/ocirecipe-edit-push-rules.pt" />
13+ <browser:page
14+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
15+ class="lp.oci.browser.ocirecipe.OCIRecipeEditCredentialsView"
16+ permission="launchpad.Edit"
17+ name="+edit-credentials"
18+ template="../templates/ocirecipe-edit-credentials.pt" />
19+ <browser:page
20+ for="lp.oci.interfaces.ocirecipe.IOCIRecipe"
21 class="lp.oci.browser.ocirecipe.OCIRecipeDeleteView"
22 permission="launchpad.Edit"
23 name="+delete"
24diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
25index 356ac1c..0578f7b 100644
26--- a/lib/lp/oci/browser/ocirecipe.py
27+++ b/lib/lp/oci/browser/ocirecipe.py
28@@ -11,6 +11,8 @@ __all__ = [
29 'OCIRecipeAdminView',
30 'OCIRecipeContextMenu',
31 'OCIRecipeDeleteView',
32+ 'OCIRecipeEditCredentialsView',
33+ 'OCIRecipeEditPushRulesView',
34 'OCIRecipeEditView',
35 'OCIRecipeNavigation',
36 'OCIRecipeNavigationMenu',
37@@ -23,23 +25,42 @@ from lazr.restful.interface import (
38 use_template,
39 )
40 from zope.component import getUtility
41+from zope.formlib.boolwidgets import CheckBoxWidget
42+from zope.formlib.form import FormFields
43+from zope.formlib.itemswidgets import RadioWidget
44+from zope.formlib.textwidgets import (
45+ PasswordWidget,
46+ TextWidget,
47+ )
48+from zope.formlib.widget import CustomWidgetFactory
49 from zope.interface import Interface
50 from zope.schema import (
51+ Bool,
52 Choice,
53 List,
54+ Password,
55+ TextLine,
56 )
57+from zope.security.proxy import removeSecurityProxy
58
59 from lp.app.browser.launchpadform import (
60 action,
61 LaunchpadEditFormView,
62- LaunchpadFormView,
63- )
64+ LaunchpadFormView, render_radio_widget_part,
65+)
66 from lp.app.browser.lazrjs import InlinePersonEditPickerWidget
67 from lp.app.browser.tales import format_link
68-from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
69+from lp.app.errors import UnexpectedFormData
70+from lp.app.widgets.itemswidgets import (
71+ LabeledMultiCheckBoxWidget,
72+ LaunchpadRadioWidget, LaunchpadBooleanRadioWidget, LaunchpadDropdownWidget,
73+)
74 from lp.buildmaster.interfaces.processor import IProcessorSet
75 from lp.code.browser.widgets.gitref import GitRefWidget
76-from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
77+from lp.oci.interfaces.ocipushrule import (
78+ IOCIPushRuleSet,
79+ OCIPushRuleAlreadyExists,
80+ )
81 from lp.oci.interfaces.ocirecipe import (
82 IOCIRecipe,
83 IOCIRecipeSet,
84@@ -50,6 +71,11 @@ from lp.oci.interfaces.ocirecipe import (
85 OCIRecipeFeatureDisabled,
86 )
87 from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
88+from lp.oci.interfaces.ociregistrycredentials import (
89+ IOCIRegistryCredentialsSet,
90+ OCIRegistryCredentialsAlreadyExist,
91+ )
92+from lp.registry.enums import VCSType
93 from lp.services.features import getFeatureFlag
94 from lp.services.helpers import english_list
95 from lp.services.propertycache import cachedproperty
96@@ -138,12 +164,18 @@ class OCIRecipeContextMenu(ContextMenu):
97
98 facet = 'overview'
99
100- links = ('request_builds',)
101+ links = ('request_builds', 'edit_push_rules')
102
103 @enabled_with_permission('launchpad.Edit')
104 def request_builds(self):
105 return Link('+request-builds', 'Request builds', icon='add')
106
107+ @enabled_with_permission('launchpad.Edit')
108+ def edit_push_rules(self):
109+ return Link('+edit-push-rules',
110+ 'Edit push rules',
111+ icon='edit')
112+
113
114 class OCIProjectRecipesView(LaunchpadView):
115 """Default view for the list of OCI recipes of an OCI project."""
116@@ -243,6 +275,601 @@ def new_builds_notification_text(builds, already_pending=None):
117 return builds_text
118
119
120+class OCIRecipeEditCredentialsView(LaunchpadFormView):
121+ """View for +ocirecipe-edit-credentials.pt."""
122+
123+ @cachedproperty
124+ def oci_registry_credentials(self):
125+ return list(getUtility(
126+ IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
127+
128+ schema = Interface
129+
130+ def _getFieldName(self, name, credentials_id):
131+ """Get the combined field name for an `OCIRegistryCredentials` ID.
132+
133+ In order to be able to render a table, we encode the credentials ID
134+ in the form field name.
135+ """
136+ return "%s.%d" % (name, credentials_id)
137+
138+ def getEditFieldsRow(self, credentials=None):
139+ id = getattr(credentials, 'id', None)
140+ owner = Choice(
141+ title=u'Owner',
142+ vocabulary=(
143+ 'AllUserTeamsParticipationPlusSelfSimpleDisplay'),
144+ default=credentials.owner.name,
145+ __name__=self._getFieldName('owner', id))
146+
147+ username = TextLine(
148+ title=u'Username',
149+ __name__=self._getFieldName('username', id),
150+ default=credentials.username,
151+ required=False, readonly=False)
152+
153+ password = Password(
154+ title=u'Password',
155+ __name__=self._getFieldName('password', id),
156+ default=None,
157+ required=False, readonly=False)
158+
159+ confirm_password = Password(
160+ title=u'Confirm password',
161+ __name__=self._getFieldName('confirm_password', id),
162+ default=None,
163+ required=False, readonly=False)
164+
165+ url = TextLine(
166+ title=u'Registry URL',
167+ __name__=self._getFieldName('url', id),
168+ default=credentials.url,
169+ required=True, readonly=False)
170+
171+ delete = Bool(
172+ title=u'Delete',
173+ __name__=self._getFieldName('delete', id),
174+ default=False,
175+ required=True, readonly=False)
176+
177+ return owner, username, password, confirm_password, url, delete
178+
179+ def getAddFieldsRow(self):
180+ add_url = TextLine(
181+ title=u'Registry URL',
182+ __name__=u'add_url',
183+ required=False, readonly=False)
184+ add_username = TextLine(
185+ title=u'Username',
186+ __name__=u'add_username',
187+ required=False, readonly=False)
188+ add_password = Password(
189+ title=u'Password',
190+ __name__=u'add_password',
191+ required=False, readonly=False)
192+ add_confirm_password = Password(
193+ title=u'Confirm password',
194+ __name__=u'add_confirm_password',
195+ required=False, readonly=False)
196+
197+ return add_url, add_username, add_password, add_confirm_password
198+
199+ def _parseFieldName(self, field_name):
200+ """Parse a combined field name as described in `_getFieldName`.
201+
202+ :raises UnexpectedFormData: if the field name cannot be parsed or
203+ the `OCIRegistryCredentials` cannot be found.
204+ """
205+ field_bits = field_name.split(".")
206+ if len(field_bits) != 2:
207+ raise UnexpectedFormData(
208+ "Cannot parse field name: %s" % field_name)
209+ field_type = field_bits[0]
210+ try:
211+ credentials_id = int(field_bits[1])
212+ except ValueError:
213+ raise UnexpectedFormData(
214+ "Cannot parse field name: %s" % field_name)
215+ return field_type, credentials_id
216+
217+ def setUpFields(self):
218+ """See `LaunchpadFormView`."""
219+ LaunchpadFormView.setUpFields(self)
220+
221+ for elem in self.oci_registry_credentials:
222+ fields = self.getEditFieldsRow(elem)
223+ self.form_fields += FormFields(*fields)
224+
225+ add_fields = self.getAddFieldsRow()
226+ self.form_fields += FormFields(*add_fields)
227+
228+ @property
229+ def label(self):
230+ return 'Edit OCI registry credentials'
231+
232+ @property
233+ def cancel_url(self):
234+ return canonical_url(self.context)
235+
236+ def getCredentialsWidgets(self, credentials):
237+ widgets_by_name = {widget.name: widget for widget in self.widgets}
238+ owner_field_name = (
239+ "field." + self._getFieldName("owner", credentials.id))
240+ username_field_name = (
241+ "field." + self._getFieldName("username", credentials.id))
242+ password_field_name = (
243+ "field." + self._getFieldName("password", credentials.id))
244+ confirm_password_field_name = (
245+ "field." + self._getFieldName("confirm_password",
246+ credentials.id))
247+ url_field_name = "field." + self._getFieldName("url", credentials.id)
248+ delete_field_name = (
249+ "field." + self._getFieldName("delete", credentials.id))
250+ return {
251+ "owner": widgets_by_name[owner_field_name],
252+ "username": widgets_by_name[username_field_name],
253+ "password": widgets_by_name[password_field_name],
254+ "confirm_password": widgets_by_name[confirm_password_field_name],
255+ "url": widgets_by_name[url_field_name],
256+ "delete": widgets_by_name[delete_field_name]
257+ }
258+
259+ def parseData(self, data):
260+ """Rearrange form data to make it easier to process."""
261+ parsed_data = {}
262+ add_url = data["add_url"]
263+ add_username = data["add_username"]
264+ add_password = data["add_password"]
265+ add_confirm_password = data["add_confirm_password"]
266+ if add_url or add_username or add_password or add_confirm_password:
267+ parsed_data.setdefault(None, {
268+ "username": add_username,
269+ "password": add_password,
270+ "confirm_password": add_confirm_password,
271+ "url": add_url,
272+ "action": "add",
273+ })
274+ for field_name in (
275+ name for name in data if name.split(".")[0] == "owner"):
276+ _, credentials_id = self._parseFieldName(field_name)
277+ owner_field_name = self._getFieldName(
278+ "owner", credentials_id)
279+ username_field_name = self._getFieldName(
280+ "username", credentials_id)
281+ password_field_name = self._getFieldName(
282+ "password", credentials_id)
283+ confirm_password_field_name = self._getFieldName(
284+ "confirm_password", credentials_id)
285+ url_field_name = self._getFieldName("url", credentials_id)
286+ delete_field_name = self._getFieldName("delete", credentials_id)
287+ if data.get(delete_field_name):
288+ action = "delete"
289+ else:
290+ action = "change"
291+ parsed_data.setdefault(credentials_id, {
292+ "username": data.get(username_field_name),
293+ "password": data.get(password_field_name),
294+ "confirm_password": data.get(confirm_password_field_name),
295+ "url": data.get(url_field_name),
296+ "owner": data.get(owner_field_name),
297+ "action": action,
298+ })
299+
300+ return parsed_data
301+
302+ def changeCredentials(self, parsed_credentials, credentials):
303+ username = parsed_credentials["username"]
304+ password = parsed_credentials["password"]
305+ confirm_password = parsed_credentials["confirm_password"]
306+ owner = parsed_credentials["owner"]
307+ if password or confirm_password:
308+ if password != confirm_password:
309+ self.setFieldError(
310+ self._getFieldName(
311+ "confirm_password", credentials.id),
312+ "Passwords do not match.")
313+ else:
314+ credentials.setCredentials(
315+ {"username": username,
316+ "password": password})
317+ credentials.url = parsed_credentials["url"]
318+ elif username != credentials.username:
319+ removeSecurityProxy(credentials).username = username
320+ credentials.url = parsed_credentials["url"]
321+ elif parsed_credentials["url"] != credentials.url:
322+ credentials.url = parsed_credentials["url"]
323+ if owner != credentials.owner:
324+ credentials.owner = owner
325+
326+ def deleteCredentials(self, credentials):
327+ push_rule_set = getUtility(IOCIPushRuleSet)
328+ if not push_rule_set.findByRegistryCredentials(
329+ credentials).is_empty():
330+ self.setFieldError(
331+ self._getFieldName(
332+ "delete", credentials.id),
333+ "These credentials cannot be deleted as there are "
334+ "push rules defined that still use them.")
335+ else:
336+ credentials.destroySelf()
337+
338+ def addCredentials(self, parsed_add_credentials):
339+ url = parsed_add_credentials["url"]
340+ password = parsed_add_credentials["password"]
341+ confirm_password = parsed_add_credentials["confirm_password"]
342+ username = parsed_add_credentials["username"]
343+ if url:
344+ if password or confirm_password:
345+ if not password == confirm_password:
346+ self.setFieldError(
347+ "add_password",
348+ "Please make sure the new "
349+ "password matches the "
350+ "confirm password field.")
351+ return
352+
353+ credentials = {
354+ 'username': username,
355+ 'password': password}
356+ try:
357+ getUtility(IOCIRegistryCredentialsSet).new(
358+ owner=self.context.owner,
359+ url=url,
360+ credentials=credentials)
361+ except OCIRegistryCredentialsAlreadyExist:
362+ self.setFieldError(
363+ "add_url",
364+ "Credentials already exist "
365+ "with the same URL and "
366+ "username.")
367+ else:
368+ credentials = {'username': username}
369+ try:
370+ getUtility(IOCIRegistryCredentialsSet).new(
371+ owner=self.context.owner,
372+ url=url,
373+ credentials=credentials)
374+ except OCIRegistryCredentialsAlreadyExist:
375+ self.setFieldError(
376+ "add_url",
377+ "Credentials already exist "
378+ "with the same URL and username.")
379+ else:
380+ self.setFieldError(
381+ "add_url",
382+ "Registry URL cannot be empty.")
383+
384+ def updateCredentialsFromData(self, parsed_data):
385+ credentials_map = {
386+ credentials.id: credentials
387+ for credentials in self.oci_registry_credentials}
388+
389+ for credentials_id, parsed_credentials in parsed_data.items():
390+ credentials = credentials_map.get(credentials_id)
391+ action = parsed_credentials["action"]
392+
393+ if action == "change":
394+ self.changeCredentials(parsed_credentials, credentials)
395+ elif action == "delete":
396+ self.deleteCredentials(credentials)
397+ elif action == "add":
398+ parsed_add_credentials = parsed_data[credentials]
399+ self.addCredentials(parsed_add_credentials)
400+ else:
401+ raise AssertionError("unknown action: %s" % action)
402+
403+ @action("Save")
404+ def save(self, action, data):
405+ parsed_data = self.parseData(data)
406+ self.updateCredentialsFromData(parsed_data)
407+
408+ if not self.errors:
409+ self.request.response.addNotification("Saved credentials")
410+ self.next_url = canonical_url(self.context)
411+
412+
413+class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
414+ """View for +ocirecipe-edit-push-rules.pt."""
415+
416+ class schema(Interface):
417+ """Schema for editing push rules."""
418+
419+ @cachedproperty
420+ def push_rules(self):
421+ return list(
422+ getUtility(IOCIPushRuleSet).findByRecipe(self.context))
423+
424+ @property
425+ def has_push_rules(self):
426+ return len(self.push_rules) > 0
427+
428+ def _getFieldName(self, name, rule_id):
429+ """Get the combined field name for an `OCIPushRule` ID.
430+
431+ In order to be able to render a table, we encode the rule ID
432+ in the form field name.
433+ """
434+ return "%s.%d" % (name, rule_id)
435+
436+ def _parseFieldName(self, field_name):
437+ """Parse a combined field name as described in `_getFieldName`.
438+
439+ :raises UnexpectedFormData: if the field name cannot be parsed or
440+ the `OCIPushRule` cannot be found.
441+ """
442+ field_bits = field_name.split(".")
443+ if len(field_bits) != 2:
444+ raise UnexpectedFormData(
445+ "Cannot parse field name: %s" % field_name)
446+ field_type = field_bits[0]
447+ try:
448+ rule_id = int(field_bits[1])
449+ except ValueError:
450+ raise UnexpectedFormData(
451+ "Cannot parse field name: %s" % field_name)
452+ return field_type, rule_id
453+
454+ def setUpFields(self):
455+ """See `LaunchpadEditFormView`."""
456+ LaunchpadEditFormView.setUpFields(self)
457+
458+ image_fields = []
459+ username_fields = []
460+ password_fields = []
461+ url_fields = []
462+ delete_fields = []
463+ existing_credentials = []
464+ credentials = []
465+
466+ for elem in list(self.context.push_rules):
467+ image_fields.append(
468+ TextLine(
469+ title=u'Image name',
470+ __name__=self._getFieldName('image_name', elem.id),
471+ default=elem.image_name,
472+ required=True, readonly=False))
473+ delete_fields.append(
474+ Bool(
475+ title=u'Delete',
476+ __name__=self._getFieldName('delete', elem.id),
477+ default=False,
478+ required=True, readonly=False))
479+ url_fields.append(
480+ TextLine(
481+ title=u'Registry URL',
482+ __name__=u'add_url',
483+ required=False, readonly=False))
484+ image_fields.append(
485+ TextLine(
486+ title=u'Image name',
487+ __name__=u'add_image_name',
488+ required=False, readonly=False))
489+ username_fields.append(
490+ TextLine(
491+ title=u'Username',
492+ __name__=u'add_username',
493+ required=False, readonly=False))
494+ password_fields.append(
495+ Password(
496+ title=u'Password',
497+ __name__=u'add_password',
498+ required=False, readonly=False))
499+ password_fields.append(
500+ Password(
501+ title=u'Confirm password',
502+ __name__=u'add_confirm_password',
503+ required=False, readonly=False))
504+ existing_credentials.append(
505+ Choice(
506+ vocabulary='OCIRegistryCredentials',
507+ title='Choose credentials',
508+ required=False,
509+ __name__=u'existing_credentials'))
510+
511+ use_existing_creds = Bool(
512+ title=u'Use existing credentials',
513+ __name__='use_existing_creds',
514+ default=False,
515+ required=True, readonly=False)
516+
517+ custom_widget_use_existing_creds = LaunchpadRadioWidget
518+
519+ add_new_creds = Bool(
520+ title=u'Enter new credentials',
521+ __name__='add_new_creds',
522+ default=False,
523+ required=True, readonly=False)
524+
525+ custom_widget_add_new_creds = LaunchpadRadioWidget
526+
527+ self.form_fields += FormFields(*image_fields)
528+ self.form_fields += FormFields(*delete_fields)
529+ self.form_fields += FormFields(*url_fields)
530+
531+ self.form_fields += FormFields(use_existing_creds)
532+ self.form_fields += FormFields(add_new_creds)
533+
534+ self.form_fields += FormFields(*username_fields)
535+ self.form_fields += FormFields(*password_fields)
536+ self.form_fields += FormFields(*existing_credentials)
537+
538+ @property
539+ def label(self):
540+ return 'Edit OCI push rules for %s' % self.context.name
541+
542+ page_title = 'Edit OCI push rules'
543+
544+ @property
545+ def cancel_url(self):
546+ return canonical_url(self.context)
547+
548+ def getRulesWidgets(self, rule):
549+ widgets_by_name = {widget.name: widget for widget in self.widgets}
550+ image_field_name = (
551+ "field." + self._getFieldName("image_name", rule.id))
552+ delete_field_name = (
553+ "field." + self._getFieldName("delete", rule.id))
554+ return {
555+ "image_name": widgets_by_name[image_field_name],
556+ "delete": widgets_by_name[delete_field_name],
557+ }
558+
559+ def parseData(self, data):
560+ """Rearrange form data to make it easier to process."""
561+ parsed_data = {}
562+ add_image_name = data["add_image_name"]
563+ add_url = data["add_url"]
564+ add_username = data["add_username"]
565+ add_password = data["add_password"]
566+ add_confirm_password = data["add_confirm_password"]
567+ add_existing_credentials = data["existing_credentials"]
568+
569+ # parse data from the Add new rule section of the form
570+ if (add_url or add_username or add_password or
571+ add_confirm_password or add_image_name or
572+ add_existing_credentials):
573+ parsed_data.setdefault(None, {
574+ "image_name": add_image_name,
575+ "url": add_url,
576+ "username": add_username,
577+ "password": add_password,
578+ "confirm_password": add_confirm_password,
579+ "existing_credentials": data["existing_credentials"],
580+ "add_new_credentials": data["add_new_credentials"],
581+ "use_existing_credentials": data["use_existing_credentials"],
582+ "action": "add",
583+ })
584+
585+ for field_name in sorted(
586+ name for name in data if name.split(".")[0] == "image_name"):
587+ _, rule_id = self._parseFieldName(field_name)
588+ image_field_name = self._getFieldName("image_name", rule_id)
589+ delete_field_name = self._getFieldName("delete", rule_id)
590+ if data.get(delete_field_name):
591+ action = "delete"
592+ else:
593+ action = "change"
594+ parsed_data.setdefault(rule_id, {
595+ "image_name": data.get(image_field_name),
596+ "action": action,
597+ })
598+
599+ return parsed_data
600+
601+ def updatePushRulesFromData(self, parsed_data):
602+ rules_map = {
603+ rule.id: rule
604+ for rule in self.context.push_rules}
605+ for rule_id, parsed_rules in parsed_data.items():
606+ rule = rules_map.get(rule_id)
607+ action = parsed_rules["action"]
608+
609+ if action == "change":
610+ image_name = parsed_rules["image_name"]
611+ if not image_name:
612+ self.setFieldError(
613+ self._getFieldName(
614+ "image_name", rule_id),
615+ "Image name must be set.")
616+ else:
617+ removeSecurityProxy(rule).image_name = image_name
618+ elif action == "delete":
619+ removeSecurityProxy(rule).destroySelf()
620+ elif action == "add":
621+ add_data = parsed_data[None]
622+ image_name = add_data["image_name"]
623+ url = add_data["url"]
624+ password = add_data["password"]
625+ confirm_password = add_data["confirm_password"]
626+ username = add_data["username"]
627+ existing_credentials = add_data["existing_credentials"]
628+ checked_use_existing_credentials = \
629+ add_data["use_existing_credentials"]
630+ checked_add_new_credentials = add_data["add_new_credentials"]
631+
632+ if image_name:
633+ if not (checked_add_new_credentials
634+ or checked_use_existing_credentials):
635+ self.setFieldError("existing_credentials",
636+ "You must check either use existing"
637+ " or introduce new credentials for "
638+ "this rule.")
639+ return
640+ if checked_add_new_credentials \
641+ and checked_use_existing_credentials:
642+ self.setFieldError("existing_credentials",
643+ "You can either use existing "
644+ "credentials or introduce new ones "
645+ "for this rule but not both.")
646+ return
647+ if checked_use_existing_credentials:
648+ if existing_credentials:
649+ try:
650+ getUtility(IOCIPushRuleSet).new(
651+ self.context,
652+ existing_credentials,
653+ image_name)
654+ except OCIPushRuleAlreadyExists:
655+ self.setFieldError("add_image_name",
656+ "A push rule already exists"
657+ " with the same URL, image "
658+ "name and credentials.")
659+ return
660+ if checked_add_new_credentials:
661+ if url:
662+ if password == confirm_password:
663+ credentials = {
664+ 'username': username,
665+ 'password': password}
666+ try:
667+ creds = getUtility(
668+ IOCIRegistryCredentialsSet
669+ ).getOrCreate(
670+ owner=self.context.owner,
671+ url=url,
672+ credentials=credentials)
673+ except OCIRegistryCredentialsAlreadyExist:
674+ self.setFieldError(
675+ "add_url",
676+ "Credentials already exist with"
677+ " the same URL and username.")
678+ return
679+ try:
680+ getUtility(IOCIPushRuleSet).new(
681+ self.context, creds, image_name)
682+ except OCIPushRuleAlreadyExists:
683+ self.setFieldError(
684+ "add_image_name",
685+ "A push rule already exists with the "
686+ "same URL, image name, and "
687+ "credentials.")
688+ return
689+ else:
690+ self.setFieldError(
691+ "password",
692+ "Please make sure the new password matches"
693+ " the confirm password field.")
694+ else:
695+ self.setFieldError(
696+ "add_url",
697+ "Registry URL must be set.")
698+ else:
699+ self.setFieldError(
700+ "add_image_name",
701+ "Image name must be set.")
702+ else:
703+ raise AssertionError("unknown action: %s" % action)
704+
705+ @action("Save")
706+ def save(self, action, data):
707+ parsed_data = self.parseData(data)
708+ self.updatePushRulesFromData(parsed_data)
709+
710+ if not self.errors:
711+ self.request.response.addNotification("Saved push rules")
712+ self.next_url = canonical_url(self.context)
713+
714+
715 class OCIRecipeRequestBuildsView(LaunchpadFormView):
716 """A view for requesting builds of an OCI recipe."""
717
718@@ -322,6 +949,7 @@ class IOCIRecipeEditSchema(Interface):
719 "build_file",
720 "build_daily",
721 "require_virtualized",
722+ "push_rules",
723 ])
724
725
726@@ -453,6 +1081,7 @@ class OCIRecipeEditView(BaseOCIRecipeEditView, EnableProcessorsMixin):
727 "git_ref",
728 "build_file",
729 "build_daily",
730+ "push_rules",
731 )
732 custom_widget_git_ref = GitRefWidget
733
734diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
735index e25d463..0e63c8f 100644
736--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
737+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
738@@ -19,12 +19,15 @@ from fixtures import FakeLogger
739 import pytz
740 import soupmatchers
741 from testtools.matchers import (
742+ Equals,
743+ MatchesDict,
744 MatchesSetwise,
745 MatchesStructure,
746 )
747 from zope.component import getUtility
748 from zope.publisher.interfaces import NotFound
749 from zope.security.interfaces import Unauthorized
750+from zope.security.proxy import removeSecurityProxy
751 from zope.testbrowser.browser import LinkNotFoundError
752
753 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
754@@ -35,11 +38,16 @@ from lp.oci.browser.ocirecipe import (
755 OCIRecipeEditView,
756 OCIRecipeView,
757 )
758+from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
759 from lp.oci.interfaces.ocirecipe import (
760 CannotModifyOCIRecipeProcessor,
761 IOCIRecipeSet,
762 OCI_RECIPE_ALLOW_CREATE,
763 )
764+from lp.oci.interfaces.ociregistrycredentials import (
765+ IOCIRegistryCredentialsSet,
766+ )
767+from lp.oci.tests.helpers import OCIConfigHelperMixin
768 from lp.services.database.constants import UTC_NOW
769 from lp.services.features.testing import FeatureFixture
770 from lp.services.propertycache import get_property_cache
771@@ -831,6 +839,288 @@ class TestOCIRecipeRequestBuildsView(BaseTestOCIRecipeView):
772 extract_text(find_main_content(browser.contents)))
773
774
775+class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
776+ BaseTestOCIRecipeView):
777+ def setUp(self):
778+ super(TestOCIRecipeEditPushRulesView, self).setUp()
779+ self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
780+ self.distroseries = self.factory.makeDistroSeries(
781+ distribution=self.ubuntu, name="shiny", displayname="Shiny")
782+ self.architectures = []
783+ for processor, architecture in ("386", "i386"), ("amd64", "amd64"):
784+ das = self.factory.makeDistroArchSeries(
785+ distroseries=self.distroseries, architecturetag=architecture,
786+ processor=getUtility(IProcessorSet).getByName(processor))
787+ das.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
788+ self.architectures.append(das)
789+ self.useFixture(FeatureFixture({
790+ OCI_RECIPE_ALLOW_CREATE: "on",
791+ "oci.build_series.%s" % self.distroseries.distribution.name:
792+ self.distroseries.name,
793+ }))
794+ oci_project = self.factory.makeOCIProject(
795+ pillar=self.distroseries.distribution,
796+ ociprojectname="oci-project-name")
797+ self.recipe = self.factory.makeOCIRecipe(
798+ name="recipe-name", registrant=self.person, owner=self.person,
799+ oci_project=oci_project)
800+
801+ self.setConfig()
802+
803+ def test_edit_oci_push_rules(self):
804+ url = unicode(self.factory.getUniqueURL())
805+ credentials = {'username': 'foo', 'password': 'bar'}
806+ registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
807+ owner=self.person,
808+ url=url,
809+ credentials=credentials)
810+ image_name = self.factory.getUniqueUnicode()
811+ push_rule = getUtility(IOCIPushRuleSet).new(
812+ recipe=self.recipe,
813+ registry_credentials=registry_credentials,
814+ image_name=image_name)
815+ browser = self.getViewBrowser(self.recipe, user=self.person)
816+ browser.getLink("Edit push rules").click()
817+ # assert image name is displayed correctly
818+ with person_logged_in(self.person):
819+ self.assertEqual(image_name, browser.getControl(
820+ name="field.image_name.%d" % push_rule.id).value)
821+
822+ # assert image name is required
823+ with person_logged_in(self.person):
824+ browser.getControl(
825+ name="field.image_name.%d" % push_rule.id).value = ""
826+ browser.getControl("Save").click()
827+ self.assertIn("Required input is missing", browser.contents)
828+
829+ # set image name to valid string
830+ with person_logged_in(self.person):
831+ browser.getControl(
832+ name="field.image_name.%d" % push_rule.id).value = "testimage1"
833+ browser.getControl("Save").click()
834+ # and assert model changed
835+ self.assertEqual(
836+ removeSecurityProxy(push_rule).image_name, "testimage1")
837+
838+ def test_delete_oci_push_rules(self):
839+ url = unicode(self.factory.getUniqueURL())
840+ credentials = {'username': 'foo', 'password': 'bar'}
841+ registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
842+ owner=self.person,
843+ url=url,
844+ credentials=credentials)
845+ image_name = self.factory.getUniqueUnicode()
846+ push_rule = getUtility(IOCIPushRuleSet).new(
847+ recipe=self.recipe,
848+ registry_credentials=registry_credentials,
849+ image_name=image_name)
850+ browser = self.getViewBrowser(self.recipe, user=self.person)
851+ browser.getLink("Edit push rules").click()
852+ with person_logged_in(self.person):
853+ browser.getControl(
854+ name="field.delete.%d" % push_rule.id).value = True
855+ browser.getControl("Save").click()
856+
857+ with person_logged_in(self.person):
858+ self.assertIsNone(removeSecurityProxy(
859+ getUtility(IOCIPushRuleSet).getByID(push_rule.id)))
860+
861+ def test_add_oci_push_rules_validations(self):
862+ # Test add new rule works when there are
863+ # no rules in the DB
864+ browser = self.getViewBrowser(self.recipe, user=self.person)
865+ browser.getLink("Edit push rules").click()
866+
867+ # Save does not error if there are no changes
868+ # on the form
869+ browser.getControl("Save").click()
870+ self.assertIn("Saved push rules", browser.contents)
871+
872+ # Only with image name we fail with Registry URL must be set
873+ browser.getLink("Edit push rules").click()
874+ browser.getControl(
875+ name="field.add_image_name").value = "imagename1"
876+ browser.getControl(
877+ name="field.add_new_credentials").value = True
878+ browser.getControl("Save").click()
879+ self.assertIn("Registry URL must be set", browser.contents)
880+
881+ # Either Use existing credentials or Add new
882+ # must be checked
883+ browser.getControl(
884+ name="field.add_new_credentials").value = False
885+ browser.getControl(
886+ name="field.use_existing_credentials").value = False
887+ browser.getControl("Save").click()
888+ self.assertIn("You must check either use existing or introduce"
889+ " new credentials for this rule.", browser.contents)
890+
891+ # Both Use existing credentials And Add new
892+ # are checked, we notify the user to check only one
893+ browser.getControl(
894+ name="field.add_new_credentials").value = True
895+ browser.getControl(
896+ name="field.use_existing_credentials").value = True
897+ browser.getControl("Save").click()
898+ self.assertIn("You can either use existing credentials or introduce"
899+ " new ones for this rule but not both.",
900+ browser.contents)
901+
902+ # No image name entered on the form
903+ # we assume user is only editing and we allow
904+ # Save on the form
905+ browser.getControl(
906+ name="field.add_image_name").value = ""
907+ browser.getControl("Save").click()
908+ self.assertIn("Saved push rules", browser.contents)
909+
910+ def test_add_oci_push_rules(self):
911+ # Image name and Registry URL will create an empty Credentials object
912+ # and a valid push rule based on the empty creds
913+
914+ url = unicode(self.factory.getUniqueURL())
915+ browser = self.getViewBrowser(self.recipe, user=self.person)
916+ browser.getLink("Edit push rules").click()
917+ browser.getControl(
918+ name="field.add_image_name").value = "imagename1"
919+ browser.getControl(
920+ name="field.add_url").value = url
921+ browser.getControl(
922+ name="field.add_new_credentials").value = True
923+ browser.getControl("Save").click()
924+ with person_logged_in(self.person):
925+ rules = list(removeSecurityProxy(
926+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
927+ self.assertEqual(len(rules), 1)
928+ rule = rules[0]
929+ self.assertIsNotNone(rule.registry_credentials)
930+ self.assertEqual(u'imagename1', rule.image_name)
931+ self.assertEqual(url, rule.registry_url)
932+ self.assertEqual(url,
933+ rule.registry_credentials.url)
934+ self.assertEqual(
935+ None,
936+ rule.registry_credentials.username)
937+ with person_logged_in(self.person):
938+ self.assertThat(
939+ rule.registry_credentials.getCredentials(),
940+ MatchesDict(
941+ {"password": Equals(None)}))
942+
943+ # Previously added registry credentials can now be chosen
944+ # from the radio widget when adding a new rule
945+ browser.getLink("Edit push rules").click()
946+
947+ browser.getControl(
948+ name="field.use_existing_credentials").value = True
949+ browser.getControl(
950+ name="field.add_image_name").value = "imagename1"
951+ browser.getControl(
952+ name="field.existing_credentials").value = (
953+ browser.getControl(
954+ name="field.existing_credentials").options[1].strip())
955+ browser.getControl("Save").click()
956+ self.assertIn(
957+ "A push rule already exists with the same URL, "
958+ "image name and credentials.", browser.contents)
959+
960+ # We display correctly the radio buttons widget when
961+ # username is empty in registry credentials and
962+ # allow correctly adding new rule based on it
963+ browser.getControl(
964+ name="field.use_existing_credentials").value = True
965+ browser.getControl(
966+ name="field.add_image_name").value = "imagename2"
967+ browser.getControl("Save").click()
968+ with person_logged_in(self.person):
969+ rules = list(removeSecurityProxy(
970+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
971+ self.assertEqual(len(rules), 2)
972+ rule = rules[1]
973+ self.assertIsNotNone(rule.registry_credentials)
974+ self.assertEqual(u'imagename2', rule.image_name)
975+ self.assertEqual(url, rule.registry_url)
976+ self.assertEqual(
977+ url,
978+ rule.registry_credentials.url)
979+ self.assertEqual(
980+ None,
981+ rule.registry_credentials.username)
982+ with person_logged_in(self.person):
983+ self.assertThat(
984+ rule.registry_credentials.getCredentials(),
985+ MatchesDict({
986+ "password": Equals(None)}))
987+
988+ browser.getLink("Edit push rules").click()
989+ browser.getControl(
990+ name="field.add_new_credentials").value = True
991+ browser.getControl(
992+ name="field.add_image_name").value = "imagename3"
993+ browser.getControl(
994+ name="field.add_url").value = url
995+ browser.getControl(
996+ name="field.add_username").value = "username"
997+ browser.getControl(
998+ name="field.add_password").value = "password"
999+ browser.getControl(
1000+ name="field.add_confirm_password").value = "password"
1001+ browser.getControl("Save").click()
1002+ with person_logged_in(self.person):
1003+ rules = list(removeSecurityProxy(
1004+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
1005+ self.assertEqual(len(rules), 3)
1006+ rule = rules[2]
1007+ self.assertIsNotNone(rule.registry_credentials)
1008+ self.assertEqual("imagename3", rule.image_name)
1009+ self.assertEqual(url, rule.registry_url)
1010+ self.assertEqual(url,
1011+ rule.registry_credentials.url)
1012+ self.assertEqual(
1013+ "username",
1014+ rule.registry_credentials.username)
1015+ with person_logged_in(self.person):
1016+ self.assertThat(
1017+ rule.registry_credentials.getCredentials(),
1018+ MatchesDict(
1019+ {"username": Equals("username"),
1020+ "password": Equals("password")}))
1021+
1022+ def test_add_oci_registry_creds(self):
1023+ url = unicode(self.factory.getUniqueURL())
1024+ credentials = {'username': 'foo', 'password': 'bar'}
1025+ image_name = self.factory.getUniqueUnicode()
1026+ registry_credentials = getUtility(IOCIRegistryCredentialsSet).new(
1027+ owner=self.person,
1028+ url=url,
1029+ credentials=credentials)
1030+ getUtility(IOCIPushRuleSet).new(
1031+ recipe=self.recipe,
1032+ registry_credentials=registry_credentials,
1033+ image_name=image_name)
1034+ browser = self.getViewBrowser(self.recipe, user=self.person)
1035+ browser.getLink("Edit push rules").click()
1036+ browser.getLink("Edit OCI registry credentials").click()
1037+
1038+ browser.getControl(name="field.add_url").value = url
1039+ browser.getControl(name="field.add_username").value = "new_username"
1040+ browser.getControl(name="field.add_password").value = "password"
1041+ browser.getControl(name="field.add_confirm_password"
1042+ ).value = "password"
1043+
1044+ browser.getControl("Save").click()
1045+ with person_logged_in(self.person):
1046+ creds = list(getUtility(
1047+ IOCIRegistryCredentialsSet).findByOwner(
1048+ self.person))
1049+
1050+ self.assertEqual(url, creds[1].url)
1051+ self.assertThat(
1052+ removeSecurityProxy(creds[1]).getCredentials(),
1053+ MatchesDict({"username": Equals("new_username"),
1054+ "password": Equals("password")}))
1055+
1056+
1057 class TestOCIProjectRecipesView(BaseTestOCIRecipeView):
1058 def setUp(self):
1059 super(TestOCIProjectRecipesView, self).setUp()
1060diff --git a/lib/lp/oci/javascript/ocirecipe.edit.js b/lib/lp/oci/javascript/ocirecipe.edit.js
1061new file mode 100644
1062index 0000000..914147f
1063--- /dev/null
1064+++ b/lib/lp/oci/javascript/ocirecipe.edit.js
1065@@ -0,0 +1,77 @@
1066+/* Copyright 2015-2020 Canonical Ltd. This software is licensed under the
1067+ * GNU Affero General Public License version 3 (see the file LICENSE).
1068+ *
1069+ * @module Y.lp.oci.ocirecipe.edit
1070+ * @requires node, DOM
1071+ */
1072+YUI.add('lp.oci.ocirecipe.edit', function(Y) {
1073+ Y.log('loading lp.oci.ocirecipe.edit');
1074+ var module = Y.namespace('lp.oci.ocirecipe.edit');
1075+
1076+ module.set_enabled = function(field_id, is_enabled) {
1077+ var field = Y.DOM.byId(field_id);
1078+ if (field !== null) {
1079+ field.disabled = !is_enabled;
1080+ }
1081+ };
1082+
1083+ module.onclick_add_new_creds = function(e) {
1084+ var add_new_creds = Y.one(
1085+ 'input[name="field.add_new_creds"]')
1086+ var use_existing_creds = Y.one(
1087+ 'input[name="field.use_existing_creds"]')
1088+
1089+ if (add_new_creds.get('checked') === true) {
1090+ use_existing_creds.set('checked', false);
1091+ Y.one('[id="field.existing_credentials"]').set('disabled', true);
1092+ Y.one('[id="field.add_url"]').set('disabled', false);
1093+ Y.one('[id="field.add_username"]').set('disabled', false);
1094+ Y.one('[id="field.add_password"]').set('disabled', false);
1095+ Y.one('[id="field.add_confirm_password"]').set('disabled', false);
1096+
1097+ }
1098+ else{
1099+ use_existing_creds.set('checked', true);
1100+ Y.one('[id="field.existing_credentials"]').set('disabled', false);
1101+ Y.one('[id="field.add_url"]').set('disabled', true);
1102+ Y.one('[id="field.add_username"]').set('disabled', true);
1103+ Y.one('[id="field.add_password"]').set('disabled', true);
1104+ Y.one('[id="field.add_confirm_password"]').set('disabled', true);
1105+
1106+ }
1107+ };
1108+
1109+ module.use_existing_creds = function(e) {
1110+ var add_new_creds = Y.one(
1111+ 'input[name="field.add_new_creds"]')
1112+ var use_existing_creds = Y.one(
1113+ 'input[name="field.use_existing_creds"]')
1114+
1115+ if (use_existing_creds.get('checked') === true) {
1116+ add_new_creds.set('checked', false);
1117+ Y.one('[id="field.existing_credentials"]').set('disabled', false);
1118+ Y.one('[id="field.add_url"]').set('disabled', true);
1119+ Y.one('[id="field.add_username"]').set('disabled', true);
1120+ Y.one('[id="field.add_password"]').set('disabled', true);
1121+ Y.one('[id="field.add_confirm_password"]').set('disabled', true);
1122+
1123+ }
1124+ else{
1125+ add_new_creds.set('checked', true);
1126+ Y.one('[id="field.existing_credentials"]').set('disabled', true);
1127+ Y.one('[id="field.add_url"]').set('disabled', false);
1128+ Y.one('[id="field.add_username"]').set('disabled', false);
1129+ Y.one('[id="field.add_password"]').set('disabled', false);
1130+ Y.one('[id="field.add_confirm_password"]').set('disabled', false);
1131+
1132+ }
1133+ };
1134+
1135+ module.setup = function() {
1136+ Y.all('input[name="field.use_existing_creds"]').on('click', module.use_existing_creds);
1137+ Y.all('input[name="field.add_new_creds"]').on('click', module.onclick_add_new_creds);
1138+ // Set the initial state.
1139+ module.onclick_add_new_creds();
1140+ module.use_existing_creds();
1141+ };
1142+}, '0.1', {'requires': ['node', 'DOM']});
1143diff --git a/lib/lp/oci/templates/ocirecipe-edit-credentials.pt b/lib/lp/oci/templates/ocirecipe-edit-credentials.pt
1144new file mode 100644
1145index 0000000..89483da
1146--- /dev/null
1147+++ b/lib/lp/oci/templates/ocirecipe-edit-credentials.pt
1148@@ -0,0 +1,59 @@
1149+<html
1150+ xmlns="http://www.w3.org/1999/xhtml"
1151+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1152+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1153+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1154+ metal:use-macro="view/macro:page/main_only"
1155+ i18n:domain="launchpad">
1156+<body>
1157+
1158+<div metal:fill-slot="main">
1159+ <div metal:use-macro="context/@@launchpad_form/form">
1160+ <metal:formbody fill-slot="widgets">
1161+
1162+ <table class="form">
1163+ <tr tal:repeat="credentials view/oci_registry_credentials">
1164+ <tal:credentials_widgets
1165+ define="credentials_widgets python:view.getCredentialsWidgets(credentials);
1166+ parity python:'even' if repeat['credentials'].even() else 'odd'">
1167+ <td tal:define="widget nocall:credentials_widgets/url">
1168+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1169+ </td>
1170+ <td tal:define="widget nocall:credentials_widgets/owner">
1171+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1172+ </td>
1173+ <td tal:define="widget nocall:credentials_widgets/username">
1174+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1175+ </td>
1176+ <td tal:define="widget nocall:credentials_widgets/password">
1177+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1178+ </td>
1179+ <td tal:define="widget nocall:credentials_widgets/confirm_password">
1180+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1181+ </td>
1182+ <td tal:define="widget nocall:credentials_widgets/delete">
1183+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1184+ </td>
1185+ </tal:credentials_widgets>
1186+ </tr>
1187+ <tr>
1188+ <td tal:define="widget nocall:view/widgets/add_url">
1189+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1190+ </td>
1191+ <td tal:define="widget nocall:view/widgets/add_username">
1192+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1193+ </td>
1194+ <td tal:define="widget nocall:view/widgets/add_password">
1195+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1196+ </td>
1197+ <td tal:define="widget nocall:view/widgets/add_confirm_password">
1198+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1199+ </td>
1200+ </tr>
1201+ </table>
1202+
1203+ </metal:formbody>
1204+ </div>
1205+</div>
1206+</body>
1207+</html>
1208diff --git a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
1209new file mode 100644
1210index 0000000..742ebe9
1211--- /dev/null
1212+++ b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
1213@@ -0,0 +1,112 @@
1214+<html
1215+ xmlns="http://www.w3.org/1999/xhtml"
1216+ xmlns:tal="http://xml.zope.org/namespaces/tal"
1217+ xmlns:metal="http://xml.zope.org/namespaces/metal"
1218+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
1219+ metal:use-macro="view/macro:page/main_only"
1220+ i18n:domain="launchpad">
1221+<body>
1222+
1223+<metal:block fill-slot="head_epilogue">
1224+ <style type="text/css">
1225+ .subordinate {
1226+ margin: 0.5em 0 0.5em 4em;
1227+ }
1228+ </style>
1229+</metal:block>
1230+
1231+<div metal:fill-slot="main">
1232+ <div metal:use-macro="context/@@launchpad_form/form">
1233+ <metal:formbody fill-slot="widgets">
1234+ <p condition="context/required:launchpad.Edit">
1235+ <a class="sprite edit" tal:attributes="href context/fmt:url/+edit-credentials">Edit OCI registry credentials</a>
1236+ </p>
1237+ <table class="form">
1238+ <tr>
1239+ <td><label>OCI credentials</label></td>
1240+ <td><label>Image name</label></td>
1241+ <td><label>Delete rule</label></td>
1242+ </tr>
1243+ <tr tal:repeat="rule context/push_rules">
1244+ <tal:rules_widgets
1245+ define="rules_widgets python:view.getRulesWidgets(rule);
1246+ parity python:'even' if repeat['rule'].even() else 'odd'">
1247+ <td>
1248+ <tal:editable condition="context/required:launchpad.Edit">
1249+ <a tal:attributes="href context/fmt:url/+edit-credentials">Edit credentials</a>
1250+ </tal:editable>
1251+ </td>
1252+ <td tal:define="widget nocall:rules_widgets/image_name">
1253+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1254+ </td>
1255+ <td tal:define="widget nocall:rules_widgets/delete">
1256+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1257+ </td>
1258+ </tal:rules_widgets>
1259+ </tr>
1260+ <tr>
1261+ <td>
1262+ <h2>Add new push rule</h2>
1263+ </td>
1264+ </tr>
1265+ <tr>
1266+ <td tal:define="widget nocall:view/widgets/add_image_name">
1267+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
1268+ </td>
1269+ </tr>
1270+ <tr>
1271+ <td>
1272+ <tal:widget define="widget nocall:view/widgets/use_existing_creds">
1273+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
1274+ </tal:widget>
1275+ </td>
1276+ </tr>
1277+ <tr>
1278+ <td>
1279+ <table class="subordinate">
1280+ <tal:widget define="widget nocall:view/widgets/existing_credentials">
1281+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
1282+ </tal:widget>
1283+ </table>
1284+ </td>
1285+ </tr>
1286+ <tr>
1287+ <td>
1288+ <tal:widget define="widget nocall:view/widgets/add_new_creds">
1289+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
1290+ </tal:widget>
1291+ <td>
1292+ </tr>
1293+ <tr>
1294+ <td>
1295+ <table class="subordinate">
1296+ <tal:widget define="widget nocall:view/widgets/add_url">
1297+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
1298+ </tal:widget>
1299+ <tal:widget define="widget nocall:view/widgets/add_username">
1300+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
1301+ </tal:widget>
1302+ <tal:widget define="widget nocall:view/widgets/add_password">
1303+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
1304+ </tal:widget>
1305+ <tal:widget define="widget nocall:view/widgets/add_confirm_password">
1306+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
1307+ </tal:widget>
1308+ </table>
1309+ </td>
1310+ </tr>
1311+ </table>
1312+ </metal:formbody>
1313+</div>
1314+
1315+ <script type="text/javascript">
1316+ LPJS.use('lp.oci.ocirecipe.edit', function(Y) {
1317+ Y.on('domready', function(e) {
1318+ Y.lp.oci.ocirecipe.edit.setup();
1319+ }, window);
1320+ });
1321+ </script>
1322+
1323+</div>
1324+</body>
1325+</html>
1326diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt
1327index cf7c549..88a95b3 100644
1328--- a/lib/lp/oci/templates/ocirecipe-index.pt
1329+++ b/lib/lp/oci/templates/ocirecipe-index.pt
1330@@ -143,6 +143,11 @@
1331 This OCI recipe has no push rules defined yet.
1332 </p>
1333
1334+ <div tal:define="link context/menu:context/edit_push_rules"
1335+ tal:condition="link/enabled">
1336+ <tal:edit-push-rules replace="structure link/fmt:link"/>
1337+ </div>
1338+
1339 </div>
1340
1341 </body>
1342diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
1343index aa1fae3..69cc049 100644
1344--- a/lib/lp/oci/vocabularies.py
1345+++ b/lib/lp/oci/vocabularies.py
1346@@ -8,10 +8,15 @@ from __future__ import absolute_import, print_function, unicode_literals
1347 __metaclass__ = type
1348 __all__ = []
1349
1350+from zope.component import getUtility
1351 from zope.interface import implementer
1352 from zope.schema.vocabulary import SimpleTerm
1353
1354+from lp.oci.interfaces.ociregistrycredentials import (
1355+ IOCIRegistryCredentialsSet,
1356+ )
1357 from lp.oci.model.ocirecipe import OCIRecipe
1358+from lp.oci.model.ociregistrycredentials import OCIRegistryCredentials
1359 from lp.services.webapp.vocabulary import (
1360 IHugeVocabulary,
1361 StormVocabularyBase,
1362@@ -35,6 +40,51 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
1363 return len(self.context.getAllowedArchitectures())
1364
1365
1366+class OCIRegistryCredentialsVocabulary(StormVocabularyBase):
1367+
1368+ _table = OCIRegistryCredentials
1369+
1370+ def toTerm(self, obj):
1371+ if obj.username:
1372+ token = "%s %s" % (
1373+ obj.url,
1374+ obj.username)
1375+ else:
1376+ token = obj.url
1377+
1378+ return SimpleTerm(obj, token)
1379+
1380+ @property
1381+ def _entries(self):
1382+ return list(getUtility(
1383+ IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
1384+
1385+ def __contains__(self, value):
1386+ """See `IVocabulary`."""
1387+ return value in self._entries
1388+
1389+ def __iter__(self):
1390+ for obj in self._entries:
1391+ yield self.toTerm(obj)
1392+
1393+ def __len__(self):
1394+ return len(self._entries)
1395+
1396+ def getTermByToken(self, token):
1397+ """See `IVocabularyTokenized`."""
1398+ try:
1399+ if ' ' in token:
1400+ url, username = token.split(' ')
1401+ else:
1402+ username = None
1403+ url = token
1404+ for obj in self._entries:
1405+ if obj.url == url and obj.username == username:
1406+ return self.toTerm(obj)
1407+ except ValueError:
1408+ raise LookupError(token)
1409+
1410+
1411 @implementer(IHugeVocabulary)
1412 class OCIRecipeVocabulary(StormVocabularyBase):
1413 """All OCI Recipes of a given OCI project."""
1414diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
1415index 1a6b75c..24a57d8 100644
1416--- a/lib/lp/oci/vocabularies.zcml
1417+++ b/lib/lp/oci/vocabularies.zcml
1418@@ -16,6 +16,16 @@
1419 </class>
1420
1421 <securedutility
1422+ name="OCIRegistryCredentials"
1423+ component="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary"
1424+ provides="zope.schema.interfaces.IVocabularyFactory">
1425+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
1426+ </securedutility>
1427+
1428+ <class class="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary">
1429+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
1430+ </class>
1431+ <securedutility
1432 name="OCIRecipe"
1433 component="lp.oci.vocabularies.OCIRecipeVocabulary"
1434 provides="zope.schema.interfaces.IVocabularyFactory">

Subscribers

People subscribed via source and target branches

to status/vote changes: