launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #25104
[Merge] ~ilasc/launchpad:oci-recipe-push-rules-add into launchpad:master
Ioana Lasc has proposed merging ~ilasc/launchpad:oci-recipe-push-rules-add into launchpad:master.
Commit message:
Add the add push rule section to edit rules page
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~ilasc/launchpad/+git/launchpad/+merge/388458
On the edit oci push rules page we now have the "add new rule" section.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilasc/launchpad:oci-recipe-push-rules-add into launchpad:master.
diff --git a/lib/lp/oci/browser/ocirecipe.py b/lib/lp/oci/browser/ocirecipe.py
index cac47ac..7b7cc61 100644
--- a/lib/lp/oci/browser/ocirecipe.py
+++ b/lib/lp/oci/browser/ocirecipe.py
@@ -30,7 +30,9 @@ from zope.schema import (
Bool,
Choice,
List,
+ Password,
TextLine,
+ ValidationError,
)
from lp.app.browser.launchpadform import (
@@ -44,7 +46,10 @@ from lp.app.errors import UnexpectedFormData
from lp.app.widgets.itemswidgets import LabeledMultiCheckBoxWidget
from lp.buildmaster.interfaces.processor import IProcessorSet
from lp.code.browser.widgets.gitref import GitRefWidget
-from lp.oci.interfaces.ocipushrule import IOCIPushRuleSet
+from lp.oci.interfaces.ocipushrule import (
+ IOCIPushRuleSet,
+ OCIPushRuleAlreadyExists,
+ )
from lp.oci.interfaces.ocirecipe import (
IOCIRecipe,
IOCIRecipeSet,
@@ -55,6 +60,10 @@ from lp.oci.interfaces.ocirecipe import (
OCIRecipeFeatureDisabled,
)
from lp.oci.interfaces.ocirecipebuild import IOCIRecipeBuildSet
+from lp.oci.interfaces.ociregistrycredentials import (
+ IOCIRegistryCredentialsSet,
+ OCIRegistryCredentialsAlreadyExist,
+ )
from lp.services.features import getFeatureFlag
from lp.services.helpers import english_list
from lp.services.propertycache import cachedproperty
@@ -253,7 +262,7 @@ def new_builds_notification_text(builds, already_pending=None):
return builds_text
-class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
+class OCIRecipeEditPushRulesView(LaunchpadFormView):
"""View for +ocirecipe-edit-push-rules.pt."""
class schema(Interface):
@@ -296,10 +305,14 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
def setUpFields(self):
"""See `LaunchpadEditFormView`."""
- LaunchpadEditFormView.setUpFields(self)
+ LaunchpadFormView.setUpFields(self)
image_fields = []
+ username_fields = []
+ password_fields = []
+ url_fields = []
delete_fields = []
+ existing_credentials = []
creds = []
for elem in list(self.context.push_rules):
image_fields.append(
@@ -326,11 +339,61 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
__name__=self._getFieldName('url', elem.id),
default=elem.registry_credentials.url,
required=True, readonly=True))
+ url_fields.append(
+ TextLine(
+ title=u'Registry URL',
+ __name__=u'add_url',
+ required=False, readonly=False))
+ image_fields.append(
+ TextLine(
+ title=u'Image name',
+ __name__=u'add_image_name',
+ required=False, readonly=False))
+ username_fields.append(
+ TextLine(
+ title=u'Username',
+ __name__=u'add_username',
+ required=False, readonly=False))
+ password_fields.append(
+ Password(
+ title=u'Password',
+ __name__=u'add_password',
+ required=False, readonly=False))
+ password_fields.append(
+ Password(
+ title=u'Confirm password',
+ __name__=u'add_confirm_password',
+ required=False, readonly=False))
+ existing_credentials.append(
+ Choice(
+ vocabulary='OCIRegistryCredentials',
+ title='Choose credentials',
+ required=False,
+ __name__=u'existing_credentials'))
+
+ use_existing_creds = Bool(
+ title=u'Use existing credentials',
+ __name__='use_existing_creds',
+ default=False,
+ required=True, readonly=False)
+
+ add_new_creds = Bool(
+ title=u'Enter new credentials',
+ __name__='add_new_creds',
+ default=False,
+ required=True, readonly=False)
self.form_fields += FormFields(*image_fields)
self.form_fields += FormFields(*creds)
self.form_fields += FormFields(*delete_fields)
+ self.form_fields += FormFields(*url_fields)
+ self.form_fields += FormFields(use_existing_creds)
+ self.form_fields += FormFields(add_new_creds)
+ self.form_fields += FormFields(*username_fields)
+ self.form_fields += FormFields(*password_fields)
+ self.form_fields += FormFields(*existing_credentials)
+
@property
def label(self):
return 'Edit OCI push rules for %s' % self.context.name
@@ -362,7 +425,29 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
def parseData(self, data):
"""Rearrange form data to make it easier to process."""
parsed_data = {}
-
+ add_image_name = data.get("add_image_name")
+ add_url = data.get("add_url")
+ add_username = data.get("add_username")
+ add_password = data.get("add_password")
+ add_confirm_password = data.get("add_confirm_password")
+ add_existing_credentials = data.get("existing_credentials")
+
+ # parse data from the Add new rule section of the form
+ if (add_url or add_username or add_password or
+ add_confirm_password or add_image_name or
+ add_existing_credentials):
+ parsed_data.setdefault(None, {
+ "image_name": add_image_name,
+ "url": add_url,
+ "username": add_username,
+ "password": add_password,
+ "confirm_password": add_confirm_password,
+ "existing_credentials": data["existing_credentials"],
+ "add_new_creds": data["add_new_creds"],
+ "use_existing_creds": data["use_existing_creds"],
+ "action": "add",
+ })
+ # parse data from the Edit existing rule section of the form
for field_name in sorted(
name for name in data if name.split(".")[0] == "image_name"):
_, rule_id = self._parseFieldName(field_name)
@@ -379,6 +464,78 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
return parsed_data
+ def addNewRule(self, parsed_data):
+ add_data = parsed_data[None]
+ image_name = add_data.get("image_name")
+ url = add_data.get("url")
+ password = add_data.get("password")
+ confirm_password = add_data.get("confirm_password")
+ username = add_data.get("username")
+ existing_credentials = add_data.get("existing_credentials")
+ checked_use_existing_credentials = add_data.get("use_existing_creds")
+ checked_add_new_credentials = add_data.get("add_new_creds")
+
+ if image_name:
+ if checked_use_existing_credentials:
+ if existing_credentials:
+ try:
+ getUtility(IOCIPushRuleSet).new(
+ self.context,
+ existing_credentials,
+ image_name)
+ except OCIPushRuleAlreadyExists:
+ self.setFieldError("add_image_name",
+ "A push rule already exists"
+ " with the same URL, image "
+ "name and credentials.")
+ return
+ if checked_add_new_credentials:
+ if url:
+ if password == confirm_password:
+ credentials = {
+ 'username': username,
+ 'password': password}
+ try:
+ creds = getUtility(
+ IOCIRegistryCredentialsSet
+ ).getOrCreate(
+ owner=self.context.owner,
+ url=url,
+ credentials=credentials)
+ except OCIRegistryCredentialsAlreadyExist:
+ self.setFieldError(
+ "add_url",
+ "Credentials already exist with"
+ " the same URL and username.")
+ return
+ except ValidationError:
+ self.setFieldError(
+ "add_url", "Not a valid URL.")
+ return
+ try:
+ getUtility(IOCIPushRuleSet).new(
+ self.context, creds, image_name)
+ except OCIPushRuleAlreadyExists:
+ self.setFieldError(
+ "add_image_name",
+ "A push rule already exists with the "
+ "same URL, image name, and "
+ "credentials.")
+ return
+ else:
+ self.setFieldError(
+ "password",
+ "Please make sure the new password matches"
+ " the confirm password field.")
+ else:
+ self.setFieldError(
+ "add_url",
+ "Registry URL must be set.")
+ else:
+ self.setFieldError(
+ "add_image_name",
+ "Image name must be set.")
+
def updatePushRulesFromData(self, parsed_data):
rules_map = {
rule.id: rule
@@ -399,6 +556,8 @@ class OCIRecipeEditPushRulesView(LaunchpadEditFormView):
rule.setNewImageName(image_name)
elif action == "delete":
rule.destroySelf()
+ elif action == "add":
+ self.addNewRule(parsed_data)
else:
raise AssertionError("unknown action: %s" % action)
diff --git a/lib/lp/oci/browser/tests/test_ocirecipe.py b/lib/lp/oci/browser/tests/test_ocirecipe.py
index 24a9c6b..5ae9ff6 100644
--- a/lib/lp/oci/browser/tests/test_ocirecipe.py
+++ b/lib/lp/oci/browser/tests/test_ocirecipe.py
@@ -27,6 +27,7 @@ from testtools.matchers import (
from zope.component import getUtility
from zope.publisher.interfaces import NotFound
from zope.security.interfaces import Unauthorized
+from zope.security.proxy import removeSecurityProxy
from zope.testbrowser.browser import LinkNotFoundError
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
@@ -957,6 +958,146 @@ class TestOCIRecipeEditPushRulesView(OCIConfigHelperMixin,
self.assertIsNone(
getUtility(IOCIPushRuleSet).getByID(push_rule.id))
+ def test_add_oci_push_rules_validations(self):
+ # Test add new rule works when there are
+ # no rules in the DB
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+
+ # Save does not error if there are no changes
+ # on the form
+ browser.getControl("Save").click()
+ self.assertIn("Saved push rules", browser.contents)
+
+ # Only with image name we fail with Registry URL must be set
+ browser.getLink("Edit push rules").click()
+ browser.getControl(
+ name="field.add_image_name").value = "imagename1"
+ browser.getControl(
+ name="field.add_new_creds").value = True
+ browser.getControl("Save").click()
+ self.assertIn("Registry URL must be set", browser.contents)
+
+ # No image name entered on the form
+ # we assume user is only editing and we allow
+ # Save on the form
+ browser.getControl(
+ name="field.add_image_name").value = ""
+ browser.getControl("Save").click()
+ self.assertIn("Saved push rules", browser.contents)
+
+ def test_add_oci_push_rules(self):
+ # Image name and Registry URL will create an empty Credentials object
+ # and a valid push rule based on the empty creds
+
+ url = unicode(self.factory.getUniqueURL())
+ browser = self.getViewBrowser(self.recipe, user=self.person)
+ browser.getLink("Edit push rules").click()
+ browser.getControl(
+ name="field.add_image_name").value = "imagename1"
+ browser.getControl(
+ name="field.add_url").value = url
+ browser.getControl(
+ name="field.add_new_creds").value = True
+ browser.getControl("Save").click()
+ with person_logged_in(self.person):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+ self.assertEqual(len(rules), 1)
+ rule = rules[0]
+ self.assertIsNotNone(rule.registry_credentials)
+ self.assertEqual(u'imagename1', rule.image_name)
+ self.assertEqual(url, rule.registry_url)
+ self.assertEqual(url,
+ rule.registry_credentials.url)
+ self.assertEqual(
+ None,
+ rule.registry_credentials.username)
+ with person_logged_in(self.person):
+ self.assertThat(
+ rule.registry_credentials.getCredentials(),
+ MatchesDict(
+ {"password": Equals(None)}))
+
+ # Previously added registry credentials can now be chosen
+ # from the radio widget when adding a new rule
+ browser.getLink("Edit push rules").click()
+
+ browser.getControl(
+ name="field.use_existing_creds").value = True
+ browser.getControl(
+ name="field.add_image_name").value = "imagename1"
+ browser.getControl(
+ name="field.existing_credentials").value = (
+ browser.getControl(
+ name="field.existing_credentials").options[1].strip())
+ browser.getControl("Save").click()
+ self.assertIn(
+ "A push rule already exists with the same URL, "
+ "image name and credentials.", browser.contents)
+
+ # We display correctly the radio buttons widget when
+ # username is empty in registry credentials and
+ # allow correctly adding new rule based on it
+ browser.getControl(
+ name="field.use_existing_creds").value = True
+ browser.getControl(
+ name="field.add_image_name").value = "imagename2"
+ browser.getControl("Save").click()
+ with person_logged_in(self.person):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+ self.assertEqual(len(rules), 2)
+ rule = rules[1]
+ self.assertIsNotNone(rule.registry_credentials)
+ self.assertEqual(u'imagename2', rule.image_name)
+ self.assertEqual(url, rule.registry_url)
+ self.assertEqual(
+ url,
+ rule.registry_credentials.url)
+ self.assertEqual(
+ None,
+ rule.registry_credentials.username)
+ with person_logged_in(self.person):
+ self.assertThat(
+ rule.registry_credentials.getCredentials(),
+ MatchesDict({
+ "password": Equals(None)}))
+
+ browser.getLink("Edit push rules").click()
+ browser.getControl(
+ name="field.add_new_creds").value = True
+ browser.getControl(
+ name="field.add_image_name").value = "imagename3"
+ browser.getControl(
+ name="field.add_url").value = url
+ browser.getControl(
+ name="field.add_username").value = "username"
+ browser.getControl(
+ name="field.add_password").value = "password"
+ browser.getControl(
+ name="field.add_confirm_password").value = "password"
+ browser.getControl("Save").click()
+ with person_logged_in(self.person):
+ rules = list(removeSecurityProxy(
+ getUtility(IOCIPushRuleSet).findByRecipe(self.recipe)))
+ self.assertEqual(len(rules), 3)
+ rule = rules[2]
+ self.assertIsNotNone(rule.registry_credentials)
+ self.assertEqual("imagename3", rule.image_name)
+ self.assertEqual(url, rule.registry_url)
+ self.assertEqual(url,
+ rule.registry_credentials.url)
+ self.assertEqual(
+ "username",
+ rule.registry_credentials.username)
+ with person_logged_in(self.person):
+ self.assertThat(
+ rule.registry_credentials.getCredentials(),
+ MatchesDict(
+ {"username": Equals("username"),
+ "password": Equals("password")}))
+
def test_edit_oci_registry_creds(self):
url = unicode(self.factory.getUniqueURL())
credentials = {'username': 'foo', 'password': 'bar'}
diff --git a/lib/lp/oci/javascript/ocirecipe.edit.js b/lib/lp/oci/javascript/ocirecipe.edit.js
new file mode 100644
index 0000000..85cd2db
--- /dev/null
+++ b/lib/lp/oci/javascript/ocirecipe.edit.js
@@ -0,0 +1,79 @@
+/* Copyright 2015-2020 Canonical Ltd. This software is licensed under the
+ * GNU Affero General Public License version 3 (see the file LICENSE).
+ *
+ * @module Y.lp.oci.ocirecipe.edit
+ * @requires node, DOM
+ */
+YUI.add('lp.oci.ocirecipe.edit', function(Y) {
+ Y.log('loading lp.oci.ocirecipe.edit');
+ var module = Y.namespace('lp.oci.ocirecipe.edit');
+
+ module.set_enabled = function(field_id, is_enabled) {
+ var field = Y.DOM.byId(field_id);
+ if (field !== null) {
+ field.disabled = !is_enabled;
+ }
+ };
+
+ module.onclick_add_new_creds = function(e) {
+ var add_new_creds = Y.one(
+ 'input[name="field.add_new_creds"]');
+ var use_existing_creds = Y.one(
+ 'input[name="field.use_existing_creds"]');
+
+ if (add_new_creds.get('checked') === true) {
+ use_existing_creds.set('checked', false);
+ Y.one('[id="field.existing_credentials"]').set('disabled', true);
+ Y.one('[id="field.add_url"]').set('disabled', false);
+ Y.one('[id="field.add_username"]').set('disabled', false);
+ Y.one('[id="field.add_password"]').set('disabled', false);
+ Y.one('[id="field.add_confirm_password"]').set('disabled', false);
+
+ }
+ else{
+ use_existing_creds.set('checked', true);
+ Y.one('[id="field.existing_credentials"]').set('disabled', false);
+ Y.one('[id="field.add_url"]').set('disabled', true);
+ Y.one('[id="field.add_username"]').set('disabled', true);
+ Y.one('[id="field.add_password"]').set('disabled', true);
+ Y.one('[id="field.add_confirm_password"]').set('disabled', true);
+
+ }
+ };
+
+ module.use_existing_creds = function(e) {
+ var add_new_creds = Y.one(
+ 'input[name="field.add_new_creds"]');
+ var use_existing_creds = Y.one(
+ 'input[name="field.use_existing_creds"]');
+
+ if (use_existing_creds.get('checked') === true) {
+ add_new_creds.set('checked', false);
+ Y.one('[id="field.existing_credentials"]').set('disabled', false);
+ Y.one('[id="field.add_url"]').set('disabled', true);
+ Y.one('[id="field.add_username"]').set('disabled', true);
+ Y.one('[id="field.add_password"]').set('disabled', true);
+ Y.one('[id="field.add_confirm_password"]').set('disabled', true);
+
+ }
+ else{
+ add_new_creds.set('checked', true);
+ Y.one('[id="field.existing_credentials"]').set('disabled', true);
+ Y.one('[id="field.add_url"]').set('disabled', false);
+ Y.one('[id="field.add_username"]').set('disabled', false);
+ Y.one('[id="field.add_password"]').set('disabled', false);
+ Y.one('[id="field.add_confirm_password"]').set('disabled', false);
+
+ }
+ };
+
+ module.setup = function() {
+ Y.all('input[name="field.use_existing_creds"]').on(
+ 'click', module.use_existing_creds);
+ Y.all('input[name="field.add_new_creds"]').on(
+ 'click', module.onclick_add_new_creds);
+ // Set the initial state.
+ module.onclick_add_new_creds();
+ module.use_existing_creds();
+ };
+}, '0.1', {'requires': ['node', 'DOM']});
diff --git a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
index a1adb89..7c1a662 100644
--- a/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
+++ b/lib/lp/oci/templates/ocirecipe-edit-push-rules.pt
@@ -7,6 +7,14 @@
i18n:domain="launchpad">
<body>
+<metal:block fill-slot="head_epilogue">
+ <style type="text/css">
+ .subordinate {
+ margin: 0.5em 0 0.5em 4em;
+ }
+ </style>
+</metal:block>
+
<div metal:fill-slot="main">
<div metal:use-macro="context/@@launchpad_form/form">
<metal:formbody fill-slot="widgets">
@@ -32,10 +40,69 @@
</td>
</tal:rules_widgets>
</tr>
+ <tr>
+ <td>
+ <h2>Add new push rule</h2>
+ </td>
+ </tr>
+ <tr>
+ <td tal:define="widget nocall:view/widgets/add_image_name">
+ <metal:widget use-macro="context/@@launchpad_form/widget_div" />
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <tal:widget define="widget nocall:view/widgets/use_existing_creds">
+ <metal:block use-macro="context/@@launchpad_form/widget_div" />
+ </tal:widget>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <table class="subordinate">
+ <tal:widget define="widget nocall:view/widgets/existing_credentials">
+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <tal:widget define="widget nocall:view/widgets/add_new_creds">
+ <metal:block use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <table class="subordinate">
+ <tal:widget define="widget nocall:view/widgets/add_url">
+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/add_username">
+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/add_password">
+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ <tal:widget define="widget nocall:view/widgets/add_confirm_password">
+ <metal:row use-macro="context/@@launchpad_form/widget_row" />
+ </tal:widget>
+ </table>
+ </td>
+ </tr>
</table>
</metal:formbody>
</div>
+ <script type="text/javascript">
+ LPJS.use('lp.oci.ocirecipe.edit', function(Y) {
+ Y.on('domready', function(e) {
+ Y.lp.oci.ocirecipe.edit.setup();
+ }, window);
+ });
+ </script>
+
</div>
</body>
</html>
diff --git a/lib/lp/oci/vocabularies.py b/lib/lp/oci/vocabularies.py
index aa1fae3..65209e1 100644
--- a/lib/lp/oci/vocabularies.py
+++ b/lib/lp/oci/vocabularies.py
@@ -8,10 +8,13 @@ from __future__ import absolute_import, print_function, unicode_literals
__metaclass__ = type
__all__ = []
+from zope.component import getUtility
from zope.interface import implementer
from zope.schema.vocabulary import SimpleTerm
+from lp.oci.interfaces.ociregistrycredentials import IOCIRegistryCredentialsSet
from lp.oci.model.ocirecipe import OCIRecipe
+from lp.oci.model.ociregistrycredentials import OCIRegistryCredentials
from lp.services.webapp.vocabulary import (
IHugeVocabulary,
StormVocabularyBase,
@@ -35,6 +38,51 @@ class OCIRecipeDistroArchSeriesVocabulary(StormVocabularyBase):
return len(self.context.getAllowedArchitectures())
+class OCIRegistryCredentialsVocabulary(StormVocabularyBase):
+
+ _table = OCIRegistryCredentials
+
+ def toTerm(self, obj):
+ if obj.username:
+ token = "%s %s" % (
+ obj.url,
+ obj.username)
+ else:
+ token = obj.url
+
+ return SimpleTerm(obj, token)
+
+ @property
+ def _entries(self):
+ return list(getUtility(
+ IOCIRegistryCredentialsSet).findByOwner(self.context.owner))
+
+ def __contains__(self, value):
+ """See `IVocabulary`."""
+ return value in self._entries
+
+ def __iter__(self):
+ for obj in self._entries:
+ yield self.toTerm(obj)
+
+ def __len__(self):
+ return len(self._entries)
+
+ def getTermByToken(self, token):
+ """See `IVocabularyTokenized`."""
+ try:
+ if ' ' in token:
+ url, username = token.split(' ')
+ else:
+ username = None
+ url = token
+ for obj in self._entries:
+ if obj.url == url and obj.username == username:
+ return self.toTerm(obj)
+ except ValueError:
+ raise LookupError(token)
+
+
@implementer(IHugeVocabulary)
class OCIRecipeVocabulary(StormVocabularyBase):
"""All OCI Recipes of a given OCI project."""
diff --git a/lib/lp/oci/vocabularies.zcml b/lib/lp/oci/vocabularies.zcml
index 1a6b75c..24a57d8 100644
--- a/lib/lp/oci/vocabularies.zcml
+++ b/lib/lp/oci/vocabularies.zcml
@@ -16,6 +16,16 @@
</class>
<securedutility
+ name="OCIRegistryCredentials"
+ component="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary"
+ provides="zope.schema.interfaces.IVocabularyFactory">
+ <allow interface="zope.schema.interfaces.IVocabularyFactory" />
+ </securedutility>
+
+ <class class="lp.oci.vocabularies.OCIRegistryCredentialsVocabulary">
+ <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
+ </class>
+ <securedutility
name="OCIRecipe"
component="lp.oci.vocabularies.OCIRecipeVocabulary"
provides="zope.schema.interfaces.IVocabularyFactory">