Merge ~ilasc/launchpad:oci-recipe-push-rules-edit into launchpad:master
- Git
- lp:~ilasc/launchpad
- oci-recipe-push-rules-edit
- Merge into master
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) |
Related bugs: |
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
- 6ca31f2... by Ioana Lasc
-
Format imports
Thiago F. Pappacena (pappacena) wrote : | # |
- 1b43a41... by Ioana Lasc
-
Address code review comments
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
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.
b = document.
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:/
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
Ioana Lasc (ilasc) wrote : | # |
This is now split between https:/
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
1 | diff --git a/lib/lp/oci/browser/configure.zcml b/lib/lp/oci/browser/configure.zcml |
2 | index 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" |
24 | diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py |
25 | index 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 | |
734 | diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py |
735 | index 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() |
1060 | diff --git a/lib/lp/oci/javascript/ocirecipe.edit.js b/lib/lp/oci/javascript/ocirecipe.edit.js |
1061 | new file mode 100644 |
1062 | index 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']}); |
1143 | diff --git a/lib/lp/oci/templates/ocirecipe-edit-credentials.pt b/lib/lp/oci/templates/ocirecipe-edit-credentials.pt |
1144 | new file mode 100644 |
1145 | index 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> |
1208 | diff --git a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt |
1209 | new file mode 100644 |
1210 | index 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> |
1326 | diff --git a/lib/lp/oci/templates/ocirecipe-index.pt b/lib/lp/oci/templates/ocirecipe-index.pt |
1327 | index 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> |
1342 | diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py |
1343 | index 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.""" |
1414 | diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml |
1415 | index 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"> |
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.