You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@allura.apache.org by jo...@apache.org on 2013/07/16 22:22:06 UTC

[1/4] git commit: [#4656] More refactor to project shortname validation

Updated Branches:
  refs/heads/master 806767084 -> c78912c46


[#4656] More refactor to project shortname validation

Signed-off-by: Cory Johns <cj...@slashdotmedia.com>


Project: http://git-wip-us.apache.org/repos/asf/incubator-allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-allura/commit/c78912c4
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/c78912c4
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/c78912c4

Branch: refs/heads/master
Commit: c78912c4692def26a65263e762f33a61b0173c2a
Parents: 7efec56
Author: Cory Johns <cj...@slashdotmedia.com>
Authored: Mon Jul 8 18:57:19 2013 +0000
Committer: Cory Johns <cj...@slashdotmedia.com>
Committed: Tue Jul 16 20:15:14 2013 +0000

----------------------------------------------------------------------
 Allura/allura/controllers/project.py            |  3 +-
 Allura/allura/lib/plugin.py                     | 42 +++++++++++++++-----
 Allura/allura/lib/widgets/forms.py              |  9 ++---
 .../tests/functional/test_neighborhood.py       | 14 +++----
 Allura/allura/tests/test_plugin.py              | 30 ++++++++++++--
 5 files changed, 70 insertions(+), 28 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c78912c4/Allura/allura/controllers/project.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/project.py b/Allura/allura/controllers/project.py
index 36c4667..3cf4112 100644
--- a/Allura/allura/controllers/project.py
+++ b/Allura/allura/controllers/project.py
@@ -81,7 +81,8 @@ class NeighborhoodController(object):
     def _lookup(self, pname, *remainder):
         pname = unquote(pname)
         provider = plugin.ProjectRegistrationProvider.get()
-        if provider.validate_project_shortname(pname, self.neighborhood):
+        valid, reason = provider.valid_project_shortname(pname, self.neighborhood)
+        if not valid:
             raise exc.HTTPNotFound, pname
         project = M.Project.query.get(shortname=self.prefix + pname, neighborhood_id=self.neighborhood._id)
         if project is None and self.prefix == 'u/':

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c78912c4/Allura/allura/lib/plugin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 111aa77..76a18ae 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -364,16 +364,14 @@ class ProjectRegistrationProvider(object):
         method = config.get('registration.method', 'local')
         return app_globals.Globals().entry_points['registration'][method]()
 
-    def name_taken(self, project_name, neighborhood):
+    def _name_taken(self, project_name, neighborhood):
         """Return False if ``project_name`` is available in ``neighborhood``.
         If unavailable, return an error message (str) explaining why.
 
         """
         from allura import model as M
         p = M.Project.query.get(shortname=project_name, neighborhood_id=neighborhood._id)
-        if p:
-            return 'This project name is taken.'
-        return False
+        return bool(p)
 
     def suggest_name(self, project_name, neighborhood):
         """Return a suggested project shortname for the full ``project_name``.
@@ -469,21 +467,45 @@ class ProjectRegistrationProvider(object):
             check_shortname = shortname.replace('u/', '', 1)
         else:
             check_shortname = shortname
-        err = self.validate_project_shortname(check_shortname, neighborhood)
-        if err:
+        allowed, err = self.allowed_project_shortname(check_shortname, neighborhood)
+        if not allowed:
             raise ValueError('Invalid project shortname: %s' % shortname)
 
         p = M.Project.query.get(shortname=shortname, neighborhood_id=neighborhood._id)
         if p:
             raise forge_exc.ProjectConflict('%s already exists in nbhd %s' % (shortname, neighborhood._id))
 
-    def validate_project_shortname(self, shortname, neighborhood):
-        """Return an error message if ``shortname`` is invalid for
-        ``neighborhood``, else return None.
+    def valid_project_shortname(self, shortname, neighborhood):
+        """Determine if the project shortname appears to be valid.
+
+        Returns a pair of values, the first being a bool indicating whether
+        the name appears to be valid, and the second being a message indicating
+        the reason, if any, why the name is invalid.
 
+        NB: Even if a project shortname is valid, it might still not be
+        allowed (it could already be taken, for example).  Use the method
+        ``allowed_project_shortname`` instead to check if the shortname can
+        actually be used.
         """
         if not h.re_project_name.match(shortname):
-            return 'Please use only letters, numbers, and dashes 3-15 characters long.'
+            return (False, 'Please use only letters, numbers, and dashes 3-15 characters long.')
+        return (True, None)
+
+    def allowed_project_shortname(self, shortname, neighborhood):
+        """Determine if a project shortname can be used.
+
+        A shortname can be used if it is valid and is not already taken.
+
+        Returns a pair of values, the first being a bool indicating whether
+        the name can be used, and the second being a message indicating the
+        reason, if any, why the name cannot be used.
+        """
+        valid, reason = self.valid_project_shortname(shortname, neighborhood)
+        if not valid:
+            return (False, reason)
+        if self._name_taken(shortname, neighborhood):
+            return (False, 'This project name is taken.')
+        return (True, None)
 
     def _create_project(self, neighborhood, shortname, project_name, user, user_project, private_project, apps):
         '''

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c78912c4/Allura/allura/lib/widgets/forms.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/widgets/forms.py b/Allura/allura/lib/widgets/forms.py
index 2312c71..a5ebe15 100644
--- a/Allura/allura/lib/widgets/forms.py
+++ b/Allura/allura/lib/widgets/forms.py
@@ -47,15 +47,12 @@ class _HTMLExplanation(ew.InputField):
 
 class NeighborhoodProjectShortNameValidator(fev.FancyValidator):
 
-    def _to_python(self, value, state):
+    def to_python(self, value, state):
         value = h.really_unicode(value or '').encode('utf-8').lower()
         neighborhood = M.Neighborhood.query.get(name=state.full_dict['neighborhood'])
         provider = plugin.ProjectRegistrationProvider.get()
-        message = provider.validate_project_shortname(value, neighborhood)
-        if message:
-            raise formencode.Invalid(message, value, state)
-        message = provider.name_taken(value, neighborhood)
-        if message:
+        allowed, message = provider.allowed_project_shortname(value, neighborhood)
+        if not allowed:
             raise formencode.Invalid(message, value, state)
         return value
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c78912c4/Allura/allura/tests/functional/test_neighborhood.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/functional/test_neighborhood.py b/Allura/allura/tests/functional/test_neighborhood.py
index fd6c3e4..f63d597 100644
--- a/Allura/allura/tests/functional/test_neighborhood.py
+++ b/Allura/allura/tests/functional/test_neighborhood.py
@@ -449,7 +449,7 @@ class TestNeighborhood(TestController):
                           params=dict(project_unixname='', project_name='Nothing', project_description='', neighborhood='Adobe'),
                           antispam=True,
                           extra_environ=dict(username='root'))
-        assert r.html.find('div', {'class':'error'}).string == 'Please enter a value'
+        assert r.html.find('div', {'class':'error'}).string == 'Please use only letters, numbers, and dashes 3-15 characters long.'
         r = self.app.post('/adobe/register',
                           params=dict(project_unixname='mymoz', project_name='My Moz', project_description='', neighborhood='Adobe'),
                           antispam=True,
@@ -742,12 +742,12 @@ class TestNeighborhood(TestController):
 
     def test_name_check(self):
         for name in ('My+Moz', 'Te%st!', 'ab', 'a' * 16):
-            r = self.app.get('/p/check_names?unix_name=%s' % name)
-            assert r.json['unixname_message'] == 'Please use only letters, numbers, and dashes 3-15 characters long.'
-        r = self.app.get('/p/check_names?unix_name=mymoz')
-        assert_equal(r.json['unixname_message'], False)
-        r = self.app.get('/p/check_names?unix_name=test')
-        assert r.json['unixname_message'] == 'This project name is taken.'
+            r = self.app.get('/p/check_names?neighborhood=Projects&project_unixname=%s' % name)
+            assert_equal(r.json, {'project_unixname': 'Please use only letters, numbers, and dashes 3-15 characters long.'})
+        r = self.app.get('/p/check_names?neighborhood=Projects&project_unixname=mymoz')
+        assert_equal(r.json, {})
+        r = self.app.get('/p/check_names?neighborhood=Projects&project_unixname=test')
+        assert_equal(r.json, {'project_unixname': 'This project name is taken.'})
 
     @td.with_tool('test/sub1', 'Wiki', 'wiki')
     def test_neighborhood_project(self):

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c78912c4/Allura/allura/tests/test_plugin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/test_plugin.py b/Allura/allura/tests/test_plugin.py
index aa2aaeb..712fe52 100644
--- a/Allura/allura/tests/test_plugin.py
+++ b/Allura/allura/tests/test_plugin.py
@@ -46,10 +46,32 @@ class TestProjectRegistrationProvider(object):
         assert_equals(f('A More Than Fifteen Character Name', Mock()),
                 'amorethanfifteencharactername')
 
-    def test_validate_project_shortname(self):
-        f = self.provider.validate_project_shortname
+    def test_valid_project_shortname(self):
+        f = self.provider.valid_project_shortname
         p = Mock()
-        assert_equals(f('thisislegit', p), None)
-        assert_equals(f('this is invalid and too long', p),
+        valid = (True, None)
+        invalid = (False,
                 'Please use only letters, numbers, and dashes '
                 '3-15 characters long.')
+        assert_equals(f('thisislegit', p), valid)
+        assert_equals(f('not valid', p), invalid)
+        assert_equals(f('this-is-valid-but-too-long', p), invalid)
+        assert_equals(f('this is invalid and too long', p), invalid)
+
+    def test_allowed_project_shortname(self):
+        allowed = valid = (True, None)
+        invalid = (False, 'invalid')
+        taken = (False, 'This project name is taken.')
+        cases = [
+                (valid, False, allowed),
+                (invalid, False, invalid),
+                (valid, True, taken),
+            ]
+        p = Mock()
+        vps = self.provider.valid_project_shortname = Mock()
+        nt = self.provider._name_taken = Mock()
+        f = self.provider.allowed_project_shortname
+        for vps_v, nt_v, result in cases:
+            vps.return_value = vps_v
+            nt.return_value = nt_v
+            assert_equals(f('project', p), result)


[3/4] git commit: [#4656] Refactored project name validation

Posted by jo...@apache.org.
[#4656] Refactored project name validation

AJAX validation and validation on submit were not using the same
validation checks.  Refactored the validation code so that both use
the same checks and consolidated redundant API methods related to those
checks.

The "extra_name_checks" method is no longer used, and those checks
should be folded into the new, more general "validate_project_shortname"
method.

Signed-off-by: Cory Johns <cj...@slashdotmedia.com>


Project: http://git-wip-us.apache.org/repos/asf/incubator-allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-allura/commit/7efec56e
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/7efec56e
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/7efec56e

Branch: refs/heads/master
Commit: 7efec56ef005433e79fdb377ef6c0a8da5f28ecd
Parents: dce4fe8
Author: Cory Johns <cj...@slashdotmedia.com>
Authored: Wed Jul 3 20:05:19 2013 +0000
Committer: Cory Johns <cj...@slashdotmedia.com>
Committed: Tue Jul 16 20:15:14 2013 +0000

----------------------------------------------------------------------
 Allura/allura/controllers/project.py | 19 +++++--------------
 Allura/allura/lib/plugin.py          | 13 -------------
 Allura/allura/lib/widgets/forms.py   | 30 +++++++++++++++---------------
 3 files changed, 20 insertions(+), 42 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/7efec56e/Allura/allura/controllers/project.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/project.py b/Allura/allura/controllers/project.py
index e5edd80..36c4667 100644
--- a/Allura/allura/controllers/project.py
+++ b/Allura/allura/controllers/project.py
@@ -80,7 +80,8 @@ class NeighborhoodController(object):
     @expose()
     def _lookup(self, pname, *remainder):
         pname = unquote(pname)
-        if not h.re_project_name.match(pname):
+        provider = plugin.ProjectRegistrationProvider.get()
+        if provider.validate_project_shortname(pname, self.neighborhood):
             raise exc.HTTPNotFound, pname
         project = M.Project.query.get(shortname=self.prefix + pname, neighborhood_id=self.neighborhood._id)
         if project is None and self.prefix == 'u/':
@@ -180,19 +181,9 @@ class NeighborhoodController(object):
             self.neighborhood))
 
     @expose('json:')
-    def check_names(self, project_name='', unix_name=''):
-        provider = plugin.ProjectRegistrationProvider.get()
-        result = dict()
-        try:
-            W.add_project.fields['project_name'].validate(project_name, '')
-        except Invalid as e:
-            result['name_message'] = str(e)
-
-        unixname_invalid_err = provider.validate_project_shortname(unix_name,
-                self.neighborhood)
-        result['unixname_message'] = (unixname_invalid_err or
-                provider.name_taken(unix_name, self.neighborhood))
-        return result
+    @validate(W.add_project)
+    def check_names(self, **raw_data):
+        return c.form_errors
 
     @h.vardec
     @expose()

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/7efec56e/Allura/allura/lib/plugin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index c0319f7..111aa77 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -373,21 +373,8 @@ class ProjectRegistrationProvider(object):
         p = M.Project.query.get(shortname=project_name, neighborhood_id=neighborhood._id)
         if p:
             return 'This project name is taken.'
-        for check in self.extra_name_checks():
-            if re.match(str(check[1]),project_name) is not None:
-                return check[0]
         return False
 
-    def extra_name_checks(self):
-        """Return an iterable of ``(error_message, regex)`` tuples.
-
-        If user attempts to register a project with a name that matches
-        ``regex``, the field will be marked invalid, and ``error_message``
-        displayed to the user.
-
-        """
-        return []
-
     def suggest_name(self, project_name, neighborhood):
         """Return a suggested project shortname for the full ``project_name``.
 

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/7efec56e/Allura/allura/lib/widgets/forms.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/widgets/forms.py b/Allura/allura/lib/widgets/forms.py
index 9501b70..2312c71 100644
--- a/Allura/allura/lib/widgets/forms.py
+++ b/Allura/allura/lib/widgets/forms.py
@@ -45,12 +45,16 @@ class _HTMLExplanation(ew.InputField):
         ''',
         'jinja2')
 
-class NeighborhoodProjectTakenValidator(fev.FancyValidator):
+class NeighborhoodProjectShortNameValidator(fev.FancyValidator):
 
     def _to_python(self, value, state):
         value = h.really_unicode(value or '').encode('utf-8').lower()
         neighborhood = M.Neighborhood.query.get(name=state.full_dict['neighborhood'])
-        message = plugin.ProjectRegistrationProvider.get().name_taken(value, neighborhood)
+        provider = plugin.ProjectRegistrationProvider.get()
+        message = provider.validate_project_shortname(value, neighborhood)
+        if message:
+            raise formencode.Invalid(message, value, state)
+        message = provider.name_taken(value, neighborhood)
         if message:
             raise formencode.Invalid(message, value, state)
         return value
@@ -779,14 +783,7 @@ class NeighborhoodAddProjectForm(ForgeForm):
                 V.MaxBytesValidator(max=40)))
         project_unixname = ew.InputField(
             label='Short Name', field_type='text',
-            validator=formencode.All(
-                fev.String(not_empty=True),
-                fev.MinLength(3),
-                fev.MaxLength(15),
-                fev.Regex(
-                    r'^[A-z][-A-z0-9]{2,}$',
-                    messages={'invalid':'Please use only letters, numbers, and dashes 3-15 characters long.'}),
-                NeighborhoodProjectTakenValidator()))
+            validator=NeighborhoodProjectShortNameValidator())
         tools = ew.CheckboxSet(name='tools', options=[
             ## Required for Neighborhood functional tests to pass
             ew.Option(label='Wiki', html_value='wiki', selected=True)
@@ -805,12 +802,14 @@ class NeighborhoodAddProjectForm(ForgeForm):
     def resources(self):
         for r in super(NeighborhoodAddProjectForm, self).resources(): yield r
         yield ew.CSSLink('css/add_project.css')
+        neighborhood = g.antispam.enc('neighborhood')
         project_name = g.antispam.enc('project_name')
         project_unixname = g.antispam.enc('project_unixname')
 
         yield ew.JSScript('''
             $(function(){
                 var $scms = $('input[type=checkbox].scm');
+                var $nbhd_input = $('input[name="%(neighborhood)s"]');
                 var $name_input = $('input[name="%(project_name)s"]');
                 var $unixname_input = $('input[name="%(project_unixname)s"]');
                 var $url_fragment = $('#url_fragment');
@@ -864,12 +863,13 @@ class NeighborhoodAddProjectForm(ForgeForm):
                 });
                 var check_names = function() {
                     var data = {
-                        'project_name':$name_input.val(),
-                        'unix_name': $unixname_input.val()
+                        'neighborhood': $nbhd_input.val(),
+                        'project_name': $name_input.val(),
+                        'project_unixname': $unixname_input.val()
                     };
                     $.getJSON('check_names', data, function(result){
-                        handle_error($name_input, result.name_message);
-                        handle_error($unixname_input, result.unixname_message);
+                        handle_error($name_input, result.project_name);
+                        handle_error($unixname_input, result.project_unixname);
                     });
                 };
                 var manual = false;
@@ -897,7 +897,7 @@ class NeighborhoodAddProjectForm(ForgeForm):
                     delay(check_names, 500);
                 });
             });
-        ''' % dict(project_name=project_name, project_unixname=project_unixname))
+        ''' % dict(neighborhood=neighborhood, project_name=project_name, project_unixname=project_unixname))
 
 
 class MoveTicketForm(ForgeForm):


[2/4] git commit: [#4656] Add tests for new provider methods

Posted by jo...@apache.org.
[#4656] Add tests for new provider methods

Signed-off-by: Tim Van Steenburgh <tv...@gmail.com>


Project: http://git-wip-us.apache.org/repos/asf/incubator-allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-allura/commit/dce4fe8c
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/dce4fe8c
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/dce4fe8c

Branch: refs/heads/master
Commit: dce4fe8c3c05109f9d9e8de7e2495ebf13b673ac
Parents: c3b47b7
Author: Tim Van Steenburgh <tv...@gmail.com>
Authored: Tue Jul 2 21:36:57 2013 +0000
Committer: Cory Johns <cj...@slashdotmedia.com>
Committed: Tue Jul 16 20:15:14 2013 +0000

----------------------------------------------------------------------
 Allura/allura/tests/test_plugin.py | 23 +++++++++++++++++++----
 1 file changed, 19 insertions(+), 4 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/dce4fe8c/Allura/allura/tests/test_plugin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/test_plugin.py b/Allura/allura/tests/test_plugin.py
index 6e1a07a..aa2aaeb 100644
--- a/Allura/allura/tests/test_plugin.py
+++ b/Allura/allura/tests/test_plugin.py
@@ -16,7 +16,7 @@
 #       under the License.
 
 from nose.tools import assert_equals
-from mock import MagicMock, patch
+from mock import Mock, MagicMock, patch
 
 from allura import model as M
 from allura.lib.utils import TruthyCallable
@@ -25,16 +25,31 @@ from allura.lib.plugin import ProjectRegistrationProvider
 
 class TestProjectRegistrationProvider(object):
 
+    def setUp(self):
+        self.provider = ProjectRegistrationProvider()
+
     @patch('allura.lib.security.has_access')
     def test_validate_project_15char_user(self, has_access):
         has_access.return_value = TruthyCallable(lambda: True)
-        provider = ProjectRegistrationProvider()
         nbhd = M.Neighborhood()
-        provider.validate_project(
+        self.provider.validate_project(
             neighborhood=nbhd,
             shortname='u/' + ('a' * 15),
             project_name='15 char username',
             user=MagicMock(),
             user_project=True,
             private_project=False,
-        )
\ No newline at end of file
+        )
+
+    def test_suggest_name(self):
+        f = self.provider.suggest_name
+        assert_equals(f('A More Than Fifteen Character Name', Mock()),
+                'amorethanfifteencharactername')
+
+    def test_validate_project_shortname(self):
+        f = self.provider.validate_project_shortname
+        p = Mock()
+        assert_equals(f('thisislegit', p), None)
+        assert_equals(f('this is invalid and too long', p),
+                'Please use only letters, numbers, and dashes '
+                '3-15 characters long.')


[4/4] git commit: [#4656] Refactor project name validation

Posted by jo...@apache.org.
[#4656] Refactor project name validation

Signed-off-by: Tim Van Steenburgh <tv...@gmail.com>


Project: http://git-wip-us.apache.org/repos/asf/incubator-allura/repo
Commit: http://git-wip-us.apache.org/repos/asf/incubator-allura/commit/c3b47b75
Tree: http://git-wip-us.apache.org/repos/asf/incubator-allura/tree/c3b47b75
Diff: http://git-wip-us.apache.org/repos/asf/incubator-allura/diff/c3b47b75

Branch: refs/heads/master
Commit: c3b47b75d1588c6c6f6b4ee2a22f3ceee469fbc6
Parents: 8067670
Author: Tim Van Steenburgh <tv...@gmail.com>
Authored: Tue Jul 2 19:56:40 2013 +0000
Committer: Cory Johns <cj...@slashdotmedia.com>
Committed: Tue Jul 16 20:15:14 2013 +0000

----------------------------------------------------------------------
 Allura/allura/controllers/project.py | 16 ++++++------
 Allura/allura/lib/plugin.py          | 41 ++++++++++++++++++++++++-------
 2 files changed, 41 insertions(+), 16 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c3b47b75/Allura/allura/controllers/project.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/project.py b/Allura/allura/controllers/project.py
index bff0399..e5edd80 100644
--- a/Allura/allura/controllers/project.py
+++ b/Allura/allura/controllers/project.py
@@ -175,21 +175,23 @@ class NeighborhoodController(object):
 
     @expose('json:')
     def suggest_name(self, project_name=''):
-        result = dict()
-        result['suggested_name'] = re.sub("[^A-Za-z0-9]", "", project_name).lower()[:15]
-        return result
+        provider = plugin.ProjectRegistrationProvider.get()
+        return dict(suggested_name=provider.suggest_name(project_name,
+            self.neighborhood))
 
     @expose('json:')
     def check_names(self, project_name='', unix_name=''):
+        provider = plugin.ProjectRegistrationProvider.get()
         result = dict()
         try:
             W.add_project.fields['project_name'].validate(project_name, '')
         except Invalid as e:
             result['name_message'] = str(e)
-        if not h.re_project_name.match(unix_name) or not (3 <= len(unix_name) <= 15):
-            result['unixname_message'] = 'Please use only letters, numbers, and dashes 3-15 characters long.'
-        else:
-            result['unixname_message'] = plugin.ProjectRegistrationProvider.get().name_taken(unix_name, self.neighborhood)
+
+        unixname_invalid_err = provider.validate_project_shortname(unix_name,
+                self.neighborhood)
+        result['unixname_message'] = (unixname_invalid_err or
+                provider.name_taken(unix_name, self.neighborhood))
         return result
 
     @h.vardec

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/c3b47b75/Allura/allura/lib/plugin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/plugin.py b/Allura/allura/lib/plugin.py
index 855e71c..c0319f7 100644
--- a/Allura/allura/lib/plugin.py
+++ b/Allura/allura/lib/plugin.py
@@ -365,6 +365,10 @@ class ProjectRegistrationProvider(object):
         return app_globals.Globals().entry_points['registration'][method]()
 
     def name_taken(self, project_name, neighborhood):
+        """Return False if ``project_name`` is available in ``neighborhood``.
+        If unavailable, return an error message (str) explaining why.
+
+        """
         from allura import model as M
         p = M.Project.query.get(shortname=project_name, neighborhood_id=neighborhood._id)
         if p:
@@ -375,18 +379,28 @@ class ProjectRegistrationProvider(object):
         return False
 
     def extra_name_checks(self):
-        '''This should be a list or iterator containing tuples.
-        The first tiem in the tuple should be an error message and the
-        second should be a regex. If the user attempts to register a
-        project with a name that matches the regex, the field will
-        be marked invalid with the message displayed to the user.
-        '''
+        """Return an iterable of ``(error_message, regex)`` tuples.
+
+        If user attempts to register a project with a name that matches
+        ``regex``, the field will be marked invalid, and ``error_message``
+        displayed to the user.
+
+        """
         return []
 
+    def suggest_name(self, project_name, neighborhood):
+        """Return a suggested project shortname for the full ``project_name``.
+
+        Example: "My Great Project" -> "mygreatproject"
+
+        """
+        return re.sub("[^A-Za-z0-9]", "", project_name).lower()
+
     def rate_limit(self, user, neighborhood):
-        '''Check the various config-defined project registration rate
+        """Check the various config-defined project registration rate
         limits, and if any are exceeded, raise ProjectRatelimitError.
-        '''
+
+        """
         if security.has_access(neighborhood, 'admin', user=user)():
             return
         # have to have the replace because, despite being UTC,
@@ -468,13 +482,22 @@ class ProjectRegistrationProvider(object):
             check_shortname = shortname.replace('u/', '', 1)
         else:
             check_shortname = shortname
-        if not h.re_project_name.match(check_shortname):
+        err = self.validate_project_shortname(check_shortname, neighborhood)
+        if err:
             raise ValueError('Invalid project shortname: %s' % shortname)
 
         p = M.Project.query.get(shortname=shortname, neighborhood_id=neighborhood._id)
         if p:
             raise forge_exc.ProjectConflict('%s already exists in nbhd %s' % (shortname, neighborhood._id))
 
+    def validate_project_shortname(self, shortname, neighborhood):
+        """Return an error message if ``shortname`` is invalid for
+        ``neighborhood``, else return None.
+
+        """
+        if not h.re_project_name.match(shortname):
+            return 'Please use only letters, numbers, and dashes 3-15 characters long.'
+
     def _create_project(self, neighborhood, shortname, project_name, user, user_project, private_project, apps):
         '''
         Actually create the project, no validation.  This should not be called directly