Allow creation of SVN repositories
authorSybren A. Stüvel <sybren@stuvel.eu>
Fri, 3 Nov 2017 16:38:43 +0000 (17:38 +0100)
committerSybren A. Stüvel <sybren@stuvel.eu>
Fri, 3 Nov 2017 16:38:43 +0000 (17:38 +0100)
src/scripts/tutti/00_utils.js
src/templates/svnman/errors/project_not_available.pug [deleted file]
src/templates/svnman/errors/project_not_setup.pug [deleted file]
src/templates/svnman/errors/service_not_available.pug [new file with mode: 0644]
src/templates/svnman/project_settings/offer_create_repo.pug [moved from src/templates/svnman/project_settings/offer_setup.pug with 71% similarity]
src/templates/svnman/sidebar.pug
svnman/__init__.py
svnman/routes.py
tests/abstract_svnman_test.py [new file with mode: 0644]
tests/test_pillar_extension.py [new file with mode: 0644]

index e69de29..7033f41 100644 (file)
@@ -0,0 +1,21 @@
+
+/* Returns a more-or-less reasonable message given an error response object. */
+function xhrErrorResponseMessage(err) {
+    console.log(err);
+    if (typeof err.responseJSON == 'undefined')
+        return err.statusText;
+
+    if (typeof err.responseJSON._error != 'undefined' && typeof err.responseJSON._error.message != 'undefined')
+        return err.responseJSON._error.message;
+
+    if (typeof err.responseJSON._message != 'undefined')
+        return err.responseJSON._message
+
+    return err.statusText;
+}
+
+function xhrErrorResponseElement(err, prefix) {
+    msg = xhrErrorResponseMessage(err);
+    return $('<span>')
+        .text(prefix + msg);
+}
diff --git a/src/templates/svnman/errors/project_not_available.pug b/src/templates/svnman/errors/project_not_available.pug
deleted file mode 100644 (file)
index a721b33..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-| {% extends "svnman/errors/layout.html" %}
-| {% block body %}
-#error_container.standalone
-       #error_box
-               .error-top-container
-                       .error-title Project not available on Subversion.
-               .error-lead
-                       p
-                               | This project is not available on Subversion. This means that either you do not have
-                               | access to the project, or there is to Subversion Manager assigned to this project
-                               | yet.
-| {% endblock %}
diff --git a/src/templates/svnman/errors/project_not_setup.pug b/src/templates/svnman/errors/project_not_setup.pug
deleted file mode 100644 (file)
index 55d0707..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-| {% extends "svnman/errors/layout.html" %}
-| {% block body %}
-#error_container.standalone
-       #error_box
-               .error-top-container
-                       .error-title Project not set up for Subversion.
-               .error-lead
-                       p Currently Subversion is in development, and only available to a select few.
-                       hr
-                       p If you want to use Subversion, contact us at
-                               a(href="mailto:cloudsupport@blender.institute") cloudsupport@blender.institute
-| {% endblock %}
diff --git a/src/templates/svnman/errors/service_not_available.pug b/src/templates/svnman/errors/service_not_available.pug
new file mode 100644 (file)
index 0000000..016ad23
--- /dev/null
@@ -0,0 +1,10 @@
+| {% extends "errors/layout.html" %}
+| {% block body %}
+#error_container.standalone
+       #error_box
+               .error-top-container
+                       .error-title Service not available.
+               .error-lead
+                       p
+                               | The Subversion service requires a special subscription.
+| {% endblock %}
 
 | {% block footer_scripts %}
 script.
-       function setupForSubversion() {
+       function createRepo() {
                $.ajax({
-                       url: '{{ url_for( "svnman.setup_for_svnman", project_url=project.url) }}',
+                       url: '{{ url_for( "svnman.create_repo", project_url=project.url) }}',
                        method: 'POST',
                })
                .done(function() {
                        window.location.reload();
                })
                .fail(function(err) {
-                       var err_elt = xhrErrorResponseElement(err, 'Error setting up your project: ');
+                       var err_elt = xhrErrorResponseElement(err, 'Error creating your repository: ');
                        toastr.error(err_elt);
                });
        }
index 88d94d9..6f80dca 100644 (file)
@@ -1,8 +1,8 @@
-li.tabs-flamenco(
-       title="Flamenco",
+li.tabs-svnman(
+       title="SVNman",
        data-toggle="tooltip",
        data-placement="right")
-       | {% if project %}
-       a(href="{{url_for('flamenco.jobs.perproject.index', project_url=project.url, _external=True)}}")
-               i.pi-flamenco
+       | {% if current_user.has_cap('svn-use') %}
+       a(href="alert('hmmm nothing here yet')")
+               i.pi-svnman
        | {% endif %}
index 466905f..78c037c 100644 (file)
@@ -1,16 +1,22 @@
 import logging
 import os.path
 
+import bson
 import flask
 from werkzeug.local import LocalProxy
 
 import pillarsdk
 from pillar.extension import PillarExtension
 from pillar.auth import current_user
+from pillar.api.projects import utils as proj_utils
 
 EXTENSION_NAME = 'svnman'
 
 
+# SVNman stores the following keys in the project extension properties:
+# repo_id: the Subversion repository ID
+# users: list of ObjectIDs of users having access to the project
+
 class SVNManExtension(PillarExtension):
     user_caps = {
         'subscriber-pro': frozenset({'svn-use'}),
@@ -111,6 +117,86 @@ class SVNManExtension(PillarExtension):
 
         return project_settings(project, **template_args)
 
+    def is_svnman_project(self, project: pillarsdk.Project) -> bool:
+        """Checks whether the project is correctly set up for SVNman."""
+
+        if not project.extension_props:
+            return False
+
+        try:
+            pprops = project.extension_props[EXTENSION_NAME]
+        except AttributeError:
+            self._log.warning("is_svnman_project: Project url=%r doesn't have"
+                              " any extension properties.", project['url'])
+            if self._log.isEnabledFor(logging.DEBUG):
+                import pprint
+                self._log.debug('Project: %s', pprint.pformat(project.to_dict()))
+            return False
+        except KeyError:
+            return False
+
+        if pprops is None:
+            self._log.warning("is_svnman_project: Project url=%r doesn't have"
+                              " Flamenco extension properties.", project['url'])
+            return False
+
+        return bool(pprops.repo_id)
+
+    def create_repo(self, project_url: str, creator: str) -> str:
+        """Creates a SVN repository with a random ID attached to the project.
+
+        Saves the repository ID in the project. Is a no-op if the project
+        already has a Subversion repository.
+        """
+
+        import random
+        import string
+
+        from . import remote, exceptions
+
+        alphabet = string.ascii_letters + string.digits
+
+        proj = proj_utils.get_project(project_url)
+        project_id = proj['_id']
+        eprops = proj.setdefault('extension_props', {}).setdefault(EXTENSION_NAME, {})
+
+        repo_id = eprops.get('repo_id')
+        if repo_id:
+            self._log.warning('project %s already has a Subversion repository %r',
+                              project_id, repo_id)
+            return repo_id
+
+        def random_id():
+            return ''.join([random.choice(alphabet) for _ in range(24)])
+
+        repo_info = remote.CreateRepo(
+            repo_id='',
+            project_id=str(project_id),
+            creator=creator,
+        )
+
+        for _ in range(100):
+            repo_info.repo_id = random_id()
+            self._log.info('creating new repository, trying out %s', repo_info)
+            try:
+                self.remote.create_repo(repo_info)
+            except exceptions.RepoAlreadyExists:
+                self._log.info('repo_id=%r already exists, trying random other one',
+                               repo_info.repo_id)
+            else:
+                break
+        else:
+            self._log.error('unable to find unique random repository ID, giving up')
+            raise ValueError('unable to find unique random repository ID, giving up')
+
+        self._log.info('created new Subversion repository: %s', repo_info)
+
+        # Update the project to include the repository ID.
+        eprops['repo_id'] = repo_info.repo_id
+        proj_utils.put_project(proj)
+
+        return repo_info.repo_id
+
 
 def _get_current_svnman() -> SVNManExtension:
     """Returns the SVNMan extension of the current application."""
index 50ff357..b02659a 100644 (file)
@@ -1,16 +1,12 @@
-import functools
 import logging
 
-import bson
-from flask import Blueprint, render_template, redirect, url_for
-from flask_login import login_required
+from flask import Blueprint, render_template, jsonify
 import werkzeug.exceptions as wz_exceptions
 
-from pillar.auth import current_user as current_user
-from pillar.api.utils.authentication import current_user_id
+from pillar.api.utils.authorization import require_login
+from pillar.auth import current_user
 from pillar.web.utils import attach_project_pictures
 from pillar.web.system_util import pillar_api
-from pillar.web.projects.routes import project_view
 import pillarsdk
 
 from svnman import current_svnman
@@ -39,24 +35,38 @@ def index():
                            projs_with_summaries=projs_with_summaries)
 
 
-def error_project_not_setup_for_svnman():
-    return render_template('svnman/errors/project_not_setup.html')
-
-
 def error_project_not_available():
     import flask
 
     if flask.request.is_xhr:
-        resp = flask.jsonify({'_error': 'project not available on Subversion'})
+        resp = flask.jsonify({'_error': 'Subversion service not available'})
         resp.status_code = 403
         return resp
 
-    return render_template('svnman/errors/project_not_available.html')
+    return render_template('svnman/errors/service_not_available.html')
+
 
+@blueprint.route('/<project_url>/create-repo', methods=['POST'])
+@require_login(require_cap='svn-use')
+def create_repo(project_url: str):
+    log.info('going to create repository for project url=%r on behalf of user %s (%s)',
+             project_url, current_user.user_id, current_user.email)
 
-@blueprint.route('/setup-for-svn')
-def setup_for_svnman(project_url):
-    return f'yeah {project_url}'
+    from . import exceptions
+
+    try:
+        current_svnman.create_repo(project_url, f'{current_user.full_name} <{current_user.email}>')
+    except (OSError, IOError):
+        log.exception('unable to reach SVNman API')
+        resp = jsonify(_message='unable to reach SVNman API')
+        resp.status_code = 500
+        return resp
+    except exceptions.RemoteError as ex:
+        log.error('API sent us an error: %s', ex)
+        resp = jsonify(_message=str(ex))
+        resp.status_code = 500
+        return resp
+    return '', 204
 
 
 def project_settings(project: pillarsdk.Project, **template_args: dict):
@@ -66,9 +76,9 @@ def project_settings(project: pillarsdk.Project, **template_args: dict):
         raise wz_exceptions.Forbidden()
 
     # Based on the project state, we can render a different template.
-    if not current_svnman.is_svnman_project(project):
-    return render_template('svnman/project_settings/offer_setup.html',
-                           project=project, **template_args)
+    if not current_svnman.is_svnman_project(project):
+        return render_template('svnman/project_settings/offer_create_repo.html',
+                               project=project, **template_args)
 
     return render_template('svnman/project_settings/settings.html',
                            project=project,
diff --git a/tests/abstract_svnman_test.py b/tests/abstract_svnman_test.py
new file mode 100644 (file)
index 0000000..368c052
--- /dev/null
@@ -0,0 +1,38 @@
+import pillarsdk
+import pillar.tests
+import pillar.auth
+
+from pillar.tests import PillarTestServer, AbstractPillarTest
+
+
+class SVNManTestServer(PillarTestServer):
+    def __init__(self, *args, **kwargs):
+        PillarTestServer.__init__(self, *args, **kwargs)
+
+        from svnman import SVNManExtension
+        self.load_extension(SVNManExtension(), '/svn')
+
+
+class AbstractSVNManTest(AbstractPillarTest):
+    pillar_server_class = SVNManTestServer
+
+    def setUp(self, **kwargs):
+        super().setUp(**kwargs)
+
+        from svnman.remote import API
+
+        self.remote: API = self.svnman.remote
+        self.proj_id, self.project = self.ensure_project_exists()
+
+        self.sdk_project = pillarsdk.Project(pillar.tests.mongo_to_sdk(self.project))
+
+    def tearDown(self):
+        self.unload_modules('svnman')
+        super().tearDown()
+
+    @property
+    def svnman(self):
+        from svnman import SVNManExtension
+
+        svnman: SVNManExtension = self.app.pillar_extensions['svnman']
+        return svnman
diff --git a/tests/test_pillar_extension.py b/tests/test_pillar_extension.py
new file mode 100644 (file)
index 0000000..c2789b7
--- /dev/null
@@ -0,0 +1,36 @@
+import copy
+
+import pillarsdk
+import pillar.tests
+
+from abstract_svnman_test import AbstractSVNManTest
+
+
+class TestPillarExtension(AbstractSVNManTest):
+    def test_is_svnman_project(self):
+        svn = self.svnman
+        sdk_project = None
+
+        def conv():
+            nonlocal sdk_project
+            sdk_project = pillarsdk.Project(pillar.tests.mongo_to_sdk(project))
+
+        # Default just-created project
+        project = copy.deepcopy(self.project)
+        conv()
+        self.assertFalse(svn.is_svnman_project(sdk_project))
+
+        project['extension_props'] = None
+        conv()
+        self.assertFalse(svn.is_svnman_project(sdk_project))
+
+        # With empty svnman extensions. We don't make any distinction between
+        # 'set up for SVNman' or 'has an actual repository'; the former → false,
+        # the latter → true.
+        project['extension_props'] = {'svnman': {}}
+        conv()
+        self.assertFalse(svn.is_svnman_project(sdk_project))
+
+        project['extension_props'] = {'svnman': {'repo_id': 'something-random'}}
+        conv()
+        self.assertTrue(svn.is_svnman_project(sdk_project))