Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement access levels for projects #246

Merged
merged 3 commits into from
Jan 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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