Skip to content

Commit

Permalink
Merge pull request #246 from NatLibFi/issue237-access-levels
Browse files Browse the repository at this point in the history
Implement access levels for projects
  • Loading branch information
osma authored Jan 29, 2019
2 parents 4e1d6cf + c751725 commit 3804bf9
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 15 deletions.
10 changes: 4 additions & 6 deletions annif/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import annif.corpus
import annif.eval
import annif.project
from annif.project import Access
from annif.hit import HitFilter

logger = annif.logger
Expand All @@ -25,7 +26,7 @@ def get_project(project_id):
"""
Helper function to get a project by ID and bail out if it doesn't exist"""
try:
return annif.project.get_project(project_id)
return annif.project.get_project(project_id, min_access=Access.hidden)
except ValueError:
click.echo(
"No projects found with id \'{0}\'.".format(project_id),
Expand Down Expand Up @@ -80,12 +81,10 @@ def run_list_projects():
"""

template = "{0: <25}{1: <45}{2: <8}"

header = template.format("Project ID", "Project Name", "Language")
click.echo(header)
click.echo("-" * len(header))

for proj in annif.project.get_projects().values():
for proj in annif.project.get_projects(min_access=Access.private).values():
click.echo(template.format(proj.project_id, proj.name, proj.language))


Expand All @@ -97,12 +96,11 @@ def run_show_project(project_id):
"""

proj = get_project(project_id)

template = "{0:<20}{1}"

click.echo(template.format('Project ID:', proj.project_id))
click.echo(template.format('Project Name:', proj.name))
click.echo(template.format('Language:', proj.language))
click.echo(template.format('Access:', proj.access.name))


@cli.command('loadvoc')
Expand Down
37 changes: 32 additions & 5 deletions annif/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import collections
import configparser
import enum
import os.path
from sklearn.externals import joblib
from sklearn.feature_extraction.text import TfidfVectorizer
Expand All @@ -19,6 +20,13 @@
logger = annif.logger


class Access(enum.IntEnum):
"""Enumeration of access levels for projects"""
private = 1
hidden = 2
public = 3


class AnnifProject:
"""Class representing the configuration of a single Annif project."""

Expand All @@ -29,6 +37,9 @@ class AnnifProject:
_vectorizer = None
initialized = False

# default values for configuration settings
DEFAULT_ACCESS = 'public'

def __init__(self, project_id, config, datadir):
self.project_id = project_id
self.name = config['name']
Expand All @@ -38,6 +49,16 @@ def __init__(self, project_id, config, datadir):
self._base_datadir = datadir
self._datadir = os.path.join(datadir, 'projects', self.project_id)
self.config = config
self._init_access()

def _init_access(self):
access = self.config.get('access', self.DEFAULT_ACCESS)
try:
self.access = getattr(Access, access)
except AttributeError:
raise ConfigurationException(
"'{}' is not a valid access setting".format(access),
project_id=self.project_id)

def _get_datadir(self):
"""return the path of the directory where this project can store its
Expand Down Expand Up @@ -222,14 +243,20 @@ def initialize_projects(app):
projects_file, datadir, init_projects)


def get_projects():
"""return the available projects as a dict of project_id -> AnnifProject"""
return current_app.annif_projects
def get_projects(min_access=Access.private):
"""Return the available projects as a dict of project_id ->
AnnifProject. The min_access parameter may be used to set the minimum
access level required for the returned projects."""

projects = [(project_id, project)
for project_id, project in current_app.annif_projects.items()
if project.access >= min_access]
return collections.OrderedDict(projects)


def get_project(project_id):
def get_project(project_id, min_access=Access.private):
"""return the definition of a single Project by project_id"""
projects = get_projects()
projects = get_projects(min_access)
try:
return projects[project_id]
except KeyError:
Expand Down
13 changes: 9 additions & 4 deletions annif/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import annif.project
from annif.hit import HitFilter
from annif.exception import AnnifException
from annif.project import Access


def project_not_found_error(project_id):
Expand All @@ -29,15 +30,18 @@ def server_error(err):
def list_projects():
"""return a dict with projects formatted according to Swagger spec"""

return {'projects': [proj.dump()
for proj in annif.project.get_projects().values()]}
return {
'projects': [
proj.dump() for proj in annif.project.get_projects(
min_access=Access.public).values()]}


def show_project(project_id):
"""return a single project formatted according to Swagger spec"""

try:
project = annif.project.get_project(project_id)
project = annif.project.get_project(
project_id, min_access=Access.hidden)
except ValueError:
return project_not_found_error(project_id)
return project.dump()
Expand All @@ -48,7 +52,8 @@ def analyze(project_id, text, limit, threshold):
Swagger spec"""

try:
project = annif.project.get_project(project_id)
project = annif.project.get_project(
project_id, min_access=Access.hidden)
except ValueError:
return project_not_found_error(project_id)

Expand Down
3 changes: 3 additions & 0 deletions tests/projects.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,23 @@ backend=dummy
analyzer=snowball(finnish)
key=value
vocab=dummy
access=public

[dummy-en]
name=Dummy English
language=en
backend=dummy
analyzer=snowball(english)
vocab=dummy
access=hidden

[dummydummy]
name=Dummy+Dummy combination
language=en
backend=dummy
analyzer=snowball(english)
vocab=dummy
access=private

[ensemble]
name=Ensemble
Expand Down
10 changes: 10 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ def test_list_projects():
result = runner.invoke(annif.cli.cli, ["list-projects"])
assert not result.exception
assert result.exit_code == 0
# public project should be visible
assert 'dummy-fi' in result.output
# hidden project should be visible
assert 'dummy-en' in result.output
# private project should be visible
assert 'dummydummy' in result.output
# project with no access setting should be visible
assert 'ensemble' in result.output


def test_list_projects_bad_arguments():
Expand All @@ -42,6 +50,8 @@ def test_show_project():
assert project_name.group(1) == 'Dummy English'
project_lang = re.search(r'Language:\s+(.+)', result.output)
assert project_lang.group(1) == 'en'
access = re.search(r'Access:\s+(.+)', result.output)
assert access.group(1) == 'hidden'


def test_show_project_nonexistent():
Expand Down
22 changes: 22 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@
import annif.project
import annif.backend.dummy
from annif.exception import ConfigurationException
from annif.project import Access


def test_create_project_wrong_access(app):
with pytest.raises(ConfigurationException):
project = annif.project.AnnifProject(
'example',
{'name': 'Example', 'language': 'en', 'access': 'invalid'},
'.')


def test_get_project_en(app):
Expand All @@ -14,6 +23,7 @@ def test_get_project_en(app):
assert project.language == 'en'
assert project.analyzer.name == 'snowball'
assert project.analyzer.param == 'english'
assert project.access == Access.hidden
assert isinstance(project.backend, annif.backend.dummy.DummyBackend)


Expand All @@ -24,6 +34,18 @@ def test_get_project_fi(app):
assert project.language == 'fi'
assert project.analyzer.name == 'snowball'
assert project.analyzer.param == 'finnish'
assert project.access == Access.public
assert isinstance(project.backend, annif.backend.dummy.DummyBackend)


def test_get_project_dummydummy(app):
with app.app_context():
project = annif.project.get_project('dummydummy')
assert project.project_id == 'dummydummy'
assert project.language == 'en'
assert project.analyzer.name == 'snowball'
assert project.analyzer.param == 'english'
assert project.access == Access.private
assert isinstance(project.backend, annif.backend.dummy.DummyBackend)


Expand Down
68 changes: 68 additions & 0 deletions tests/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,80 @@
import annif.rest


def test_rest_list_projects(app):
with app.app_context():
result = annif.rest.list_projects()
project_ids = [proj['project_id'] for proj in result['projects']]
# public project should be returned
assert 'dummy-fi' in project_ids
# hidden project should not be returned
assert 'dummy-en' not in project_ids
# private project should not be returned
assert 'dummydummy' not in project_ids
# project with no access level setting should be returned
assert 'ensemble' in project_ids


def test_rest_show_project_public(app):
# public projects should be accessible via REST
with app.app_context():
result = annif.rest.show_project('dummy-fi')
assert result['project_id'] == 'dummy-fi'


def test_rest_show_project_hidden(app):
# hidden projects should be accessible if you know the project id
with app.app_context():
result = annif.rest.show_project('dummy-en')
assert result['project_id'] == 'dummy-en'


def test_rest_show_project_private(app):
# private projects should not be accessible via REST
with app.app_context():
result = annif.rest.show_project('dummydummy')
assert result.status_code == 404


def test_rest_show_project_nonexistent(app):
with app.app_context():
result = annif.rest.show_project('nonexistent')
assert result.status_code == 404


def test_rest_analyze_public(app):
# public projects should be accessible via REST
with app.app_context():
result = annif.rest.analyze(
'dummy-fi',
text='example text',
limit=10,
threshold=0.0)
assert 'results' in result


def test_rest_analyze_hidden(app):
# hidden projects should be accessible if you know the project id
with app.app_context():
result = annif.rest.analyze(
'dummy-en',
text='example text',
limit=10,
threshold=0.0)
assert 'results' in result


def test_rest_analyze_private(app):
# private projects should not be accessible via REST
with app.app_context():
result = annif.rest.analyze(
'dummydummy',
text='example text',
limit=10,
threshold=0.0)
assert result.status_code == 404


def test_rest_analyze_nonexistent(app):
with app.app_context():
result = annif.rest.analyze(
Expand Down

0 comments on commit 3804bf9

Please sign in to comment.