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/02/21 17:33:55 UTC

[2/2] git commit: [#5658] Admin UI for posting a task

Updated Branches:
  refs/heads/master 7f209c952 -> cc78b920e


[#5658] Admin UI for posting a task


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

Branch: refs/heads/master
Commit: 550d4f9c4624cff82b488d51beb46c5e1d2bd11d
Parents: 7f209c9
Author: Tim Van Steenburgh <tv...@gmail.com>
Authored: Wed Feb 20 19:39:52 2013 +0000
Committer: Cory Johns <jo...@geek.net>
Committed: Thu Feb 21 16:32:23 2013 +0000

----------------------------------------------------------------------
 Allura/allura/controllers/site_admin.py           |   41 ++++++-
 Allura/allura/lib/validators.py                   |   77 +++++++++++
 Allura/allura/templates/site_admin_task_list.html |    2 +
 Allura/allura/templates/site_admin_task_new.html  |  112 ++++++++++++++++
 Allura/allura/tests/functional/test_site_admin.py |   42 ++++++-
 Allura/allura/tests/test_validators.py            |  104 ++++++++++++++
 6 files changed, 375 insertions(+), 3 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/550d4f9c/Allura/allura/controllers/site_admin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/controllers/site_admin.py b/Allura/allura/controllers/site_admin.py
index a4d5f64..7bb2c5d 100644
--- a/Allura/allura/controllers/site_admin.py
+++ b/Allura/allura/controllers/site_admin.py
@@ -1,9 +1,10 @@
 import re
+import json
 import logging
 from datetime import datetime, timedelta
 from collections import defaultdict
 
-from tg import expose, validate, flash, config, request
+from tg import expose, validate, flash, config, redirect
 from tg.decorators import with_trailing_slash, without_trailing_slash
 from ming.orm import session
 import pymongo
@@ -11,9 +12,11 @@ import bson
 import tg
 from pylons import tmpl_context as c, app_globals as g
 from pylons import request
-from formencode import validators
+from formencode import validators, Invalid
 
 from allura.lib import helpers as h
+from allura.lib import validators as v
+from allura.lib.decorators import require_post
 from allura.lib.security import require_access
 from allura.lib.widgets import form_fields as ffw
 from allura import model as M
@@ -303,3 +306,37 @@ class TaskManagerController(object):
             task.app_config = M.AppConfig.query.get(_id=task.context.app_config_id)
             task.user = M.User.query.get(_id=task.context.user_id)
         return dict(task=task)
+
+    @expose('jinja:allura:templates/site_admin_task_new.html')
+    @without_trailing_slash
+    def new(self, **kw):
+        """Render the New Task form"""
+        return dict(
+            form_errors=c.form_errors or {},
+            form_values=c.form_values or {},
+        )
+
+    @expose()
+    @require_post()
+    @validate(v.CreateTaskSchema(), error_handler=new)
+    def create(self, task, task_args=None, user=None, path=None):
+        """Post a new task"""
+        args = task_args.get("args", ())
+        kw = task_args.get("kwargs", {})
+        config_dict = path
+        if user:
+            config_dict['user'] = user
+        with h.push_config(c, **config_dict):
+            task = task.post(*args, **kw)
+        redirect('view', task_id=task._id)
+
+    @expose('json:')
+    def task_doc(self, task_name):
+        """Return a task's docstring"""
+        error, doc = None, None
+        try:
+            task = v.TaskValidator.to_python(task_name)
+            doc = task.__doc__ or 'No doc string available'
+        except Invalid as e:
+            error = str(e)
+        return dict(doc=doc, error=error)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/550d4f9c/Allura/allura/lib/validators.py
----------------------------------------------------------------------
diff --git a/Allura/allura/lib/validators.py b/Allura/allura/lib/validators.py
index b7ff459..de6bf51 100644
--- a/Allura/allura/lib/validators.py
+++ b/Allura/allura/lib/validators.py
@@ -55,7 +55,69 @@ class MaxBytesValidator(fev.FancyValidator):
     def from_python(self, value, state):
         return h.really_unicode(value or '')
 
+class TaskValidator(fev.FancyValidator):
+    def _to_python(self, value, state):
+        try:
+            mod, func = value.rsplit('.', 1)
+        except ValueError:
+            raise fe.Invalid('Invalid task name. Please provide the full '
+                    'dotted path to the python callable.', value, state)
+        try:
+            mod = __import__(mod, fromlist=[str(func)])
+        except ImportError:
+            raise fe.Invalid('Could not import "%s"' % value, value, state)
+
+        try:
+            task = getattr(mod, func)
+        except AttributeError:
+            raise fe.Invalid('Module has no attribute "%s"' % func, value, state)
+
+        if not hasattr(task, 'post'):
+            raise fe.Invalid('"%s" is not a task.' % value, value, state)
+        return task
+
+class UserValidator(fev.FancyValidator):
+    def _to_python(self, value, state):
+        from allura import model as M
+        user = M.User.by_username(value)
+        if not user:
+            raise fe.Invalid('Invalid username', value, state)
+        return user
+
+class PathValidator(fev.FancyValidator):
+    def _to_python(self, value, state):
+        from allura import model as M
+
+        parts = value.strip('/').split('/')
+        if len(parts) < 2:
+            raise fe.Invalid("You must specify at least a neighborhood and "
+                "project, i.e. '/nbhd/project'", value, state)
+        elif len(parts) == 2:
+            nbhd_name, project_name, app_name = parts[0], parts[1], None
+        elif len(parts) > 2:
+            nbhd_name, project_name, app_name = parts[0], parts[1], parts[2]
+
+        path_parts = {}
+        nbhd_url_prefix = '/%s/' % nbhd_name
+        nbhd = M.Neighborhood.query.get(url_prefix=nbhd_url_prefix)
+        if not nbhd:
+            raise fe.Invalid('Invalid neighborhood: %s' % nbhd_url_prefix, value, state)
+
+        project = M.Project.query.get(shortname=project_name, neighborhood_id=nbhd._id)
+        if not project:
+            raise fe.Invalid('Invalid project: %s' % project_name, value, state)
+
+        path_parts['project'] = project
+        if app_name:
+            app = project.app_instance(app_name)
+            if not app:
+                raise fe.Invalid('Invalid app mount point: %s' % app_name, value, state)
+            path_parts['app'] = app
+
+        return path_parts
+
 class JsonValidator(fev.FancyValidator):
+    """Validates a string as JSON and returns the original string"""
     def _to_python(self, value, state):
         try:
             json.loads(value)
@@ -63,6 +125,21 @@ class JsonValidator(fev.FancyValidator):
             raise fe.Invalid('Invalid JSON: ' + str(e), value, state)
         return value
 
+class JsonConverter(fev.FancyValidator):
+    """Deserializes a string to JSON and returns a Python object"""
+    def _to_python(self, value, state):
+        try:
+            obj = json.loads(value)
+        except ValueError, e:
+            raise fe.Invalid('Invalid JSON: ' + str(e), value, state)
+        return obj
+
+class CreateTaskSchema(fe.Schema):
+    task = TaskValidator(not_empty=True, strip=True)
+    task_args = JsonConverter(if_missing=dict(args=[], kwargs={}))
+    user = UserValidator(strip=True, if_missing=None)
+    path = PathValidator(strip=True, if_missing={})
+
 class DateValidator(fev.FancyValidator):
     def _to_python(self, value, state):
         value = convertDate(value)

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/550d4f9c/Allura/allura/templates/site_admin_task_list.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/site_admin_task_list.html b/Allura/allura/templates/site_admin_task_list.html
index 5862a9a..5539a94 100644
--- a/Allura/allura/templates/site_admin_task_list.html
+++ b/Allura/allura/templates/site_admin_task_list.html
@@ -52,6 +52,8 @@
     <input type="submit" />
     <input type="hidden" name="page_num" value="{{ page_num }}" />
     <input type="hidden" name="minutes" value="{{ minutes }}" />
+
+    <a href="task_manager/new">Create a new task</a>
 </form>
 {{ _paging() }}
 <div class="paging-window">

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/550d4f9c/Allura/allura/templates/site_admin_task_new.html
----------------------------------------------------------------------
diff --git a/Allura/allura/templates/site_admin_task_new.html b/Allura/allura/templates/site_admin_task_new.html
new file mode 100644
index 0000000..45cb0f6
--- /dev/null
+++ b/Allura/allura/templates/site_admin_task_new.html
@@ -0,0 +1,112 @@
+{% set page="task_manager" %}
+{% set sidebar_rel = '../' %}
+{% extends 'allura:templates/site_admin.html' %}
+
+{% block extra_css %}
+<style>
+  form {
+    margin: 1em;
+  }
+  form > div {
+    margin-bottom: 1em;
+  }
+  form > div > *{
+    display: inline-block;
+    vertical-align: top;
+  }
+  form > div input,
+  form > div textarea,
+  form > .input {
+    display: block;
+    width: 300px;
+  }
+  form > div label {
+    width: 100px;
+  }
+  .error {
+    width: 300px;
+    background: none;
+    border: none;
+    color: #f00;
+    margin: 0;
+    padding: 0 0 0 .8em;
+  }
+  pre.doc {
+    clear: left;
+  }
+  .note {
+    font-size: small;
+    font-style: italic;
+  }
+</style>
+{% endblock %}
+
+{% macro error(field) %}
+  {% if form_errors.get(field) %}
+  <span class="error">{{form_errors.get(field)}}</span>
+  {% endif %}
+{% endmacro %}
+
+{% block content %}
+<h2>New Task</h2>
+<form method="POST" action="create" id="newtask">
+  <div>
+    <label>Task Name *</label>
+    <div class="input">
+      <input name="task" value="{{form_values.get('task', '')}}" />
+      <span class="note">Dotted python path to task callable</span>
+    </div>
+    {{error('task')}}
+  </div>
+  <div>
+    <label>c.user</label>
+    <div class="input">
+      <input name="user" value="{{form_values.get('user', '')}}" />
+      <span class="note">Username</span>
+    </div>
+    {{error('user')}}
+  </div>
+  <div>
+    <label>c.project/c.app</label>
+    <div class="input">
+      <input name="path" value="{{form_values.get('path', '')}}" />
+      <span class="note">e.g. /p/allura or /p/allura/git</span>
+    </div>
+    {{error('path')}}
+  </div>
+  <div>
+    <label>Task args/kwargs</label>
+    <div class="input">
+      <textarea name="task_args" rows="4">{{form_values.get('task_args', '{\n    "args": [],\n    "kwargs": {}\n}')}}</textarea>
+    </div>
+    {{error('task_args')}}
+  </div>
+
+  <input type="submit" /><br/>
+
+  <pre class="doc"></pre>
+</form>
+{% endblock %}
+
+{% block extra_js %}
+<script>
+  $(function() {
+    var $task = $('input[name=task]');
+    $task.blur(function() {
+      $.get("task_doc", {task_name: $task.val()}, function(data) {
+        $task.parent().siblings('.error').remove();
+        $('pre.doc').empty();
+        if (data.error) {
+          $task.parent().after('<span class="error">' + data.error + '</span>');
+        }
+        if (data.doc) {
+          $('pre.doc').html(data.doc);
+        }
+      });
+    });
+    if ($task.val().trim() !== '') {
+      $task.blur();
+    }
+  });
+</script>
+{% endblock %}

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/550d4f9c/Allura/allura/tests/functional/test_site_admin.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/functional/test_site_admin.py b/Allura/allura/tests/functional/test_site_admin.py
index 6483b0f..b5f2d10 100644
--- a/Allura/allura/tests/functional/test_site_admin.py
+++ b/Allura/allura/tests/functional/test_site_admin.py
@@ -1,6 +1,8 @@
+import json
+
 from allura import model as M
 from allura.tests import TestController
-
+from allura.lib.decorators import task
 
 class TestSiteAdmin(TestController):
 
@@ -87,3 +89,41 @@ class TestSiteAdmin(TestController):
         r = self.app.get(url, extra_environ=dict(username='*anonymous'), status=302)
         r = self.app.get(url)
         assert 'math.ceil' in r, r
+
+    def test_task_new(self):
+        r = self.app.get('/nf/admin/task_manager/new')
+        assert 'New Task' in r, r
+
+    def test_task_create(self):
+        project = M.Project.query.get(shortname='test')
+        app = project.app_instance('admin')
+        user = M.User.by_username('root')
+
+        task_args = dict(
+                args=['foo'],
+                kwargs=dict(bar='baz'))
+
+        r = self.app.post('/nf/admin/task_manager/create', params=dict(
+            task='allura.tests.functional.test_site_admin.test_task',
+            task_args=json.dumps(task_args),
+            user='root',
+            path='/p/test/admin',
+            ), status=302)
+        task = M.MonQTask.query.find({}).sort('_id', -1).next()
+        assert str(task._id) in r.location
+        assert task.context['project_id'] == project._id
+        assert task.context['app_config_id'] == app.config._id
+        assert task.context['user_id'] == user._id
+        assert task.args == task_args['args']
+        assert task.kwargs == task_args['kwargs']
+
+    def test_task_doc(self):
+        r = self.app.get('/nf/admin/task_manager/task_doc', params=dict(
+            task_name='allura.tests.functional.test_site_admin.test_task'))
+        assert json.loads(r.body)['doc'] == 'test_task doc string'
+
+
+@task
+def test_task(*args, **kw):
+    """test_task doc string"""
+    pass

http://git-wip-us.apache.org/repos/asf/incubator-allura/blob/550d4f9c/Allura/allura/tests/test_validators.py
----------------------------------------------------------------------
diff --git a/Allura/allura/tests/test_validators.py b/Allura/allura/tests/test_validators.py
new file mode 100644
index 0000000..5472e2d
--- /dev/null
+++ b/Allura/allura/tests/test_validators.py
@@ -0,0 +1,104 @@
+import unittest
+import formencode as fe
+
+from allura import model as M
+from allura.lib import validators as v
+from allura.lib.decorators import task
+from alluratest.controller import setup_basic_test
+
+
+def setUp():
+    setup_basic_test()
+
+
+@task
+def dummy_task(*args, **kw):
+    pass
+
+
+class TestJsonConverter(unittest.TestCase):
+    val = v.JsonConverter
+
+    def test_valid(self):
+        self.assertEqual({}, self.val.to_python('{}'))
+
+    def test_invalid(self):
+        with self.assertRaises(fe.Invalid):
+            self.val.to_python('{')
+
+
+class TestUserValidator(unittest.TestCase):
+    val = v.UserValidator
+
+    def test_valid(self):
+        self.assertEqual(M.User.by_username('root'), self.val.to_python('root'))
+
+    def test_invalid(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('fakeuser')
+        self.assertEqual(str(cm.exception), "Invalid username")
+
+
+class TestTaskValidator(unittest.TestCase):
+    val = v.TaskValidator
+
+    def test_valid(self):
+        self.assertEqual(dummy_task, self.val.to_python('allura.tests.test_validators.dummy_task'))
+
+    def test_invalid_name(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('badname')
+        self.assertTrue(str(cm.exception).startswith('Invalid task name'))
+
+    def test_import_failure(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('allura.does.not.exist')
+        self.assertEqual(str(cm.exception), 'Could not import "allura.does.not.exist"')
+
+    def test_attr_lookup_failure(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('allura.tests.test_validators.typo')
+        self.assertEqual(str(cm.exception), 'Module has no attribute "typo"')
+
+    def test_not_a_task(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('allura.tests.test_validators.setUp')
+        self.assertEqual(str(cm.exception), '"allura.tests.test_validators.setUp" is not a task.')
+
+
+class TestPathValidator(unittest.TestCase):
+    val = v.PathValidator
+
+    def test_valid_project(self):
+        project = M.Project.query.get(shortname='test')
+        d = self.val.to_python('/p/test')
+        self.assertEqual(d['project'], project)
+        self.assertTrue('app' not in d)
+
+    def test_valid_app(self):
+        project = M.Project.query.get(shortname='test')
+        app = project.app_instance('admin')
+        d = self.val.to_python('/p/test/admin/')
+        self.assertEqual(d['project'], project)
+        self.assertEqual(d['app'].config._id, app.config._id)
+
+    def test_invalid_format(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('test')
+        self.assertTrue(str(cm.exception).startswith(
+            'You must specify at least a neighborhood and project'))
+
+    def test_invalid_neighborhood(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('/q/test')
+        self.assertEqual(str(cm.exception), 'Invalid neighborhood: /q/')
+
+    def test_invalid_project(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('/p/badproject')
+        self.assertEqual(str(cm.exception), 'Invalid project: badproject')
+
+    def test_invalid_app_mount_point(self):
+        with self.assertRaises(fe.Invalid) as cm:
+            self.val.to_python('/p/test/badapp')
+        self.assertEqual(str(cm.exception), 'Invalid app mount point: badapp')