From 9541ed71082e11f68d796dc926b9a07b045870ae Mon Sep 17 00:00:00 2001 From: Osma Suominen Date: Tue, 29 Jan 2019 12:23:18 +0200 Subject: [PATCH 1/3] Implement access levels for projects. Fixes #237 --- annif/cli.py | 6 ++-- annif/project.py | 34 ++++++++++++++++++---- annif/rest.py | 13 ++++++--- tests/projects.cfg | 3 ++ tests/test_cli.py | 10 +++++++ tests/test_project.py | 14 +++++++++ tests/test_rest.py | 68 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 137 insertions(+), 11 deletions(-) diff --git a/annif/cli.py b/annif/cli.py index e4238c7ac..e4e4f2c36 100644 --- a/annif/cli.py +++ b/annif/cli.py @@ -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 @@ -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), @@ -85,7 +86,7 @@ def run_list_projects(): 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)) @@ -103,6 +104,7 @@ def run_show_project(project_id): 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') diff --git a/annif/project.py b/annif/project.py index 841ddc0d8..455fecd7b 100644 --- a/annif/project.py +++ b/annif/project.py @@ -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 @@ -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.""" @@ -29,11 +37,21 @@ 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'] self.language = config['language'] self.analyzer_spec = config.get('analyzer', None) + access = config.get('access', self.DEFAULT_ACCESS) + try: + self.access = getattr(Access, access) + except AttributeError: + raise ConfigurationException( + "'%s' is not a valid access setting".format(self.access), + project_id=self.project_id) self.vocab_id = config.get('vocab', None) self._base_datadir = datadir self._datadir = os.path.join(datadir, 'projects', self.project_id) @@ -222,14 +240,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: diff --git a/annif/rest.py b/annif/rest.py index da5ebcf8c..ce13cea38 100644 --- a/annif/rest.py +++ b/annif/rest.py @@ -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): @@ -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() @@ -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) diff --git a/tests/projects.cfg b/tests/projects.cfg index 84e45bb4f..8185205b8 100644 --- a/tests/projects.cfg +++ b/tests/projects.cfg @@ -7,6 +7,7 @@ backend=dummy analyzer=snowball(finnish) key=value vocab=dummy +access=public [dummy-en] name=Dummy English @@ -14,6 +15,7 @@ language=en backend=dummy analyzer=snowball(english) vocab=dummy +access=hidden [dummydummy] name=Dummy+Dummy combination @@ -21,6 +23,7 @@ language=en backend=dummy analyzer=snowball(english) vocab=dummy +access=private [ensemble] name=Ensemble diff --git a/tests/test_cli.py b/tests/test_cli.py index 2cfc4a6b1..5385bb9b4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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(): @@ -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(): diff --git a/tests/test_project.py b/tests/test_project.py index 614a47910..cb89c72a7 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -5,6 +5,7 @@ import annif.project import annif.backend.dummy from annif.exception import ConfigurationException +from annif.project import Access def test_get_project_en(app): @@ -14,6 +15,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) @@ -24,6 +26,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) diff --git a/tests/test_rest.py b/tests/test_rest.py index 85a56e75c..0aa657774 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -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( From abf8ec70cb40e0a679f8fb641e0243c56d845296 Mon Sep 17 00:00:00 2001 From: Osma Suominen Date: Tue, 29 Jan 2019 12:51:19 +0200 Subject: [PATCH 2/3] remove extra empty lines --- annif/cli.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/annif/cli.py b/annif/cli.py index e4e4f2c36..3a590904b 100644 --- a/annif/cli.py +++ b/annif/cli.py @@ -81,11 +81,9 @@ 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(min_access=Access.private).values(): click.echo(template.format(proj.project_id, proj.name, proj.language)) @@ -98,9 +96,7 @@ 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)) From c751725c5a867a944c13e47760f9272effe0c31e Mon Sep 17 00:00:00 2001 From: Osma Suominen Date: Tue, 29 Jan 2019 12:50:47 +0200 Subject: [PATCH 3/3] move project access initialization into a separate method and add test --- annif/project.py | 15 +++++++++------ tests/test_project.py | 8 ++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/annif/project.py b/annif/project.py index 455fecd7b..893e37932 100644 --- a/annif/project.py +++ b/annif/project.py @@ -45,17 +45,20 @@ def __init__(self, project_id, config, datadir): self.name = config['name'] self.language = config['language'] self.analyzer_spec = config.get('analyzer', None) - access = config.get('access', self.DEFAULT_ACCESS) + self.vocab_id = config.get('vocab', None) + 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( - "'%s' is not a valid access setting".format(self.access), + "'{}' is not a valid access setting".format(access), project_id=self.project_id) - self.vocab_id = config.get('vocab', None) - self._base_datadir = datadir - self._datadir = os.path.join(datadir, 'projects', self.project_id) - self.config = config def _get_datadir(self): """return the path of the directory where this project can store its diff --git a/tests/test_project.py b/tests/test_project.py index cb89c72a7..08c473d84 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -8,6 +8,14 @@ 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): with app.app_context(): project = annif.project.get_project('dummy-en')