Skip to content

Commit

Permalink
Merge pull request #195 from UUDigitalHumanitieslab/feature/collectio…
Browse files Browse the repository at this point in the history
…n-records-api

basic collection records endpoint
  • Loading branch information
jgonggrijp authored Dec 12, 2024
2 parents 488e469 + fbd22f7 commit 4427cee
Show file tree
Hide file tree
Showing 7 changed files with 190 additions and 22 deletions.
33 changes: 30 additions & 3 deletions backend/collect/api.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
from rest_framework.viewsets import ModelViewSet
from rest_framework.views import Request
from rest_framework.exceptions import NotFound
from rdflib import URIRef, RDF, Graph
from rdf.views import RDFView
from rdflib import URIRef, RDF, Graph, BNode, Literal
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 triplestore.constants import EDPOPCOL, AS
from collect.serializers import CollectionSerializer
from collect.permissions import CollectionPermission
from collect.graphs import list_to_graph_collection

class CollectionViewSet(ModelViewSet):
'''
Viewset for listing or retrieving collections
Viewset for listing or retrieving collection metadata
'''

lookup_value_regex = '.+'
Expand Down Expand Up @@ -41,3 +44,27 @@ def get_object(self):
self.check_object_permissions(self.request, collection)
return collection


class CollectionRecordsView(RDFView):
'''
View the records inside a collection
'''

def get_graph(self, request: Request, collection: str, **kwargs) -> Graph:
collection_uri = URIRef(collection)

if not collection_exists(collection_uri):
raise NotFound('Collection does not exist')

collection_obj = EDPOPCollection(collection_graph(collection_uri), collection_uri)

g = Graph()
g.add((collection_obj.uri, RDF.type, EDPOPCOL.Collection))
g.add((collection_obj.uri, RDF.type, AS.Collection))

items_node = BNode()
g.add((collection_obj.uri, AS.items, items_node))
g.add((collection_obj.uri, AS.totalItems, Literal(len(collection_obj.records))))
g += list_to_graph_collection(collection_obj.records, items_node)

return g
55 changes: 54 additions & 1 deletion backend/collect/api_test.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
from django.test import Client
from rest_framework.status import is_success, is_client_error
from rdflib import URIRef, RDF, Literal
from rdflib import URIRef, RDF, Graph, 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
from collect.rdf_models import EDPOPCollection
from collect.utils import collection_graph

def example_collection_data(project_name) -> Dict:
return {
Expand Down Expand Up @@ -127,3 +129,54 @@ def test_project_validation(db, user, client: Client):
}, content_type='application/json')

assert is_client_error(response.status_code)

def test_collection_records(db, user, project, client: Client):
client.force_login(user)
create_response = post_collection(client, project.name)
collection_uri = URIRef(create_response.data['uri'])

records_url = '/api/collection-records/' + str(collection_uri) + '/'

# check response with empty data
empty_response = client.get(records_url)
assert is_success(empty_response.status_code)
g = Graph().parse(empty_response.content)
result = g.query(f'''
ASK {{
<{collection_uri}> a edpopcol:Collection ;
a as:Collection ;
as:items ?items ;
as:totalItems 0 .
?items rdf:rest rdf:nil .
}}
''',
initNs={'as': AS, 'rdf': RDF, 'edpopcol': EDPOPCOL}
)
assert result.askAnswer

# add some records to the collection
collection_obj = EDPOPCollection(collection_graph(collection_uri), collection_uri)
collection_obj.records = [
URIRef('https://example.com/example1'), URIRef('https://example.com/example2')
]
collection_obj.save()

# check response contains records
response = client.get(records_url)
assert is_success(response.status_code)
g = Graph().parse(response.content)
result = g.query(f'''
ASK {{
<{collection_uri}> a edpopcol:Collection ;
a as:Collection ;
as:items ?items ;
as:totalItems 2 .
?items rdf:first <https://example.com/example1> ;
rdf:rest ?rest .
?rest rdf:first <https://example.com/example2> ;
rdf:rest rdf:nil .
}}
''',
initNs={'as': AS, 'rdf': RDF, 'edpopcol': EDPOPCOL}
)
assert result.askAnswer
46 changes: 46 additions & 0 deletions backend/collect/graphs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import List
from rdflib import Graph, RDF, IdentifiedNode
from rdflib.term import Node

from triplestore.utils import Triples

def list_from_graph_collection(graph: Graph, list_node: IdentifiedNode) -> List[Node]:
'''
Extract a list of nodes from an RDF collection in a graph
'''

items = list(graph.objects(list_node, RDF.first))
rest_nodes = graph.objects(list_node, RDF.rest)
for rest in rest_nodes:
items += list_from_graph_collection(graph, rest)
return items


def list_to_graph_collection(items: List[Node], items_node: IdentifiedNode) -> Graph:
'''
Return a list of items as an RDF collection
'''

g = Graph()
collection = g.collection(items_node)
collection += items # indirectly modifies g
return g


def collection_triples(graph: Graph, list_node: IdentifiedNode) -> Triples:
'''
Select all triples that make up an RDF collection in a graph.
This collects the chain of `rdf:first` / `rdf:rest` relations that make up the
collection. It collects what is actually stored in the graph, rather than a
normalised version, so this method should be used to select the current triples in
a delete or update operation.
'''

triples = list(graph.triples((list_node, RDF.first, None)))
triples += list(graph.triples((list_node, RDF.rest, None)))

for rest in graph.objects(list_node, RDF.rest):
triples += collection_triples(graph, rest)

return triples
49 changes: 37 additions & 12 deletions backend/collect/rdf_models.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,55 @@
from rdflib import RDFS, IdentifiedNode, URIRef
from rdflib import RDFS, IdentifiedNode, URIRef, Graph, RDF, Literal
from typing import Iterable

from triplestore.utils import Triples
from triplestore.utils import Triples, replace_blank_nodes_in_triples
from triplestore.constants import EDPOPCOL, AS
from triplestore.rdf_model import RDFModel
from triplestore.rdf_field import RDFField, RDFUniquePropertyField

from collect.graphs import (
list_from_graph_collection, list_to_graph_collection, collection_triples
)

class CollectionMembersField(RDFField):
'''
Field for the records that are contained in an EDPOP collection.
'''

def get(self, instance: RDFModel):
return [
s
for (s, p, o) in self._stored_triples(instance)
]
g = self.get_graph(instance)
items = next(g.objects(instance.uri, AS.items), None)
if items:
return list_from_graph_collection(g, items)
return []


def _stored_triples(self,instance: RDFModel) -> Triples:
g = self.get_graph(instance)
return g.triples((None, RDFS.member, instance.uri))
subgraph = Graph()
subgraph += g.triples((instance.uri, RDF.type, AS.Collection))
subgraph += g.triples((instance.uri, AS.totalItems, None))
subgraph += g.triples((instance.uri, AS.items, None))

item_collections = g.objects(instance.uri, AS.items)
for collection in item_collections:
subgraph += collection_triples(g, collection)

return list(subgraph.triples((None, None, None)))


def _triples_to_store(self, instance: RDFModel, value: Iterable[IdentifiedNode]) -> Triples:
return [
(uri, RDFS.member, instance.uri)
for uri in value
]
g = Graph()
g.add((instance.uri, RDF.type, AS.Collection))
g.add((instance.uri, AS.totalItems, Literal(len(value))))

items_node = self._items_uri(instance)
g += list_to_graph_collection(value, items_node)
g.add((instance.uri, AS.items, items_node))

return list(replace_blank_nodes_in_triples(g.triples((None, None, None))))


def _items_uri(self, instance: RDFModel):
return URIRef(str(instance.uri) + '/items')


class EDPOPCollection(RDFModel):
Expand Down
21 changes: 15 additions & 6 deletions backend/collect/rdf_models_test.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import pytest
from rdflib import URIRef, RDF, RDFS
from rdflib import URIRef, RDF
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
from collect.utils import collection_graph, collection_uri

@pytest.fixture()
def project(db):
Expand All @@ -14,9 +15,8 @@ def project(db):
return rdf_project

def test_collection_model(project):
uri = URIRef('test-collection', base='https://test.org/collections/')

collection = EDPOPCollection(project.graph, uri)
uri = collection_uri('Test collection')
collection = EDPOPCollection(collection_graph(uri), uri)
collection.name = 'Test collection'
collection.project = project.uri
collection.records = [
Expand All @@ -27,12 +27,21 @@ def test_collection_model(project):

store = settings.RDFLIB_STORE

for triple, _ in store.triples((None, None, None)):
print(*triple)

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)))
assert any(store.triples((collection.uri, AS.items, None)))

collection.refresh_from_store()
assert collection.records == [
URIRef('https://example.org/example1'),
URIRef('https://example.org/example2')
]

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)))
assert not any(store.triples((collection.uri, AS.items, None)))
7 changes: 7 additions & 0 deletions backend/collect/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import re_path

from . import api

urlpatterns = [
re_path('collection-records/(?P<collection>.+)/', api.CollectionRecordsView.as_view()),
]
1 change: 1 addition & 0 deletions backend/edpop/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
path('api-auth/',
include('rest_framework.urls', namespace='rest_framework')),
path('api/', include(api_router.urls)),
path('api/', include('collect.urls')),
path('', include('catalogs.urls')),
path('', include('accounts.urls')),
path('', include('projects.urls')),
Expand Down

0 comments on commit 4427cee

Please sign in to comment.