Initial checkin of some base functionality
authorSybren A. Stüvel <sybren@stuvel.eu>
Fri, 3 Nov 2017 11:16:13 +0000 (12:16 +0100)
committerSybren A. Stüvel <sybren@stuvel.eu>
Fri, 3 Nov 2017 11:16:13 +0000 (12:16 +0100)
27 files changed:
.gitignore [new file with mode: 0644]
CHANGELOG.md [new file with mode: 0644]
LICENSE.txt [new file with mode: 0644]
README.md [new file with mode: 0644]
gulp [new file with mode: 0755]
gulpfile.js [new file with mode: 0644]
package.json [new file with mode: 0644]
requirements-dev.txt [new file with mode: 0644]
requirements.txt [new file with mode: 0644]
rsync_ui.sh [new file with mode: 0755]
runserver.py [new file with mode: 0644]
setup.cfg [new file with mode: 0644]
setup.py [new file with mode: 0644]
src/scripts/tutti/00_utils.js [new file with mode: 0644]
src/templates/svnman/errors/project_not_available.pug [new file with mode: 0644]
src/templates/svnman/errors/project_not_setup.pug [new file with mode: 0644]
src/templates/svnman/project_settings/offer_setup.pug [new file with mode: 0644]
src/templates/svnman/project_settings/settings.pug [new file with mode: 0644]
src/templates/svnman/project_settings/svnman_layout.pug [new file with mode: 0644]
src/templates/svnman/sidebar.pug [new file with mode: 0644]
svnman/__init__.py [new file with mode: 0644]
svnman/cli.py [new file with mode: 0644]
svnman/exceptions.py [new file with mode: 0644]
svnman/remote.py [new file with mode: 0644]
svnman/routes.py [new file with mode: 0644]
tests/logging_config.py [new file with mode: 0644]
update_version.sh [new file with mode: 0755]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..39c0904
--- /dev/null
@@ -0,0 +1,14 @@
+*.pyc
+*.bak
+*.css.map
+__pycache__
+.coverage
+
+/build
+/.cache
+/*.egg-info/
+/.eggs/
+/node_modules/
+/svnman/templates/
+/svnman/static/assets/css/
+/svnman/static/assets/js/generated/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644 (file)
index 0000000..59e7399
--- /dev/null
@@ -0,0 +1 @@
+# SVNMan Pillar Extension Changelog
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644 (file)
index 0000000..41fbe44
--- /dev/null
@@ -0,0 +1,13 @@
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 2 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along
+with this program; if not, write to the Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..054e45a
--- /dev/null
+++ b/README.md
@@ -0,0 +1 @@
+# SVNMan Pillar Extension
diff --git a/gulp b/gulp
new file mode 100755 (executable)
index 0000000..093ad60
--- /dev/null
+++ b/gulp
@@ -0,0 +1,19 @@
+#!/bin/bash -ex
+
+GULP=./node_modules/.bin/gulp
+
+function install() {
+    npm install
+    touch $GULP  # installer doesn't always touch this after a build, so we do.
+}
+
+# Rebuild Gulp if missing or outdated.
+[ -e $GULP ] || install
+[ gulpfile.js -nt $GULP ] && install
+
+if [ "$1" == "watch" ]; then
+    # Treat "gulp watch" as "gulp && gulp watch"
+    $GULP
+fi
+
+exec $GULP "$@"
diff --git a/gulpfile.js b/gulpfile.js
new file mode 100644 (file)
index 0000000..a28f46b
--- /dev/null
@@ -0,0 +1,120 @@
+var argv         = require('minimist')(process.argv.slice(2));
+var autoprefixer = require('gulp-autoprefixer');
+var chmod        = require('gulp-chmod');
+var concat       = require('gulp-concat');
+var git          = require('gulp-git');
+var gulp         = require('gulp');
+var gulpif       = require('gulp-if');
+var pug          = require('gulp-pug');
+var livereload   = require('gulp-livereload');
+var plumber      = require('gulp-plumber');
+var rename       = require('gulp-rename');
+var sass         = require('gulp-sass');
+var sourcemaps   = require('gulp-sourcemaps');
+var uglify       = require('gulp-uglify');
+var cache        = require('gulp-cached');
+
+var enabled = {
+    uglify: argv.production,
+    maps: argv.production,
+    failCheck: argv.production,
+    prettyPug: !argv.production,
+    liveReload: !argv.production,
+    cleanup: argv.production,
+};
+
+var destination = {
+    css: 'svnman/static/assets/css',
+    pug: 'svnman/templates',
+    js: 'svnman/static/assets/js/generated',
+}
+
+
+/* CSS */
+gulp.task('styles', function() {
+    gulp.src('src/styles/**/*.sass')
+        .pipe(gulpif(enabled.failCheck, plumber()))
+        .pipe(gulpif(enabled.maps, sourcemaps.init()))
+        .pipe(sass({
+            outputStyle: 'compressed'}
+            ))
+        .pipe(autoprefixer("last 3 versions"))
+        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
+        .pipe(gulp.dest(destination.css))
+        .pipe(gulpif(enabled.liveReload, livereload()));
+});
+
+
+/* Templates - Jade */
+gulp.task('templates', function() {
+    gulp.src('src/templates/**/*.pug')
+        .pipe(gulpif(enabled.failCheck, plumber()))
+        .pipe(cache('templating'))
+        .pipe(pug({
+            pretty: enabled.prettyPug
+        }))
+        .pipe(gulp.dest(destination.pug))
+        .pipe(gulpif(enabled.liveReload, livereload()));
+});
+
+
+/* Individual Uglified Scripts */
+gulp.task('scripts', function() {
+    gulp.src('src/scripts/*.js')
+        .pipe(gulpif(enabled.failCheck, plumber()))
+        .pipe(cache('scripting'))
+        .pipe(gulpif(enabled.maps, sourcemaps.init()))
+        .pipe(gulpif(enabled.uglify, uglify()))
+        .pipe(rename({suffix: '.min'}))
+        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
+        .pipe(chmod(644))
+        .pipe(gulp.dest(destination.js))
+        .pipe(gulpif(enabled.liveReload, livereload()));
+});
+
+
+/* Collection of scripts in src/scripts/tutti/ to merge into tutti.min.js */
+/* Since it's always loaded, it's only for functions that we want site-wide */
+gulp.task('scripts_tutti', function() {
+    gulp.src('src/scripts/tutti/**/*.js')
+        .pipe(gulpif(enabled.failCheck, plumber()))
+        .pipe(gulpif(enabled.maps, sourcemaps.init()))
+        .pipe(concat("tutti.min.js"))
+        .pipe(gulpif(enabled.uglify, uglify()))
+        .pipe(gulpif(enabled.maps, sourcemaps.write(".")))
+        .pipe(chmod(644))
+        .pipe(gulp.dest(destination.js))
+        .pipe(gulpif(enabled.liveReload, livereload()));
+});
+
+
+// While developing, run 'gulp watch'
+gulp.task('watch',function() {
+    // Only listen for live reloads if ran with --livereload
+    if (argv.livereload){
+        livereload.listen();
+    }
+
+    gulp.watch('src/styles/**/*.sass',['styles']);
+    gulp.watch('src/templates/**/*.pug',['templates']);
+    gulp.watch('src/scripts/*.js',['scripts']);
+    gulp.watch('src/scripts/tutti/*.js',['scripts_tutti']);
+});
+
+// Erases all generated files in output directories.
+gulp.task('cleanup', function() {
+    var paths = [];
+    for (attr in destination) {
+        paths.push(destination[attr]);
+    }
+
+    git.clean({ args: '-f -X ' + paths.join(' ') }, function (err) {
+        if(err) throw err;
+    });
+
+});
+
+// Run 'gulp' to build everything at once
+var tasks = [];
+if (enabled.cleanup) tasks.push('cleanup');
+gulp.task('default', tasks.concat(['styles', 'templates', 'scripts', 'scripts_tutti']));
diff --git a/package.json b/package.json
new file mode 100644 (file)
index 0000000..eb8ea6e
--- /dev/null
@@ -0,0 +1,26 @@
+{
+       "name": "pillar-svnman",
+       "license": "GPL-2.0+",
+       "author": "Blender Institute",
+       "repository": {
+               "type": "git",
+               "url": "git://git.blender.org/pillar-svnman.git"
+       },
+       "devDependencies": {
+               "gulp": "~3.9.1",
+               "gulp-autoprefixer": "~2.3.1",
+               "gulp-cached": "~1.1.0",
+               "gulp-chmod": "~1.3.0",
+               "gulp-concat": "~2.6.0",
+               "gulp-if": "^2.0.1",
+               "gulp-git": "~2.4.2",
+               "gulp-pug": "~3.2.0",
+               "gulp-livereload": "~3.8.1",
+               "gulp-plumber": "~1.1.0",
+               "gulp-rename": "~1.2.2",
+               "gulp-sass": "~2.3.1",
+               "gulp-sourcemaps": "~1.6.0",
+               "gulp-uglify": "~1.5.3",
+               "minimist": "^1.2.0"
+       }
+}
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644 (file)
index 0000000..d56fb76
--- /dev/null
@@ -0,0 +1,3 @@
+# Development requirements
+-r requirements.txt
+-r ../pillar/requirements-dev.txt
diff --git a/requirements.txt b/requirements.txt
new file mode 100644 (file)
index 0000000..25f615a
--- /dev/null
@@ -0,0 +1,3 @@
+# Primary requirements:
+-r ../pillar-python-sdk/requirements.txt
+-r ../pillar/requirements.txt
diff --git a/rsync_ui.sh b/rsync_ui.sh
new file mode 100755 (executable)
index 0000000..fa3f284
--- /dev/null
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+
+set -e  # error out when one of the commands in the script errors.
+
+if [ -z "$1" ]; then
+    echo "Usage: $0 {host-to-deploy-to}" >&2
+    exit 1
+fi
+
+DEPLOYHOST="$1"
+
+# macOS does not support readlink -f, so we use greadlink instead
+if [[ `uname` == 'Darwin' ]]; then
+    command -v greadlink 2>/dev/null 2>&1 || { echo >&2 "Install greadlink using brew."; exit 1; }
+    readlink='greadlink'
+else
+    readlink='readlink'
+fi
+
+MY_DIR="$(dirname "$($readlink -f "$0")")"
+if [ ! -d "$MY_DIR" ]; then
+    echo "Unable to find dir '$MY_DIR'"
+    exit 1
+fi
+
+ASSETS="$MY_DIR/svnman/static/assets/"
+TEMPLATES="$MY_DIR/svnman/templates/pillar-svnman"
+
+if [ ! -d "$ASSETS" ]; then
+    echo "Unable to find assets dir $ASSETS"
+    exit 1
+fi
+
+cd $MY_DIR
+if [ $(git rev-parse --abbrev-ref HEAD) != "production" ]; then
+    echo "You are NOT on the production branch, refusing to rsync_ui." >&2
+    exit 1
+fi
+
+echo
+echo "*** GULPA GULPA ***"
+./gulp --production
+
+echo
+echo "*** SYNCING ASSETS ***"
+# Exclude files managed by Git.
+rsync -avh $ASSETS --exclude js/vendor/ root@${DEPLOYHOST}:/data/git/pillar-svnman/svnman/static/assets/
+
+echo
+echo "*** SYNCING TEMPLATES ***"
+rsync -avh $TEMPLATES root@${DEPLOYHOST}:/data/git/pillar-svnman/svnman/templates/
diff --git a/runserver.py b/runserver.py
new file mode 100644 (file)
index 0000000..7ec2887
--- /dev/null
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+
+from pillar import PillarServer
+from svnman import SVNManExtension
+
+app = PillarServer('.')
+app.load_extension(SVNManExtension(), '/svn')
+app.process_extensions()
+
+if __name__ == '__main__':
+    app.run('::0', 5000, debug=True)
diff --git a/setup.cfg b/setup.cfg
new file mode 100644 (file)
index 0000000..914cf72
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,5 @@
+[tool:pytest]
+addopts = -v --cov svnman --cov-report term-missing --ignore node_modules
+
+[pep8]
+max-line-length = 100
diff --git a/setup.py b/setup.py
new file mode 100644 (file)
index 0000000..784ba02
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+
+"""Setup file for the Pillar SVNMan extension."""
+
+import setuptools
+
+setuptools.setup(
+    name='pillar-svnman',
+    version='0.1-dev',
+    packages=setuptools.find_packages('.', exclude=['test']),
+    install_requires=[],
+    tests_require=[
+        'pytest>=2.9.1',
+        'responses>=0.5.1',
+        'pytest-cov>=2.2.1',
+        'mock>=2.0.0',
+    ],
+    zip_safe=False,
+)
diff --git a/src/scripts/tutti/00_utils.js b/src/scripts/tutti/00_utils.js
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/templates/svnman/errors/project_not_available.pug b/src/templates/svnman/errors/project_not_available.pug
new file mode 100644 (file)
index 0000000..a721b33
--- /dev/null
@@ -0,0 +1,12 @@
+| {% 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
new file mode 100644 (file)
index 0000000..55d0707
--- /dev/null
@@ -0,0 +1,12 @@
+| {% 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/project_settings/offer_setup.pug b/src/templates/svnman/project_settings/offer_setup.pug
new file mode 100644 (file)
index 0000000..a86eb03
--- /dev/null
@@ -0,0 +1,26 @@
+| {% extends 'svnman/project_settings/svnman_layout.html'  %}
+
+| {% block svnman_container %}
+#node-edit-form
+       p This project does not have a Subversion repository
+       p
+               button.btn.btn-success(onclick='createRepo()') Create Subversion repository
+
+| {% endblock svnman_container %}
+
+| {% block footer_scripts %}
+script.
+       function setupForSubversion() {
+               $.ajax({
+                       url: '{{ url_for( "svnman.setup_for_svnman", project_url=project.url) }}',
+                       method: 'POST',
+               })
+               .done(function() {
+                       window.location.reload();
+               })
+               .fail(function(err) {
+                       var err_elt = xhrErrorResponseElement(err, 'Error setting up your project: ');
+                       toastr.error(err_elt);
+               });
+       }
+| {% endblock %}
diff --git a/src/templates/svnman/project_settings/settings.pug b/src/templates/svnman/project_settings/settings.pug
new file mode 100644 (file)
index 0000000..a7d2a70
--- /dev/null
@@ -0,0 +1,6 @@
+| {% extends 'svnman/project_settings/svnman_layout.html'  %}
+
+| {% block svnman_container %}
+#node-edit-form
+       p nothing here yet.
+| {% endblock svnman_container %}
diff --git a/src/templates/svnman/project_settings/svnman_layout.pug b/src/templates/svnman/project_settings/svnman_layout.pug
new file mode 100644 (file)
index 0000000..0843ef7
--- /dev/null
@@ -0,0 +1,23 @@
+| {% extends 'projects/edit_layout.html'  %}
+| {% set title = 'svnman' %}
+| {% block page_title %}Subversion settings for {{ project.name }}{% endblock %}
+
+| {% block head %}
+script(src="{{ url_for('static_svnman', filename='assets/js/generated/tutti.min.js') }}")
+| {% endblock %}
+
+| {% block project_context_header %}
+span#project-edit-title
+       | {{ self.page_title() }}
+| {% endblock %}
+
+| {% block project_context %}
+#node-edit-container
+       | {% block svnman_container %}
+       | {% endblock svnman_container %}
+
+       .settings-footer
+               p
+                       | New to Subversion?
+                       | Learn more at website we have yet to find.
+| {% endblock project_context %}
diff --git a/src/templates/svnman/sidebar.pug b/src/templates/svnman/sidebar.pug
new file mode 100644 (file)
index 0000000..88d94d9
--- /dev/null
@@ -0,0 +1,8 @@
+li.tabs-flamenco(
+       title="Flamenco",
+       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
+       | {% endif %}
diff --git a/svnman/__init__.py b/svnman/__init__.py
new file mode 100644 (file)
index 0000000..22dd480
--- /dev/null
@@ -0,0 +1,122 @@
+import logging
+import os.path
+
+import flask
+from werkzeug.local import LocalProxy
+
+import pillarsdk
+from pillar.extension import PillarExtension
+from pillar.auth import current_user
+
+EXTENSION_NAME = 'svnman'
+
+
+class SVNManExtension(PillarExtension):
+    user_caps = {
+        'subscriber-pro': frozenset({'svn-use'}),
+        'demo': frozenset({'svn-use'}),
+        'admin': frozenset({'svn-use', 'svn-admin'}),
+    }
+
+    def __init__(self):
+        from . import remote
+
+        self._log = logging.getLogger('%s.SVNManExtension' % __name__)
+        self.remote: remote.Remote = None
+
+    @property
+    def name(self):
+        return EXTENSION_NAME
+
+    def flask_config(self):
+        """Returns extension-specific defaults for the Flask configuration.
+
+        Use this to set sensible default values for configuration settings
+        introduced by the extension.
+
+        :rtype: dict
+        """
+
+        # Just so that it registers the management commands.
+        from . import cli
+
+        return {
+            'SVNMAN_API_URL': 'http://configure-SVNMAN_API_URL/api/',
+            'SVNMAN_API_USERNAME': 'SVNMAN_API_USERNAME',
+            'SVNMAN_API_PASSWORD': 'SVNMAN_API_PASSWORD',
+        }
+
+    def eve_settings(self):
+        """Returns extensions to the Eve settings.
+
+        Currently only the DOMAIN key is used to insert new resources into
+        Eve's configuration.
+
+        :rtype: dict
+        """
+        return {'DOMAIN': {}}
+
+    def blueprints(self):
+        """Returns the list of top-level blueprints for the extension.
+
+        These blueprints will be mounted at the url prefix given to
+        app.load_extension().
+
+        :rtype: list of flask.Blueprint objects.
+        """
+
+        from . import routes
+
+        return [
+            routes.blueprint,
+        ]
+
+    def setup_app(self, app):
+        from . import remote
+
+        self.remote = remote.Remote(
+            remote_url=app.config['SVNMAN_API_URL'],
+            username=app.config['SVNMAN_API_USERNAME'],
+            password=app.config['SVNMAN_API_PASSWORD'],
+        )
+
+    @property
+    def template_path(self):
+        return os.path.join(os.path.dirname(__file__), 'templates')
+
+    @property
+    def static_path(self):
+        return os.path.join(os.path.dirname(__file__), 'static')
+
+    def sidebar_links(self, project):
+        if not current_user.has_cap('svn-use'):
+            return ''
+        return flask.render_template('svnman/sidebar.html', project=project)
+
+    @property
+    def has_project_settings(self) -> bool:
+        return current_user.has_cap('svn-use')
+
+    def project_settings(self, project: pillarsdk.Project, **template_args: dict) -> flask.Response:
+        """Renders the project settings page for this extension.
+
+        Set YourExtension.has_project_settings = True and Pillar will call this function.
+
+        :param project: the project for which to render the settings.
+        :param template_args: additional template arguments.
+        :returns: a Flask HTTP response
+        """
+
+        from .routes import project_settings
+
+        return project_settings(project, **template_args)
+
+
+def _get_current_svnman() -> SVNManExtension:
+    """Returns the SVNMan extension of the current application."""
+
+    return flask.current_app.pillar_extensions[EXTENSION_NAME]
+
+
+current_svnman: SVNManExtension = LocalProxy(_get_current_svnman)
+"""SVNMan extension of the current app."""
diff --git a/svnman/cli.py b/svnman/cli.py
new file mode 100644 (file)
index 0000000..50982d9
--- /dev/null
@@ -0,0 +1,28 @@
+"""Commandline interface for SVNMan."""
+
+import logging
+
+from flask import current_app
+from flask_script import Manager
+
+from pillar.cli import manager
+
+log = logging.getLogger(__name__)
+
+manager_svnman = Manager(current_app, usage="Perform SVNMan operations")
+
+
+@manager_svnman.command
+def info(repo_id):
+    """Fetches repository information from the SVNMan API."""
+
+    from . import current_svnman
+
+    log.info('Fetching repository %r', repo_id)
+    repoinfo = current_svnman.remote.fetch_repo(repo_id)
+
+    log.info('Repo ID: %s', repoinfo.repo_id)
+    log.info('Access : %s', sorted(repoinfo.access))
+
+
+manager.add_command('svn', manager_svnman)
diff --git a/svnman/exceptions.py b/svnman/exceptions.py
new file mode 100644 (file)
index 0000000..177e7dd
--- /dev/null
@@ -0,0 +1,6 @@
+"""SVNMan-specific exceptions."""
+
+
+class SVNManException(Exception):
+    """Base exception for all SVNMan-specific exceptions."""
+
diff --git a/svnman/remote.py b/svnman/remote.py
new file mode 100644 (file)
index 0000000..cf6438e
--- /dev/null
@@ -0,0 +1,54 @@
+import typing
+
+import attr
+import requests
+
+from pillar import attrs_extra
+
+
+@attr.s
+class RepoDescription(object):
+    repo_id: str = attr.ib(validator=attr.validators.instance_of(str))
+    access: typing.List[str] = attr.ib(validator=attr.validators.instance_of(list))
+
+
+@attr.s
+class Remote(object):
+    # The remote URL and credentials are separate. This way we can log the
+    # URL that is used in requests without worrying about leaking creds.
+    remote_url: str = attr.ib(validator=attr.validators.instance_of(str))
+    """URL of the remote SVNMan API.
+    
+    Should probably end in '/api/'.
+    """
+
+    username: str = attr.ib(validator=attr.validators.instance_of(str))
+    """Username for authenticating ourselves with the API."""
+    password: str = attr.ib(validator=attr.validators.instance_of(str))
+    """Password for authenticating ourselves with the API."""
+
+    _log = attrs_extra.log('%s.Remote' % __name__)
+    _session = requests.Session()
+
+    def __attrs_post_init__(self):
+        from requests.adapters import HTTPAdapter
+
+        if self.username or self.password:
+            self._session.auth = (self.username, self.password)
+        self._session.mount('/', HTTPAdapter(max_retries=10))
+
+    def fetch_repo(self, repo_id: str) -> RepoDescription:
+        """Fetches repository information from the remote."""
+
+        resp = self._request('GET', f'repo/{repo_id}')
+        resp.raise_for_status()
+        return RepoDescription(**resp.json())
+
+    def _request(self, method: str, rel_url: str, **kwargs) -> requests.Response:
+        """Performs a HTTP request on the API server."""
+
+        from urllib.parse import urljoin
+
+        abs_url = urljoin(self.remote_url, rel_url)
+        self._log.getChild('request').info('%s %s', method, abs_url)
+        return self._session.request(method, abs_url, **kwargs)
diff --git a/svnman/routes.py b/svnman/routes.py
new file mode 100644 (file)
index 0000000..50ff357
--- /dev/null
@@ -0,0 +1,75 @@
+import functools
+import logging
+
+import bson
+from flask import Blueprint, render_template, redirect, url_for
+from flask_login import login_required
+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.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
+
+blueprint = Blueprint('svnman', __name__)
+log = logging.getLogger(__name__)
+
+
+@blueprint.route('/')
+def index():
+    api = pillar_api()
+
+    # FIXME Sybren: add permission check.
+    # TODO: add projections.
+    projects = current_svnman.svnman_projects()
+
+    for project in projects['_items']:
+        attach_project_pictures(project, api)
+
+    projs_with_summaries = [
+        (proj, current_svnman.job_manager.job_status_summary(proj['_id']))
+        for proj in projects['_items']
+    ]
+
+    return render_template('svnman/index.html',
+                           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.status_code = 403
+        return resp
+
+    return render_template('svnman/errors/project_not_available.html')
+
+
+@blueprint.route('/setup-for-svn')
+def setup_for_svnman(project_url):
+    return f'yeah {project_url}'
+
+
+def project_settings(project: pillarsdk.Project, **template_args: dict):
+    """Renders the project settings page for Subversion projects."""
+
+    if not current_user.has_cap('svn-use'):
+        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)
+
+    return render_template('svnman/project_settings/settings.html',
+                           project=project,
+                           **template_args)
diff --git a/tests/logging_config.py b/tests/logging_config.py
new file mode 100644 (file)
index 0000000..b2ba902
--- /dev/null
@@ -0,0 +1,26 @@
+LOGGING = {
+    'version': 1,
+    'formatters': {
+        'default': {'format': '%(asctime)-15s %(levelname)8s %(name)36s %(message)s'}
+    },
+    'handlers': {
+        'console': {
+            'class': 'logging.StreamHandler',
+            'formatter': 'default',
+            'stream': 'ext://sys.stderr',
+        }
+    },
+    'loggers': {
+        'pillar': {'level': 'DEBUG'},
+        'svnman': {'level': 'DEBUG'},
+        'werkzeug': {'level': 'INFO'},
+        'eve': {'level': 'WARNING'},
+        # 'requests': {'level': 'DEBUG'},
+    },
+    'root': {
+        'level': 'INFO',
+        'handlers': [
+            'console',
+        ],
+    }
+}
diff --git a/update_version.sh b/update_version.sh
new file mode 100755 (executable)
index 0000000..3282003
--- /dev/null
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+if [ -z "$1" ]; then
+    echo "Usage: $0 new-version" >&2
+    exit 1
+fi
+
+sed "s/version='[^']*'/version='$1'/" -i setup.py
+
+git diff
+echo
+echo "Don't forget to commit and tag:"
+echo git commit -m \'Bumped version to $1\' setup.py
+echo git tag -a v$1 -m \'Tagged version $1\'