-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'feature/collection-api' into develop
- Loading branch information
Showing
22 changed files
with
593 additions
and
24 deletions.
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
from rest_framework.viewsets import ModelViewSet | ||
from rest_framework.exceptions import NotFound | ||
from rdflib import URIRef, RDF, Graph | ||
from django.conf import settings | ||
|
||
from projects.api import user_projects | ||
from collect.rdf_models import EDPOPCollection | ||
from collect.utils import collection_exists, collection_graph | ||
from triplestore.constants import EDPOPCOL | ||
from collect.serializers import CollectionSerializer | ||
from collect.permissions import CollectionPermission | ||
|
||
class CollectionViewSet(ModelViewSet): | ||
''' | ||
Viewset for listing or retrieving collections | ||
''' | ||
|
||
lookup_value_regex = '.+' | ||
serializer_class = CollectionSerializer | ||
permission_classes = [CollectionPermission] | ||
|
||
def get_queryset(self): | ||
projects = user_projects(self.request.user) | ||
return [ | ||
EDPOPCollection(collection_graph(uri), uri) | ||
for project in projects | ||
for uri in project.rdf_model().collections | ||
] | ||
|
||
|
||
def get_object(self): | ||
uri = URIRef(self.kwargs['pk']) | ||
|
||
if not collection_exists(uri): | ||
raise NotFound(f'Collection does not exist') | ||
|
||
store = settings.RDFLIB_STORE | ||
context = next(store.contexts((uri, RDF.type, EDPOPCOL.Collection))) | ||
graph = Graph(store, context) | ||
collection = EDPOPCollection(graph, uri) | ||
self.check_object_permissions(self.request, collection) | ||
return collection | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,129 @@ | ||
from django.test import Client | ||
from rest_framework.status import is_success, is_client_error | ||
from rdflib import URIRef, RDF, Literal | ||
from django.conf import settings | ||
from urllib.parse import quote | ||
from typing import Dict | ||
|
||
from triplestore.constants import EDPOPCOL, AS | ||
from collect.utils import collection_uri | ||
from projects.models import Project | ||
|
||
def example_collection_data(project_name) -> Dict: | ||
return { | ||
'name': 'My collection', | ||
'summary': 'These are my favourite records', | ||
'project': project_name, | ||
} | ||
|
||
def post_collection(client, project_name): | ||
data = example_collection_data(project_name) | ||
return client.post('/api/collections/', data, content_type='application/json') | ||
|
||
def test_create_collection(db, user, project, client: Client): | ||
client.force_login(user) | ||
|
||
response = post_collection(client, project.name) | ||
assert is_success(response.status_code) | ||
uri = URIRef(response.data['uri']) | ||
|
||
store = settings.RDFLIB_STORE | ||
assert any(store.triples((uri, RDF.type, EDPOPCOL.Collection))) | ||
|
||
|
||
def test_create_fails_if_collection_exists(db, user, project, client: Client): | ||
client.force_login(user) | ||
success_response = post_collection(client, project.name) | ||
assert is_success(success_response.status_code) | ||
uri = URIRef(success_response.data['uri']) | ||
|
||
# try to create a collection at the same location | ||
fail_response = client.post('/api/collections/', { | ||
'name': 'My collection', | ||
'summary': 'I like these too', | ||
'project': project.name | ||
}) | ||
assert is_client_error(fail_response.status_code) | ||
|
||
store = settings.RDFLIB_STORE | ||
is_stored = lambda triple: any(store.triples(triple)) | ||
assert is_stored((uri, AS.summary, Literal('These are my favourite records'))) | ||
assert not is_stored((uri, AS.summary, Literal('I like these too'))) | ||
|
||
|
||
def test_list_collections(db, user, project, client: Client): | ||
client.force_login(user) | ||
|
||
response = client.get('/api/collections/') | ||
assert is_success(response.status_code) | ||
assert len(response.data) == 0 | ||
|
||
response = post_collection(client, project.name) | ||
|
||
response = client.get('/api/collections/') | ||
assert is_success(response.status_code) | ||
assert len(response.data) == 1 | ||
assert response.data[0]['uri'] == settings.RDF_NAMESPACE_ROOT + 'collections/my_collection' | ||
assert response.data[0]['name'] == 'My collection' | ||
|
||
|
||
def collection_detail_url(collection_uri: str) -> str: | ||
return '/api/collections/{}/'.format(quote(collection_uri, safe='')) | ||
|
||
|
||
def test_retrieve_collection(db, user, project, client: Client): | ||
client.force_login(user) | ||
create_response = post_collection(client, project.name) | ||
|
||
|
||
correct_url = collection_detail_url(create_response.data['uri']) | ||
nonexistent_uri = collection_uri('does not exist') | ||
|
||
not_found_response = client.get(collection_detail_url(nonexistent_uri)) | ||
assert not_found_response.status_code == 404 | ||
|
||
success_response = client.get(correct_url) | ||
assert is_success(success_response.status_code) | ||
assert success_response.data['name'] == 'My collection' | ||
|
||
client.logout() | ||
no_permission_response = client.get(correct_url) | ||
assert no_permission_response.status_code == 403 | ||
|
||
def test_delete_collection(db, user, project, client: Client): | ||
client.force_login(user) | ||
create_response = post_collection(client, project.name) | ||
|
||
detail_url = collection_detail_url(create_response.data['uri']) | ||
delete_response = client.delete(detail_url) | ||
assert is_success(delete_response.status_code) | ||
|
||
retrieve_response = client.get(detail_url) | ||
assert retrieve_response.status_code == 404 | ||
|
||
def test_update_collection(db, user, project, client: Client): | ||
client.force_login(user) | ||
|
||
create_response = post_collection(client, project.name) | ||
detail_url = collection_detail_url(create_response.data['uri']) | ||
|
||
data = example_collection_data(project.name) | ||
data.update({'summary': 'I don\'t like these anymore'}) | ||
|
||
update_response = client.put(detail_url, data, content_type='application/json') | ||
assert is_success(update_response.status_code) | ||
assert update_response.data['summary'] == 'I don\'t like these anymore' | ||
|
||
|
||
def test_project_validation(db, user, client: Client): | ||
client.force_login(user) | ||
|
||
Project.objects.create(name='secret', display_name='Top secret records') | ||
|
||
response = client.post('/api/collections/', { | ||
'name': 'new collection', | ||
'summary': None, | ||
'project': 'secret', | ||
}, content_type='application/json') | ||
|
||
assert is_client_error(response.status_code) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from django.apps import AppConfig | ||
|
||
|
||
class CollectConfig(AppConfig): | ||
default_auto_field = 'django.db.models.BigAutoField' | ||
name = 'collect' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
import pytest | ||
from django.contrib.auth.models import User | ||
from projects.models import Project | ||
|
||
|
||
@pytest.fixture() | ||
def user(db) -> User: | ||
return User.objects.create( | ||
username='tester', | ||
password='secret' | ||
) | ||
|
||
@pytest.fixture() | ||
def project(db, user): | ||
project = Project.objects.create( | ||
name='test_project', | ||
display_name='Test project' | ||
) | ||
project.users.add(user) | ||
return project |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
from rest_framework import permissions | ||
|
||
from projects.models import Project | ||
|
||
class CollectionPermission(permissions.BasePermission): | ||
''' | ||
Checks whether the user has access to read or write a collection. | ||
''' | ||
|
||
def has_object_permission(self, request, view, obj): | ||
project_uri = obj.project | ||
project = Project.objects.get(uri=project_uri) | ||
|
||
if request.method in permissions.SAFE_METHODS: | ||
return project.permit_query_by(request.user) | ||
else: | ||
return project.permit_update_by(request.user) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
from rdflib import RDFS, IdentifiedNode, URIRef | ||
from typing import Iterable | ||
|
||
from triplestore.utils import Triples | ||
from triplestore.constants import EDPOPCOL, AS | ||
from triplestore.rdf_model import RDFModel | ||
from triplestore.rdf_field import RDFField, RDFUniquePropertyField | ||
|
||
|
||
class CollectionMembersField(RDFField): | ||
def get(self, instance: RDFModel): | ||
return [ | ||
s | ||
for (s, p, o) in self._stored_triples(instance) | ||
] | ||
|
||
|
||
def _stored_triples(self,instance: RDFModel) -> Triples: | ||
g = self.get_graph(instance) | ||
return g.triples((None, RDFS.member, instance.uri)) | ||
|
||
|
||
def _triples_to_store(self, instance: RDFModel, value: Iterable[IdentifiedNode]) -> Triples: | ||
return [ | ||
(uri, RDFS.member, instance.uri) | ||
for uri in value | ||
] | ||
|
||
|
||
class EDPOPCollection(RDFModel): | ||
''' | ||
RDF model for EDPOP collections. | ||
''' | ||
rdf_class = EDPOPCOL.Collection | ||
|
||
name = RDFUniquePropertyField(AS.name) | ||
summary = RDFUniquePropertyField(AS.summary) | ||
project = RDFUniquePropertyField(AS.context) | ||
records = CollectionMembersField() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import pytest | ||
from rdflib import URIRef, RDF, RDFS | ||
from django.conf import settings | ||
|
||
from triplestore.constants import AS, EDPOPCOL | ||
from projects.models import Project | ||
from projects.rdf_models import RDFProject | ||
from collect.rdf_models import EDPOPCollection | ||
|
||
@pytest.fixture() | ||
def project(db): | ||
project = Project.objects.create(name='test', display_name='Test') | ||
rdf_project = RDFProject(project.graph(), project.identifier()) | ||
return rdf_project | ||
|
||
def test_collection_model(project): | ||
uri = URIRef('test-collection', base='https://test.org/collections/') | ||
|
||
collection = EDPOPCollection(project.graph, uri) | ||
collection.name = 'Test collection' | ||
collection.project = project.uri | ||
collection.records = [ | ||
URIRef('https://example.org/example1'), | ||
URIRef('https://example.org/example2') | ||
] | ||
collection.save() | ||
|
||
store = settings.RDFLIB_STORE | ||
|
||
assert any(store.triples((collection.uri, RDF.type, EDPOPCOL.Collection))) | ||
assert any(store.triples((collection.uri, AS.context, project.uri))) | ||
assert any(store.triples((None, RDFS.member, collection.uri))) | ||
|
||
collection.delete() | ||
|
||
assert not any(store.triples((collection.uri, RDF.type, EDPOPCOL.Collection))) | ||
assert not any(store.triples((collection.uri, AS.context, project.uri))) | ||
assert not any(store.triples((None, RDFS.member, collection.uri))) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
from rest_framework import serializers | ||
from rdflib import URIRef | ||
|
||
from collect.rdf_models import EDPOPCollection | ||
from collect.utils import collection_uri, collection_exists, collection_graph | ||
from projects.models import Project | ||
|
||
|
||
class ProjectField(serializers.Field): | ||
def __init__(self, **kwargs): | ||
super().__init__( **kwargs) | ||
|
||
def to_internal_value(self, data): | ||
project = Project.objects.get(name=data) | ||
return URIRef(project.uri) | ||
|
||
def to_representation(self, value): | ||
project = Project.objects.get(uri=str(value)) | ||
return project.name | ||
|
||
|
||
def can_update_project(data): | ||
''' | ||
Validates that the specified project is one the user is allowed to write to. | ||
Note: not to be confused with CollectionPermission. That permission checks whether the | ||
user has access to a collection its current context; this validator checks the | ||
user-submitted data. This prevents users from adding collections to projects they | ||
cannot access. | ||
''' | ||
|
||
project_uri = data['project'] | ||
user = data['user'] | ||
|
||
project_obj = Project.objects.get(uri=str(project_uri)) | ||
if not project_obj.permit_update_by(user): | ||
raise serializers.ValidationError( | ||
'No permission to write to this project' | ||
) | ||
|
||
|
||
class CollectionSerializer(serializers.Serializer): | ||
name = serializers.CharField(max_length=128) | ||
summary = serializers.CharField( | ||
max_length=1024, required=False, allow_null=True, default=None | ||
) | ||
project = ProjectField() | ||
uri = serializers.URLField(read_only=True) | ||
user = serializers.HiddenField(default=serializers.CurrentUserDefault()) | ||
|
||
class Meta: | ||
validators = [can_update_project] | ||
|
||
def create(self, validated_data): | ||
project_uri = validated_data['project'] | ||
uri = collection_uri(validated_data['name']) | ||
graph = collection_graph(uri) | ||
|
||
if collection_exists(uri): | ||
raise serializers.ValidationError(f'Collection {uri} already exists') | ||
|
||
collection = EDPOPCollection(graph, uri) | ||
collection.name = validated_data['name'] | ||
collection.summary = validated_data['summary'] | ||
collection.project = project_uri | ||
collection.save() | ||
return collection | ||
|
||
def update(self, instance: EDPOPCollection, validated_data): | ||
instance.name = validated_data['name'] | ||
instance.summary = validated_data['summary'] | ||
instance.project = validated_data['project'] | ||
instance.save() | ||
return instance |
Oops, something went wrong.