diff --git a/.travis.yml b/.travis.yml
index 51c3295..27ef003 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -7,7 +7,7 @@ services:
- elasticsearch
install:
- - "pip install flake8"
+ - "pip install flake8 sphinx"
- "python setup.py install"
before_script:
@@ -16,3 +16,4 @@ before_script:
script:
- 'flake8'
- python setup.py test
+ - "python setup.py build_sphinx"
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
new file mode 100644
index 0000000..7c6aeab
--- /dev/null
+++ b/CHANGELOG.rst
@@ -0,0 +1,35 @@
+===================
+Libreant changelog
+===================
+
+0.3
++++
+
+Major changes:
+--------------
+- Implemented a role-based access control layer.
+ This means that libreant now support the common ``login`` procedure.
+ This functionality isn't documented yet, anyway you can use the brand new ``libreant-users`` command to manage users, groups and capabilities,
+ and enable this feature at runtime with the ``--users-db`` parameter.
+ The default user is (user: admin, password: admin)
+
+Web interface:
+--------------
+- Added possibility to delete a volume through a button on the single-volume-view page.
+- New user menu (only in users-mode)
+- New login/logut pages.
+- Improoved error messages/pages
+
+Deployment:
+-----------
+- Removed elasticsearch strong dependecy.
+ Now libreant can be started with elasticsearch still not ready or not running.
+- Bugfix: make libreant command exits with code 1 on exception.
+- Fixed ``elasticsearch-py`` version dependency. Now the version must be ``>=1`` and ``<2``.
+- Reloader is used only in debug mode (``--debug``).
+- More uniform logs.
+
+Documentation:
+--------------
+- The suggested version for elasticsearch installation has been updated: ``1.4`` -> ``1.7``
+- A lot of packages have been inserted in the official docs.
diff --git a/DATA.mdwn b/DATA.mdwn
deleted file mode 100644
index 130d2d2..0000000
--- a/DATA.mdwn
+++ /dev/null
@@ -1,25 +0,0 @@
-Rationale
-===========
-
-You have installed elasticsearch, setup your virtualenv and your python dependencies. Now you want to test it
-a bit. Or maybe you want to develop, and need some "lorem ipsum" data. This howto is for you!
-
-HowTo
-=====
-
-First of all, make sure that elasticsearch is running.
-
-Then, "enter" your virtualenv. This typically is
-
-```sh
-source ve/bin/activate
-```
-
-Now run
-```sh
-curl -s 'http://boyska.s.pt-labs.net/libreant/contrib/colibri.json' 'http://boyska.s.pt-labs.net/libreant/contrib/rabbia/rabbia.json' 'http://boyska.s.pt-labs.net/libreant/contrib/csv2json/info_forte.json' | webant-manage db_import -
-```
-
-This will fetch three sample "libraries" and add every book to your elasticsearch.
-
-Of course you can exclude the ones that you don't like, or create your own.
diff --git a/MANIFEST.in b/MANIFEST.in
index cab3948..726d9c0 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -3,3 +3,4 @@ recursive-include webant/templates *
recursive-include webant/translations *.po
include msgfmt.py
include README.rst
+include CHANGELOG.rst
diff --git a/Vagrantfile b/Vagrantfile
index f330258..a5846f4 100644
--- a/Vagrantfile
+++ b/Vagrantfile
@@ -14,7 +14,7 @@ end
$script = <
+
+{% endblock scripts%}
diff --git a/webant/templates/error.html b/webant/templates/error.html
index 59000bd..846c416 100644
--- a/webant/templates/error.html
+++ b/webant/templates/error.html
@@ -6,7 +6,7 @@
{% endblock %}
{% block navbar %}
-{% import 'navbar.html' as navbar %}
+{% import 'navbar.html' as navbar with context %}
{{navbar.navbar(search=True)}}
{% endblock %}
diff --git a/webant/templates/index.html b/webant/templates/index.html
index 30df406..c620e46 100644
--- a/webant/templates/index.html
+++ b/webant/templates/index.html
@@ -7,7 +7,7 @@
{% endblock %}
{% block navbar %}
-{% import 'navbar.html' as navbar %}
+{% import 'navbar.html' as navbar with context %}
{{navbar.navbar(search=False)}}
{% endblock %}
diff --git a/webant/templates/login.html b/webant/templates/login.html
new file mode 100644
index 0000000..93799ed
--- /dev/null
+++ b/webant/templates/login.html
@@ -0,0 +1,41 @@
+{% extends "bootstrap/base.html" %}
+{% import "bootstrap/fixes.html" as fixes %}
+{% import 'searchbar.html' as searchbar %}
+
+{% block title %}
+Libreant | {%trans%}Login{%endtrans%}
+{% endblock %}
+
+{% block navbar %}
+{% import 'navbar.html' as navbar with context%}
+{{navbar.navbar()}}
+{% endblock %}
+
+{% block styles %}
+{{ super() }}
+{% endblock styles %}
+
+{% block content %}
+
+
+
+ {% if message %}
+
+ {{ message }}
+
+ {% endif %}
+
+
+
+
+{% endblock content %}
diff --git a/webant/templates/navbar.html b/webant/templates/navbar.html
index 5f0e729..f30c118 100644
--- a/webant/templates/navbar.html
+++ b/webant/templates/navbar.html
@@ -1,7 +1,7 @@
{% macro navbar(search=True, search_query="") %}
diff --git a/webant/templates/recents.html b/webant/templates/recents.html
index d047192..c47e7e0 100644
--- a/webant/templates/recents.html
+++ b/webant/templates/recents.html
@@ -22,7 +22,7 @@
{% endblock %}
{% block navbar %}
-{% import 'navbar.html' as navbar %}
+{% import 'navbar.html' as navbar with context %}
{{ navbar.navbar(search=True) }}
{% endblock %}
diff --git a/webant/templates/search.html b/webant/templates/search.html
index 040f7af..dd4109f 100644
--- a/webant/templates/search.html
+++ b/webant/templates/search.html
@@ -7,7 +7,7 @@
{% endblock %}
{% block navbar %}
-{% import 'navbar.html' as navbar %}
+{% import 'navbar.html' as navbar with context %}
{{navbar.navbar(search=False)}}
{% endblock %}
diff --git a/webant/test/__init__.py b/webant/test/__init__.py
new file mode 100644
index 0000000..c951e12
--- /dev/null
+++ b/webant/test/__init__.py
@@ -0,0 +1,13 @@
+import unittest
+from webant import create_app
+from conf.defaults import get_def_conf
+
+
+class WebantTestCase(unittest.TestCase):
+
+ def setUp(self):
+ conf = get_def_conf()
+ conf['USERS_DATABASE'] = "sqlite:///:memory:"
+ conf['PWD_ROUNDS'] = 1
+ conf['TESTING'] = True
+ self.wtc = create_app(conf).test_client()
diff --git a/webant/test/api/__init__.py b/webant/test/api/__init__.py
new file mode 100644
index 0000000..cd79233
--- /dev/null
+++ b/webant/test/api/__init__.py
@@ -0,0 +1,42 @@
+from webant.test import WebantTestCase
+from flask.json import loads, dumps
+
+
+class WebantTestApiCase(WebantTestCase):
+
+ API_PREFIX = '/api/v1'
+
+ GRP_URI = API_PREFIX + '/groups/'
+ USR_URI = API_PREFIX + '/users/'
+ CAP_URI = API_PREFIX + '/capabilities/'
+
+ def add_user(self, userData):
+ res = self.wtc.post(self.API_PREFIX + '/users/',
+ data=dumps(userData),
+ content_type="application/json")
+ if not res.status_code == 201:
+ raise ApiClientError(res)
+ return loads(res.data)['data']['id']
+
+ def add_group(self, groupData):
+ res = self.wtc.post(self.GRP_URI,
+ data=dumps(groupData),
+ content_type="application/json")
+ if not res.status_code == 201:
+ raise ApiClientError(res)
+ return loads(res.data)['data']['id']
+
+ def add_capability(self, capData):
+ res = self.wtc.post(self.CAP_URI,
+ data=dumps(capData),
+ content_type="application/json")
+ if not res.status_code == 201:
+ raise ApiClientError(res)
+ return loads(res.data)['data']['id']
+
+
+class ApiClientError(Exception):
+
+ def __init__(self, res):
+ super(ApiClientError, self).__init__(loads(res.data))
+ self.res = res
diff --git a/webant/test/api/test_api_capabilities.py b/webant/test/api/test_api_capabilities.py
new file mode 100644
index 0000000..f2296cb
--- /dev/null
+++ b/webant/test/api/test_api_capabilities.py
@@ -0,0 +1,152 @@
+from webant.test.api import WebantTestApiCase, ApiClientError
+from nose.tools import eq_
+from flask.json import loads, dumps
+
+
+class TestApiCapabilities(WebantTestApiCase):
+
+ def test_get_capability_not_exist(self):
+ r = self.wtc.get(self.CAP_URI + 'a1s2d')
+ eq_(r.status_code, 404)
+ r = self.wtc.get(self.CAP_URI + '10233')
+ eq_(r.status_code, 404)
+
+ def test_add_capability(self):
+ self.add_capability({'domain':'/res1/id1/res2/*', 'actions':['READ','UPDATE']})
+
+ def test_add_capability_wrong_content_type(self):
+ r = self.wtc.post(self.CAP_URI,
+ data=dumps({'domain':'/res1/id1/res2/*', 'actions':['READ','UPDATE']}))
+ eq_(r.status_code, 415)
+ r = self.wtc.post(self.CAP_URI,
+ data="asdasd",
+ content_type="application/json")
+ eq_(r.status_code, 400)
+
+ def test_add_capability_no_actions(self):
+ with self.assertRaises(ApiClientError) as ace:
+ self.add_capability({'domain':'/res1/id1/res2/*'})
+ eq_(ace.res.status_code, 400)
+
+ def test_add_capability_no_domain(self):
+ with self.assertRaises(ApiClientError) as ace:
+ self.add_capability({'actions':['UPDATE', 'DELETE']})
+ eq_(ace.res.status_code, 400)
+
+ def test_add_capability_same_name(self):
+ capData = {'domain':'/res1/id1/res2/*', 'actions':['READ','UPDATE']}
+ self.add_capability(capData)
+ self.add_capability(capData)
+
+ def test_get_capability(self):
+ capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']}
+ capID = self.add_capability(capData)
+ rg = self.wtc.get(self.CAP_URI + str(capID))
+ eq_(rg.status_code, 200)
+ capDataRet = loads(rg.data)['data']
+ eq_(capDataRet['domain'], capData['domain'])
+ eq_(capDataRet['actions'], capData['actions'])
+
+ def test_delete_capability(self):
+ capData = {'domain':'*', 'actions':['DELETE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.delete(self.CAP_URI + str(capID))
+ eq_(r.status_code, 200)
+
+ def test_delete_capability_not_exists(self):
+ r = self.wtc.delete(self.CAP_URI + str(2))
+ eq_(r.status_code, 404)
+
+ def test_update_capability(self):
+ capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.patch(self.CAP_URI + str(capID),
+ data=dumps({'actions':['READ']}),
+ content_type="application/json")
+ eq_(r.status_code, 200)
+ r = self.wtc.get(self.CAP_URI + str(capID))
+ eq_(loads(r.data)['data']['actions'], ['READ'])
+ r = self.wtc.patch(self.CAP_URI + str(capID),
+ data=dumps({'domain':'*'}),
+ content_type="application/json")
+ eq_(r.status_code, 200)
+ r = self.wtc.get(self.CAP_URI + str(capID))
+ eq_(loads(r.data)['data']['domain'], '*')
+
+ def test_update_capability_wrong_content_type(self):
+ capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.patch(self.CAP_URI + str(capID),
+ data=dumps(capData))
+ eq_(r.status_code, 415)
+ r = self.wtc.patch(self.CAP_URI + str(capID),
+ data="asdasd",
+ content_type="application/json")
+ eq_(r.status_code, 400)
+
+ def test_update_capability_not_exist(self):
+ capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']}
+ r = self.wtc.patch(self.CAP_URI + str(50),
+ content_type="application/json",
+ data=dumps(capData))
+ eq_(r.status_code, 404)
+
+ def test_add_capability_to_group(self):
+ gid = self.add_group({'name':'groupName'})
+ capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID))
+ eq_(r.status_code, 200)
+
+ def test_add_capabilty_to_group_not_exist(self):
+ gid = self.add_group({'name':'groupName'})
+ capData = {'domain':'res1/id1/res2/*', 'actions':['READ','UPDATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.put(self.GRP_URI + str(123) + '/capabilities/' + str(capID))
+ eq_(r.status_code, 404)
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(123))
+ eq_(r.status_code, 404)
+
+ def test_get_capability_in_group(self):
+ gid = self.add_group({'name':'groupName'})
+ capData = {'domain':'res1/*', 'actions':['CREATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID))
+ r = self.wtc.get(self.GRP_URI + str(gid) + '/capabilities/')
+ eq_(r.status_code, 200)
+ eq_(loads(r.data)['data'][0]['id'], capID)
+
+ def test_get_capabilities_of_group_not_exist(self):
+ r = self.wtc.get(self.GRP_URI + str(1233) + '/capabilities/')
+ eq_(r.status_code, 404)
+
+ def test_get_groups_with_capability(self):
+ gid = self.add_group({'name':'groupName'})
+ capData = {'domain':'res1/*', 'actions':['CREATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID))
+ r = self.wtc.get(self.CAP_URI + str(capID) + '/groups/')
+ eq_(r.status_code, 200)
+ eq_(loads(r.data)['data'][0]['id'], gid)
+
+ def test_get_groups_with_capability_not_exist(self):
+ r = self.wtc.get(self.CAP_URI + str(1233) + '/groups/')
+ eq_(r.status_code, 404)
+
+ def test_remove_capabilty_from_group(self):
+ gid = self.add_group({'name':'groupName'})
+ capData = {'domain':'res1/*', 'actions':['CREATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID))
+ r = self.wtc.delete(self.GRP_URI + str(gid) + '/capabilities/' + str(capID))
+ eq_(r.status_code, 200)
+
+ def test_remove_capability_from_group_not_exist(self):
+ gid = self.add_group({'name':'groupName'})
+ capData = {'domain':'res1/*', 'actions':['CREATE']}
+ capID = self.add_capability(capData)
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/capabilities/' + str(capID))
+ r = self.wtc.delete(self.GRP_URI + str(gid) + '/capabilities/' + str(3214))
+ eq_(r.status_code, 404)
+ r = self.wtc.delete(self.GRP_URI + str(3123) + '/capabilities/' + str(capID))
+ eq_(r.status_code, 404)
diff --git a/webant/test/api/test_api_groups.py b/webant/test/api/test_api_groups.py
new file mode 100644
index 0000000..b180aff
--- /dev/null
+++ b/webant/test/api/test_api_groups.py
@@ -0,0 +1,129 @@
+from webant.test.api import WebantTestApiCase, ApiClientError
+from nose.tools import eq_
+from flask.json import loads, dumps
+
+
+class TestApiGroups(WebantTestApiCase):
+
+ def test_get_group_not_exist(self):
+ r = self.wtc.get(self.GRP_URI + 'a1s2d')
+ eq_(r.status_code, 404)
+ r = self.wtc.get(self.GRP_URI + '10233')
+ eq_(r.status_code, 404)
+
+ def test_add_group(self):
+ self.add_group({'name':'groupName'})
+
+ def test_add_group_wrong_content_type(self):
+ r = self.wtc.post(self.GRP_URI,
+ data=dumps({'name':'groupStoName'}))
+ eq_(r.status_code, 415)
+ r = self.wtc.post(self.GRP_URI,
+ data="asdasd",
+ content_type="application/json")
+ eq_(r.status_code, 400)
+
+ def test_add_group_no_name(self):
+ with self.assertRaises(ApiClientError) as ace:
+ self.add_group({'altro':'altroValue'})
+ eq_(ace.res.status_code, 400)
+
+ def test_add_group_same_name(self):
+ self.add_group({'name':'groupName'})
+ with self.assertRaises(ApiClientError) as ace:
+ self.add_group({'name':'groupName'})
+ eq_(ace.res.status_code, 409)
+
+ def test_get_group(self):
+ gid = self.add_group({'name':'groupName'})
+ rg = self.wtc.get(self.GRP_URI + str(gid))
+ eq_(rg.status_code, 200)
+
+ def test_delete_group(self):
+ gid = self.add_group({'name':'groupName'})
+ r = self.wtc.delete(self.GRP_URI + str(gid))
+ eq_(r.status_code, 200)
+
+ def test_delete_grop_not_exists(self):
+ r = self.wtc.delete(self.GRP_URI + str(56))
+ eq_(r.status_code, 404)
+
+ def test_update_group(self):
+ gid = self.add_group({'name':'groupName'})
+ r = self.wtc.patch(self.GRP_URI + str(gid),
+ data=dumps({'name':'otherGroupName'}),
+ content_type="application/json")
+ eq_(r.status_code, 200)
+ r = self.wtc.get(self.GRP_URI + str(gid))
+ eq_(loads(r.data)['data']['name'], 'otherGroupName')
+
+ def test_update_group_wrong_content_type(self):
+ gid = self.add_group({'name':'groupName'})
+ r = self.wtc.patch(self.GRP_URI + str(gid),
+ data=dumps({'name':'groupStoName'}))
+ eq_(r.status_code, 415)
+ r = self.wtc.patch(self.GRP_URI + str(gid),
+ data="asdasd",
+ content_type="application/json")
+ eq_(r.status_code, 400)
+
+ def test_update_group_not_exist(self):
+ self.add_group({'name':'groupName'})
+ r = self.wtc.patch(self.GRP_URI + str(50),
+ content_type="application/json",
+ data=dumps({'name':'seStaAFaTardi'}))
+ eq_(r.status_code, 404)
+
+ def test_add_user_to_group(self):
+ gid = self.add_group({'name':'groupName'})
+ uid = self.add_user({'name':'userName', 'password':'pwd'})
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid))
+ eq_(r.status_code, 200)
+
+ def test_add_user_to_group_not_exist(self):
+ uid = self.add_user({'name':'userName', 'password':'pwd'})
+ gid = self.add_group({'name':'groupName'})
+ r = self.wtc.put(self.GRP_URI + str(123) + '/users/' + str(uid))
+ eq_(r.status_code, 404)
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(123))
+ eq_(r.status_code, 404)
+
+ def test_get_users_in_group(self):
+ gid = self.add_group({'name':'groupName'})
+ uid = self.add_user({'name':'userName', 'password':'pwd'})
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid))
+ r = self.wtc.get(self.GRP_URI + str(gid) + '/users/')
+ eq_(r.status_code, 200)
+ eq_(loads(r.data)['data'][0]['id'], uid)
+
+ def test_get_users_from_group_not_exist(self):
+ r = self.wtc.get(self.GRP_URI + str(1233) + '/users/')
+ eq_(r.status_code, 404)
+
+ def test_get_groups_from_user(self):
+ gid = self.add_group({'name':'groupName'})
+ uid = self.add_user({'name':'userName', 'password':'pwd'})
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid))
+ r = self.wtc.get(self.USR_URI + str(uid) + '/groups/')
+ eq_(r.status_code, 200)
+ eq_(loads(r.data)['data'][0]['id'], gid)
+
+ def test_get_groups_from_user_not_exist(self):
+ r = self.wtc.get(self.USR_URI + str(1233) + '/groups/')
+ eq_(r.status_code, 404)
+
+ def test_remove_user_from_group(self):
+ gid = self.add_group({'name':'groupName'})
+ uid = self.add_user({'name':'userName', 'password':'pwd'})
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid))
+ r = self.wtc.delete(self.GRP_URI + str(gid) + '/users/' + str(uid))
+ eq_(r.status_code, 200)
+
+ def test_remove_user_from_group_not_exist(self):
+ gid = self.add_group({'name':'groupName'})
+ uid = self.add_user({'name':'userName', 'password':'pwd'})
+ r = self.wtc.put(self.GRP_URI + str(gid) + '/users/' + str(uid))
+ r = self.wtc.delete(self.GRP_URI + str(gid) + '/users/' + str(3214))
+ eq_(r.status_code, 404)
+ r = self.wtc.delete(self.GRP_URI + str(3123) + '/users/' + str(uid))
+ eq_(r.status_code, 404)
diff --git a/webant/test/api/test_api_users.py b/webant/test/api/test_api_users.py
new file mode 100644
index 0000000..8ad23fd
--- /dev/null
+++ b/webant/test/api/test_api_users.py
@@ -0,0 +1,85 @@
+from webant.test.api import WebantTestApiCase, ApiClientError
+from nose.tools import eq_
+from flask.json import dumps
+
+
+class TestApiUsers(WebantTestApiCase):
+
+ def test_add_user(self):
+ self.add_user({'name':'testName', 'password': 'testPassword'})
+
+ def test_add_user_wrong_content_type(self):
+ r = self.wtc.post(self.USR_URI,
+ data=dumps({'name':'testName', 'password': 'testPassword'}))
+ eq_(r.status_code, 415)
+ r = self.wtc.post(self.USR_URI,
+ data="asdasd",
+ content_type="application/json")
+ eq_(r.status_code, 400)
+
+ def test_get_user_not_exist(self):
+ r = self.wtc.get(self.API_PREFIX + '/users/12')
+ eq_(r.status_code, 404)
+
+ def test_get_user(self):
+ userData = {'name':'testName', 'password': 'testPassword'}
+ uid = self.add_user(userData)
+ rg = self.wtc.get(self.API_PREFIX + '/users/' + str(uid))
+ eq_(rg.status_code, 200)
+
+ def test_add_user_no_name(self):
+ with self.assertRaises(ApiClientError) as ace:
+ self.add_user({'password':'testPassword'})
+ eq_(ace.res.status_code, 400)
+
+ def test_add_user_no_pass(self):
+ with self.assertRaises(ApiClientError) as ace:
+ self.add_user({'name':'testName'})
+ eq_(ace.res.status_code, 400)
+
+ def test_add_user_same_name(self):
+ user_data = {'name':'testName', 'password': 'testPassword'}
+ self.add_user(user_data)
+ with self.assertRaises(ApiClientError) as ace:
+ self.add_user(user_data)
+ eq_(ace.res.status_code, 409)
+
+ def test_delete_user(self):
+ userData = {'name':'testName', 'password': 'testPassword'}
+ uid = self.add_user(userData)
+ r = self.wtc.delete(self.API_PREFIX + '/users/' + str(uid))
+ eq_(r.status_code, 200)
+
+ def test_delete_user_not_exists(self):
+ r = self.wtc.delete(self.API_PREFIX + '/users/' + str(56))
+ eq_(r.status_code, 404)
+
+ def test_update_user(self):
+ userData = {'name':'testName', 'password': 'testPassword'}
+ uid = self.add_user(userData)
+ userData = {'name':'testName2', 'password': 'testPassword2'}
+ r = self.wtc.patch(self.API_PREFIX + '/users/' + str(uid),
+ data=dumps(userData),
+ content_type="application/json")
+ eq_(r.status_code, 200)
+
+ def test_update_user_wrong_content_type(self):
+ userData = {'name':'testName', 'password': 'testPassword'}
+ uid = self.add_user(userData)
+ userData = {'name':'testName2', 'password': 'testPassword2'}
+ r = self.wtc.patch(self.API_PREFIX + '/users/' + str(uid),
+ data=dumps(userData))
+ eq_(r.status_code, 415)
+ r = self.wtc.patch(self.API_PREFIX + '/users/' + str(uid),
+ data="asdasd",
+ content_type="application/json")
+ eq_(r.status_code, 400)
+
+ def test_update_user_not_exist(self):
+ userData = {'name':'testName', 'password': 'testPassword'}
+ self.add_user(userData)
+ userData = {'name':'testName2', 'password': 'testPassword2'}
+ r = self.wtc.patch(self.API_PREFIX + '/users/' + str(50),
+ content_type="application/json",
+ data=dumps(userData))
+ eq_(r.status_code, 404)
diff --git a/webant/test/test_api_archivant.py b/webant/test/test_api_archivant.py
new file mode 100644
index 0000000..bba8ecc
--- /dev/null
+++ b/webant/test/test_api_archivant.py
@@ -0,0 +1,10 @@
+from webant.test import WebantTestCase
+from nose.tools import eq_
+
+
+class TestApiArchivant(WebantTestCase):
+
+ API_PREFIX = '/api/v1'
+
+ def test_get_volumes(self):
+ eq_(self.wtc.get(self.API_PREFIX + '/volumes/').status_code, 200)
diff --git a/webant/util.py b/webant/util.py
index e0e2b1c..574e605 100644
--- a/webant/util.py
+++ b/webant/util.py
@@ -1,5 +1,7 @@
import functools
-from flask import send_file
+from flask import send_file, session
+from authbone import Authenticator, Authorizator
+from users.api import get_user, get_anonymous_user, NotFoundException
def memoize(obj):
@@ -48,3 +50,96 @@ def send_attachment_file(archivant, volumeID, attachmentID):
mimetype=attachment['metadata']['mime'],
attachment_filename=attachment['metadata']['name'],
as_attachment=True)
+
+
+def routes_collector(gatherer):
+ """Decorator utility to collect flask routes in a dictionary.
+
+ This function together with :func:`add_routes` provides an
+ easy way to split flask routes declaration in multiple modules.
+
+ :param gatherer: dict in which will be collected routes
+
+ The decorator provided by this function should be used as the
+ `original flask decorator `_
+ example::
+
+ routes = []
+ route = routes_collector(routes)
+
+ @route('/volumes/', methods=['GET', 'POST'])
+ def volumes():
+ return 'page body'
+
+ After you've collected your routes you can use :func:`add_routes` to register
+ them onto the main blueprint/flask_app.
+ """
+ def hatFunc(rule, **options):
+ def decorator(f):
+ rule_dict = {'rule':rule, 'view_func':f}
+ rule_dict.update(options)
+ gatherer.append(rule_dict)
+ return decorator
+ return hatFunc
+
+
+def add_routes(fapp, routes, prefix=""):
+ """Batch routes registering
+
+ Register routes to a blueprint/flask_app previously collected
+ with :func:`routes_collector`.
+
+ :param fapp: bluprint or flask_app to whom attach new routes.
+ :param routes: dict of routes collected by :func:`routes_collector`
+ :param prefix: url prefix under which register all routes
+ """
+ for r in routes:
+ r['rule'] = prefix + r['rule']
+ fapp.add_url_rule(**r)
+
+
+class AuthtFromSession(Authenticator):
+
+ USERID_KEY = 'user_id'
+
+ def login(self, userID):
+ session[self.USERID_KEY] = userID
+
+ def logout(self):
+ session.pop(self.USERID_KEY)
+
+ def is_logged_in(self):
+ return self.USERID_KEY in session
+
+ def auth_data_getter(self):
+ return session.get(self.USERID_KEY, None)
+
+ def authenticate(self, userID):
+ try:
+ return get_user(id=userID)
+ except NotFoundException:
+ return None
+
+ def bad_auth_data_callback(self):
+ self.identity_elaborator(get_anonymous_user())
+
+ def not_authenticated_callback(self):
+ self.identity_elaborator(get_anonymous_user())
+
+
+class AuthzFromSession(Authorizator):
+
+ def check_capability(self, identity, capability):
+ return identity.can(capability[0], capability[1])
+
+
+class TransparentAutht(AuthtFromSession):
+
+ def perform_authentication(self, *args, **kwargs):
+ pass
+
+
+class TransparentAuthz(AuthzFromSession):
+
+ def perform_authorization(self, *args, **kwargs):
+ pass
diff --git a/webant/webant.py b/webant/webant.py
index 0ba210f..71833bd 100644
--- a/webant/webant.py
+++ b/webant/webant.py
@@ -1,13 +1,14 @@
import tempfile
import os
-from flask import Flask, render_template, request, abort, Response, redirect, url_for, make_response
+from flask import Flask, render_template, request, Response, redirect, url_for
from werkzeug import secure_filename
from flask_bootstrap import Bootstrap
from elasticsearch import exceptions as es_exceptions
from flask.ext.babel import Babel, gettext
from babel.dates import format_timedelta
from datetime import datetime
+from logging import getLogger
from presets import PresetManager
from constants import isoLangs
@@ -17,6 +18,9 @@
from agherant import agherant
from api.blueprint_api import api
from webserver_utils import gevent_run
+import users
+import util
+from authbone.authorization import CapabilityMissingException
class LibreantCoreApp(Flask):
@@ -27,29 +31,59 @@ def __init__(self, import_name, conf={}):
'FSDB_PATH': "",
'SECRET_KEY': 'really insecure, please change me!',
'ES_HOSTS': None,
- 'ES_INDEXNAME': 'libreant'
+ 'ES_INDEXNAME': 'libreant',
+ 'USERS_DATABASE': "",
+ 'PWD_ROUNDS': None,
+ 'PWD_SALT_SIZE': None
}
defaults.update(conf)
self.config.update(defaults)
+ '''dirty trick: prevent default flask handler to be created
+ in flask version > 0.10.1 will be a nicer way to disable default loggers
+ tanks to this new code mitsuhiko/flask@84ad89ffa4390d3327b4d35983dbb4d84293b8e2
+ '''
+ self._logger = getLogger(self.import_name)
+
self.archivant = Archivant(conf={k: self.config[k] for k in ('FSDB_PATH', 'ES_HOSTS', 'ES_INDEXNAME')})
self.presetManager = PresetManager(self.config['PRESET_PATHS'])
+ if self.config['USERS_DATABASE']:
+ self.usersDB = users.init_db(self.config['USERS_DATABASE'],
+ pwd_salt_size=self.config['PWD_SALT_SIZE'],
+ pwd_rounds=self.config['PWD_ROUNDS'])
+ users.populate_with_defaults()
+ else:
+ self.logger.warning("""It has not been set any value for 'USERS_DATABASE', \
+all operations about users will be unsupported. Are all admins.""")
+ self.usersDB = None
+
+ @property
+ def users_enabled(self):
+ return bool(self.usersDB)
+
class LibreantViewApp(LibreantCoreApp):
def __init__(self, import_name, conf={}):
defaults = {
'BOOTSTRAP_SERVE_LOCAL': True,
'AGHERANT_DESCRIPTIONS': [],
+ 'API_URL': "/api/v1"
}
defaults.update(conf)
super(LibreantViewApp, self).__init__(import_name, defaults)
if self.config['AGHERANT_DESCRIPTIONS']:
self.register_blueprint(agherant, url_prefix='/agherant')
- self.register_blueprint(api, url_prefix='/api/v1')
+ self.register_blueprint(api, url_prefix=self.config['API_URL'])
Bootstrap(self)
self.babel = Babel(self)
self.available_translations = [l.language for l in self.babel.list_translations()]
+ if self.users_enabled:
+ self.autht = util.AuthtFromSession()
+ self.authz = util.AuthzFromSession(authenticator=self.autht)
+ else:
+ self.autht = util.TransparentAutht()
+ self.authz = util.TransparentAuthz()
def create_app(conf={}):
@@ -63,7 +97,7 @@ def index():
def search():
query = request.args.get('q', None)
if query is None:
- abort(400, "No query given")
+ return renderErrorPage(message='No query given', httpCode=400)
res = app.archivant._db.user_search(query)['hits']['hits']
books = []
for b in res:
@@ -81,9 +115,10 @@ def search():
books=books, query=query),
mimetype='text/xml')
else:
- abort(500)
+ return renderErrorPage(message='Unknown format requested', httpCode=400)
@app.route('/add', methods=['POST'])
+ @app.authz.requires_capability(('/volumes', users.Action.CREATE))
def upload():
requiredFields = ['_language']
optFields = ['_preset']
@@ -127,6 +162,7 @@ def upload():
return redirect(url_for('view_volume', volumeID=addedVolumeID))
@app.route('/add', methods=['GET'])
+ @app.authz.requires_capability(('/volumes', users.Action.CREATE))
def add():
reqPreset = request.args.get('preset', None)
@@ -146,14 +182,25 @@ def description():
mimetype='text/xml')
@app.route('/view/')
+ @app.autht.requires_authentication
def view_volume(volumeID):
+ app.authz.perform_authorization(('volumes/{}'.format(volumeID), users.Action.READ))
try:
volume = app.archivant.get_volume(volumeID)
except NotFoundException:
return renderErrorPage(message='no volume found with id "{}"'.format(volumeID), httpCode=404)
+ # hide button from action toolbar if current user has not capability to perform them
+ hideFromToolbar = None
+ if app.users_enabled:
+ currentDomain = 'volumes/{}'.format(volumeID)
+ hideFromToolbar = {}
+ hideFromToolbar['delete'] = not app.autht.currIdentity.can(currentDomain, users.Action.DELETE)
similar = app.archivant._db.mlt(volume['id'])['hits']['hits'][:10]
return render_template('details.html',
- volume=volume, similar=similar)
+ volume=volume,
+ similar=similar,
+ hide_from_toolbar=hideFromToolbar,
+ api_url=app.config['API_URL'])
@app.route('/download//')
def download_attachment(volumeID, attachmentID):
@@ -175,6 +222,47 @@ def get_locale():
return request.values['lang']
return request.accept_languages.best_match(app.available_translations)
+ if app.users_enabled:
+ @app.route('/login', methods=['GET'])
+ def login_form():
+ if app.autht.is_logged_in():
+ return renderErrorPage('you are already logged in', 400)
+ return render_template('login.html')
+
+ @app.route('/login', methods=['POST'])
+ def login():
+ name = request.form.get('username', None)
+ pwd = request.form.get('password', None)
+ if not name:
+ return render_template('login.html', message='Missing username'), 400
+ elif not pwd:
+ return render_template('login.html', message='Missing password'), 400
+ try:
+ usr = users.api.get_user(name=name)
+ except users.api.NotFoundException:
+ return render_template('login.html', message='"{}" is not registered'.format(name)), 400
+ if usr.verify_password(pwd):
+ app.autht.login(usr.id)
+ return redirect(url_for('index'), code=302)
+ return render_template('login.html', message='Wrong password')
+
+ @app.route('/logout')
+ def logout():
+ if not app.autht.is_logged_in():
+ return renderErrorPage('you are not logged in', 400)
+ app.autht.logout()
+ return redirect(url_for('index'), code=302)
+
+ def current_user():
+ app.autht.perform_authentication()
+ if users.api.is_anonymous(app.autht.currIdentity):
+ return None
+ return app.autht.currIdentity
+
+ @app.context_processor
+ def user_processor():
+ return dict(users_enabled = True, current_user = current_user)
+
@app.template_filter('timepassedformat')
def timepassedformat_filter(timestamp):
'''given a timestamp it returns a string
@@ -190,9 +278,8 @@ def timepassedformat_filter(timestamp):
@app.errorhandler(es_exceptions.ConnectionError)
def handle_elasticsearch_down(error):
- rsp = make_response('DB connection error', 503)
- app.logger.error("Error connecting to DB; check your configuration")
- return rsp
+ app.logger.exception("Error connecting to DB; check your configuration")
+ return renderErrorPage(message='DB connection error', httpCode=503)
@app.errorhandler(404)
def not_found(error):
@@ -203,6 +290,10 @@ def not_found(error):
def internal_server_error(error):
return renderErrorPage(message='Internal Server Error', httpCode=500)
+ @app.errorhandler(CapabilityMissingException)
+ def not_authenticated_handler(error):
+ return renderErrorPage(message='Authorization Required', httpCode=401)
+
return app
diff --git a/webant/webserver_utils.py b/webant/webserver_utils.py
index 68c60b3..f5b5ca7 100644
--- a/webant/webserver_utils.py
+++ b/webant/webserver_utils.py
@@ -7,16 +7,20 @@ def gevent_run(app):
from gevent.wsgi import WSGIServer
import gevent.monkey
from werkzeug.debug import DebuggedApplication
- from werkzeug.serving import run_with_reloader
gevent.monkey.patch_socket()
run_app = app
if app.config['DEBUG']:
run_app = DebuggedApplication(app)
- @run_with_reloader
def run_server():
port = int(app.config.get('PORT', 5000))
address = app.config.get('ADDRESS', '')
print('Listening on http://%s:%d/' % (address or '0.0.0.0', port))
http_server = WSGIServer((address, port), run_app)
http_server.serve_forever()
+
+ if app.config['DEBUG']:
+ from werkzeug.serving import run_with_reloader
+ run_with_reloader(run_server)
+ else:
+ run_server()