View
@@ -0,0 +1,276 @@
# -*- coding: utf-8 -*-
import deform
import mock
import pytest
from pyramid import httpexceptions
from h.groups import views
def _mock_request(feature=None, settings=None, params=None,
authenticated_userid=None, route_url=None, **kwargs):
"""Return a mock Pyramid request object."""
params = params or {"foo": "bar"}
return mock.Mock(
feature=feature or (lambda feature: True),
registry=mock.Mock(settings=settings or {
"h.hashids.salt": "test salt"}),
params=params, POST=params,
authenticated_userid=authenticated_userid or "acct:fred@hypothes.is",
route_url=route_url or mock.Mock(return_value="test-read-url"),
**kwargs)
# The fixtures required to mock all of create_group_form()'s dependencies.
create_group_form_fixtures = pytest.mark.usefixtures('GroupSchema', 'Form')
@create_group_form_fixtures
def test_create_group_form_404s_if_groups_feature_is_off():
with pytest.raises(httpexceptions.HTTPNotFound):
views.create_group_form(_mock_request(feature=lambda feature: False))
@create_group_form_fixtures
def test_create_group_form_creates_form_with_GroupSchema(GroupSchema, Form):
test_schema = mock.Mock()
GroupSchema.return_value = mock.Mock(
bind=mock.Mock(return_value=test_schema))
views.create_group_form(request=_mock_request())
Form.assert_called_once_with(test_schema)
@create_group_form_fixtures
def test_create_group_form_returns_form(Form):
test_form = mock.Mock()
Form.return_value = test_form
template_data = views.create_group_form(request=_mock_request())
assert template_data["form"] == test_form
@create_group_form_fixtures
def test_create_group_form_returns_empty_form_data(Form):
test_form = mock.Mock()
Form.return_value = test_form
template_data = views.create_group_form(request=_mock_request())
assert template_data["data"] == {}
# The fixtures required to mock all of create_group()'s dependencies.
create_group_fixtures = pytest.mark.usefixtures(
'GroupSchema', 'Form', 'Group', 'User', 'hashids')
@create_group_fixtures
def test_create_group_404s_if_groups_feature_is_off():
with pytest.raises(httpexceptions.HTTPNotFound):
views.create_group(_mock_request(feature=lambda feature: False))
@create_group_fixtures
def test_create_group_inits_form_with_schema(GroupSchema, Form):
schema = mock.Mock()
GroupSchema.return_value = mock.Mock(bind=mock.Mock(return_value=schema))
views.create_group(request=_mock_request())
Form.assert_called_once_with(schema)
@create_group_fixtures
def test_create_group_validates_form(Form):
Form.return_value = form = mock.Mock()
form.validate.return_value = {"name": "new group"}
request = _mock_request()
views.create_group(request)
form.validate.assert_called_once_with(request.params.items())
@create_group_fixtures
def test_create_group_rerenders_form_on_validation_failure(Form):
Form.return_value = form = mock.Mock()
form.validate.side_effect = deform.ValidationFailure(None, None, None)
params = {"foo": "bar"}
template_data = views.create_group(_mock_request())
assert template_data['form'] == form
assert template_data['data'] == params
@create_group_fixtures
def test_create_group_gets_user_with_authenticated_id(Form, User):
"""It uses the "name" from the validated data to create a new group."""
Form.return_value = mock.Mock(validate=lambda data: {"name": "test-group"})
request = _mock_request()
views.create_group(request)
User.get_by_id.assert_called_once_with(
request, request.authenticated_userid)
@create_group_fixtures
def test_create_group_uses_name_from_validated_data(Form, User, Group):
"""It uses the "name" from the validated data to create a new group."""
Form.return_value = mock.Mock(validate=lambda data: {"name": "test-group"})
User.get_by_id.return_value = user = mock.Mock()
views.create_group(_mock_request())
Group.assert_called_once_with(name="test-group", creator=user)
@create_group_fixtures
def test_create_group_adds_group_to_db(Group):
"""It should add the new group to the database session."""
group = mock.Mock(id=6)
Group.return_value = group
request = _mock_request()
views.create_group(request)
request.db.add.assert_called_once_with(group)
@create_group_fixtures
def test_create_group_redirects_to_group_read_page(Group, hashids):
"""After successfully creating a new group it should redirect."""
group = mock.Mock(id='test-id', slug='test-slug')
Group.return_value = group
request = _mock_request()
hashids.encode.return_value = "testhashid"
redirect = views.create_group(request)
request.route_url.assert_called_once_with(
"group_read", hashid="testhashid", slug="test-slug")
assert redirect.status_int == 303
assert redirect.location == "test-read-url"
@create_group_fixtures
def test_create_group_with_non_ascii_name():
views.create_group(_mock_request(params={"name": u"☆ ßüper Gröup ☆"}))
# The fixtures required to mock all of read_group()'s dependencies.
read_group_fixtures = pytest.mark.usefixtures('Group', 'hashids')
@read_group_fixtures
def test_read_group_404s_if_groups_feature_is_off():
with pytest.raises(httpexceptions.HTTPNotFound):
views.read_group(_mock_request(feature=lambda feature: False))
@read_group_fixtures
def test_read_group_decodes_hashid(hashids):
request = _mock_request(matchdict={"hashid": "abc", "slug": "slug"})
views.read_group(request)
hashids.decode.assert_called_once_with(
request, "h.groups", "abc")
@read_group_fixtures
def test_read_group_gets_group_by_id(Group, hashids):
hashids.decode.return_value = 1
views.read_group(_mock_request(matchdict={"hashid": "1", "slug": "slug"}))
Group.get_by_id.assert_called_once_with(1)
@read_group_fixtures
def test_read_group_returns_the_group(Group):
group = Group.get_by_id.return_value
template_data = views.read_group(_mock_request(
matchdict={"hashid": "1", "slug": "slug"}))
assert template_data["group"] == group
@read_group_fixtures
def test_read_group_404s_when_group_does_not_exist(Group):
Group.get_by_id.return_value = None
with pytest.raises(httpexceptions.HTTPNotFound):
views.read_group(_mock_request(
matchdict={"hashid": "1", "slug": "slug"}))
@read_group_fixtures
def test_read_group_without_slug_redirects(Group):
"""/groups/<hashid> should redirect to /groups/<hashid>/<slug>."""
Group.return_value = mock.Mock(slug="my-group")
matchdict = {"hashid": "1"} # No slug.
request = _mock_request(
matchdict=matchdict,
route_url=mock.Mock(return_value="/1/my-group"))
redirect = views.read_group(request)
assert request.route_url.called_with(
"group_read", hashid="1", slug="my-group")
assert redirect.location == "/1/my-group"
@read_group_fixtures
def test_read_group_with_wrong_slug_redirects(Group):
"""/groups/<hashid>/<wrong> should redirect to /groups/<hashid>/<slug>."""
Group.return_value = mock.Mock(slug="my-group")
matchdict = {"hashid": "1", "slug": "my-gro"}
request = _mock_request(
matchdict=matchdict,
route_url=mock.Mock(return_value="/1/my-group"))
redirect = views.read_group(request)
assert request.route_url.called_with(
"group_read", hashid="1", slug="my-group")
assert redirect.location == "/1/my-group"
@pytest.fixture
def Form(request):
patcher = mock.patch('h.groups.views.deform.Form', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()
@pytest.fixture
def GroupSchema(request):
patcher = mock.patch('h.groups.views.schemas.GroupSchema', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()
@pytest.fixture
def Group(request):
patcher = mock.patch('h.groups.views.models.Group', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()
@pytest.fixture
def User(request):
patcher = mock.patch('h.groups.views.accounts_models.User', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()
@pytest.fixture
def hashids(request):
patcher = mock.patch('h.groups.views.hashids', autospec=True)
request.addfinalizer(patcher.stop)
return patcher.start()
View
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
import deform
import colander
from pyramid import httpexceptions as exc
from pyramid.view import view_config
from h.groups import schemas
from h.groups import models
from h.accounts import models as accounts_models
from h import hashids
@view_config(route_name='group_create',
request_method='GET',
renderer='h:groups/templates/create_group.html.jinja2',
permission="authenticated")
def create_group_form(request):
if not request.feature('groups'):
raise exc.HTTPNotFound()
schema = schemas.GroupSchema().bind(request=request)
form = deform.Form(schema)
return {'form': form, 'data': {}}
@view_config(route_name='group_create',
request_method='POST',
renderer='h:groups/templates/create_group.html.jinja2',
permission="authenticated")
def create_group(request):
if not request.feature('groups'):
raise exc.HTTPNotFound()
form = deform.Form(schemas.GroupSchema().bind(request=request))
try:
appstruct = form.validate(request.POST.items())
except deform.ValidationFailure as err:
return {'form': form, 'data': request.params}
user = accounts_models.User.get_by_id(
request, request.authenticated_userid)
group = models.Group(name=appstruct["name"], creator=user)
request.db.add(group)
# We need to flush the db session here so that group.id will be generated.
request.db.flush()
hashid = hashids.encode(request, "h.groups", group.id)
return exc.HTTPSeeOther(
location=request.route_url(
'group_read', hashid=hashid, slug=group.slug))
@view_config(route_name='group_read',
request_method='GET',
renderer='h:groups/templates/read_group.html.jinja2')
@view_config(route_name='group_read_noslug', request_method='GET')
def read_group(request):
if not request.feature('groups'):
raise exc.HTTPNotFound()
hashid = request.matchdict["hashid"]
slug = request.matchdict.get("slug")
group_id = hashids.decode(request, "h.groups", hashid)
group = models.Group.get_by_id(group_id)
if group is None:
raise exc.HTTPNotFound()
if slug is None or slug != group.slug:
canonical = request.route_url('group_read',
hashid=hashid,
slug=group.slug)
return exc.HTTPMovedPermanently(location=canonical)
return {"group": group}
def includeme(config):
assert config.registry.settings.get("h.hashids.salt"), (
"There needs to be a h.hashids.salt config setting")
config.add_route('group_create', '/groups/new')
# Match "/groups/<hashid>/": we redirect to the version with the slug.
config.add_route('group_read', '/groups/{hashid}/{slug:[^/]*}')
config.add_route('group_read_noslug', '/groups/{hashid}')
config.scan(__name__)
View
@@ -0,0 +1,16 @@
from __future__ import absolute_import
import hashids
def _get_hashids(request, context):
salt = request.registry.settings["h.hashids.salt"] + context
return hashids.Hashids(salt=salt, min_length=6)
def encode(request, context, number):
return _get_hashids(request, context).encode(number)
def decode(request, context, hashid):
return _get_hashids(request, context).decode(str(hashid))[0]
View
@@ -0,0 +1,37 @@
"""Add group table
Revision ID: 3bf1c2289e8d
Revises: 17a69c28b6c2
Create Date: 2015-07-20 11:50:11.639973
"""
# revision identifiers, used by Alembic.
revision = '3bf1c2289e8d'
down_revision = '17a69c28b6c2'
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table('group',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.Unicode(length=100), nullable=False),
sa.Column('created', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('updated', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('creator_id', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['creator_id'], ['user.id'])
)
op.create_table('user_group',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['group.id']),
sa.ForeignKeyConstraint(['user_id'], ['user.id'])
)
def downgrade():
op.drop_table('user_group')
op.drop_table('group')
View
@@ -4,6 +4,7 @@
from h.accounts import models as accounts_models
from h.api import models as api_models
from h.api.nipsa import models as nipsa_models
from h.groups import models as groups_models
from h.notification import models as notification_models
__all__ = (
@@ -12,6 +13,7 @@
'Client',
'Document',
'Feature',
'Group',
'NipsaUser',
'Subscriptions',
'User',
@@ -22,6 +24,7 @@
Annotation = api_models.Annotation
Document = api_models.Document
Feature = features.Feature
Group = groups_models.Group
NipsaUser = nipsa_models.NipsaUser
Subscriptions = notification_models.Subscriptions
User = accounts_models.User
View
@@ -54,6 +54,7 @@ class Stream(Resource):
class Root(Resource):
__acl__ = [
(security.Allow, 'group:admin', security.ALL_PERMISSIONS),
(security.Allow, security.Authenticated, 'authenticated'),
]
View
@@ -1,13 +1,16 @@
# -*- coding: utf-8 -*-
from pyramid.session import SignedCookieSessionFactory
from .security import derive_key
from h import hashids
from h import models
from h.security import derive_key
def model(request):
session = {}
session['csrf'] = request.session.get_csrf_token()
session['userid'] = request.authenticated_userid
session['groups'] = _current_groups(request)
return session
@@ -16,6 +19,27 @@ def pop_flash(request):
for k in ['error', 'info', 'warning', 'success']}
def _current_groups(request):
"""
Return a list of the groups the current user is a member of to be returned
to the client in the "session" model.
"""
groups = []
userid = request.authenticated_userid
if userid is None:
return groups
user = models.User.get_by_id(request, userid)
if user is None:
return groups
for g in user.groups:
hid = hashids.encode(request, 'h.groups', g.id)
groups.append({
'name': g.name,
'url': request.route_url('group_read', hashid=hid, slug=g.slug),
})
return groups
def includeme(config):
registry = config.registry
settings = registry.settings
View
@@ -3,17 +3,15 @@ angular = require('angular')
module.exports = class AppController
this.$inject = [
'$controller', '$document', '$location', '$rootScope', '$route', '$scope',
'$window',
'$controller', '$document', '$location', '$route', '$scope', '$window',
'auth', 'drafts', 'features', 'identity',
'permissions', 'streamer', 'annotationUI',
'session', 'streamer', 'annotationUI',
'annotationMapper', 'threading'
]
constructor: (
$controller, $document, $location, $rootScope, $route, $scope,
$window,
$controller, $document, $location, $route, $scope, $window,
auth, drafts, features, identity,
permissions, streamer, annotationUI,
session, streamer, annotationUI,
annotationMapper, threading
) ->
$controller('AnnotationUIController', {$scope})
@@ -23,6 +21,9 @@ module.exports = class AppController
# if ($scope.feature('foo')) { ... }
$scope.feature = features.flagEnabled
# Allow all child scopes access to the session
$scope.session = session
$scope.auth = auth
isFirstRun = $location.search().hasOwnProperty('firstrun')
View
@@ -102,6 +102,7 @@ module.exports = angular.module('h', [
.directive('deepCount', require('./directive/deep-count'))
.directive('formInput', require('./directive/form-input'))
.directive('formValidate', require('./directive/form-validate'))
.directive('groupList', require('./directive/group-list'))
.directive('markdown', require('./directive/markdown'))
.directive('privacy', require('./directive/privacy'))
.directive('simpleSearch', require('./directive/simple-search'))
View
@@ -0,0 +1,17 @@
'use strict';
/**
* @ngdoc directive
* @name groupList
* @restrict AE
* @description Displays a list of groups of which the user is a member.
*/
module.exports = function () {
return {
restrict: 'AE',
scope: {
groups: '='
},
templateUrl: 'group_list.html'
};
};
View
@@ -11,7 +11,7 @@ describe 'AppController', ->
fakeIdentity = null
fakeLocation = null
fakeParams = null
fakePermissions = null
fakeSession = null
fakeStore = null
fakeStreamer = null
fakeStreamFilter = null
@@ -68,9 +68,7 @@ describe 'AppController', ->
fakeParams = {id: 'test'}
fakePermissions = {
permits: sandbox.stub().returns(true)
}
fakeSession = {}
fakeStore = {
SearchResource: {
@@ -104,7 +102,7 @@ describe 'AppController', ->
$provide.value 'identity', fakeIdentity
$provide.value '$location', fakeLocation
$provide.value '$routeParams', fakeParams
$provide.value 'permissions', fakePermissions
$provide.value 'session', fakeSession
$provide.value 'store', fakeStore
$provide.value 'streamer', fakeStreamer
$provide.value 'streamfilter', fakeStreamFilter
View
@@ -129,6 +129,9 @@ ol {
}
}
.group-list {
margin-right: 0.5em;
}
.user-picker {
.avatar {
View
@@ -42,6 +42,12 @@
</div>
</div>
<div class="pull-right dropdown group-list"
ng-if="auth.user && feature('groups')"
group-list=""
groups="session.state.groups">
</div>
<!-- Searchbar -->
<div class="simple-search"
query="search.query"
@@ -94,6 +100,9 @@
<script type="text/ng-template" id="annotation.html">
{{ include_raw("h:templates/client/annotation.html") }}
</script>
<script type="text/ng-template" id="group_list.html">
{{ include_raw("h:templates/client/group_list.html") }}
</script>
<script type="text/ng-template" id="markdown.html">
{{ include_raw("h:templates/client/markdown.html") }}
</script>
View
@@ -0,0 +1,12 @@
<span role="button" class="dropdown-toggle" data-toggle="dropdown">
Groups
<i class="h-icon-arrow-drop-down"></i>
</span>
<ul class="dropdown-menu pull-right" role="menu">
<li ng-repeat="group in groups">
<a ng-href="{{group.url}}" ng-bind="group.name" target="_blank"></a>
</li>
<li>
<a href="/groups/new" target="_blank"><i class="h-icon-add"></i> Create a group</a>
</li>
</ul>
View
@@ -44,6 +44,8 @@ def run_tests(self):
'gevent>=1.0.2,<1.1.0',
'gnsq>=0.3.0,<0.4.0',
'gunicorn>=19.2,<20',
'hashids==1.1.0', # hashid format may change with updated hashid package,
# so always pin.
'jsonpointer==1.0',
'jsonschema==1.3.0',
'oauthlib==0.6.3',
@@ -52,6 +54,7 @@ def run_tests(self):
'pyramid-oauthlib>=0.2.0,<0.3.0',
'pyramid_tm>=0.7',
'python-dateutil>=2.1',
'python-slugify>=1.1.3,<1.2.0',
'python-statsd>=1.7.0,<1.8.0',
'pyramid_webassets>=0.9,<1.0',
'pyramid-jinja2>=2.3.3',