diff --git a/apis_core/apis_entities/models.py b/apis_core/apis_entities/models.py index 18754dfba..14b1468f7 100644 --- a/apis_core/apis_entities/models.py +++ b/apis_core/apis_entities/models.py @@ -7,6 +7,7 @@ from django.urls import NoReverseMatch, reverse from apis_core.apis_metainfo.models import RootObject, Uri +from apis_core.utils.settings import apis_base_uri NEXT_PREV = getattr(settings, "APIS_NEXT_PREV", True) @@ -94,7 +95,7 @@ def create_default_uri(sender, instance, created, raw, using, update_fields, **k skip_default_uri = getattr(instance, "skip_default_uri", False) if create_default_uri and not skip_default_uri: if isinstance(instance, AbstractEntity) and created: - base = getattr(settings, "APIS_BASE_URI", "https://example.org").strip("/") + base = apis_base_uri().strip("/") try: route = reverse("GetEntityGenericRoot", kwargs={"pk": instance.pk}) except NoReverseMatch: diff --git a/apis_core/apis_entities/serializers.py b/apis_core/apis_entities/serializers.py index 34cd94cce..b9d963575 100644 --- a/apis_core/apis_entities/serializers.py +++ b/apis_core/apis_entities/serializers.py @@ -1,6 +1,11 @@ +from rdflib import Literal, Namespace, URIRef +from rdflib.namespace import GEO, RDF, RDFS, XSD from rest_framework import serializers -from apis_core.generic.serializers import GenericHyperlinkedIdentityField +from apis_core.generic.serializers import ( + GenericHyperlinkedIdentityField, + GenericModelCidocSerializer, +) class MinimalEntitySerializer(serializers.Serializer): @@ -11,3 +16,169 @@ class MinimalEntitySerializer(serializers.Serializer): def get_name(self, object) -> str: return str(object) + + +class E53_PlaceCidocSerializer(GenericModelCidocSerializer): + def to_representation(self, instance): + g = super().to_representation(instance) + + crm_namespace = Namespace("http://www.cidoc-crm.org/cidoc-crm/") + attributes_namespace = Namespace(f"{self.base_uri}/attributes/") + g.add((self.instance_uri, RDF.type, crm_namespace.E53_Place)) + if instance.latitude is not None and instance.longitude is not None: + node_spaceprimitive = attributes_namespace[f"spaceprimitive.{instance.id}"] + g.add( + ( + self.instance_uri, + crm_namespace.P168_place_is_defined_by, + node_spaceprimitive, + ) + ) + g.add( + ( + node_spaceprimitive, + RDF.type, + crm_namespace.E94_Space_Primitive, + ) + ) + g.add( + ( + node_spaceprimitive, + crm_namespace.P168_place_is_defined_by, + Literal( + ( + f"Point ( {'+' if instance.longitude > 0 else ''}{instance.longitude} {'+' if instance.latitude > 0 else ''}{instance.latitude} )" + ), + datatype=GEO.wktLiteral, + ), + ) + ) + return g + + +class E21_PersonCidocSerializer(GenericModelCidocSerializer): + def to_representation(self, instance): + g = super().to_representation(instance) + + crm_namespace = Namespace("http://www.cidoc-crm.org/cidoc-crm/") + appellation_namespace = Namespace(f"{self.base_uri}/appellation/") + attributes_namespace = Namespace(f"{self.base_uri}/attributes/") + g.add((self.instance_uri, RDF.type, crm_namespace.E21_Person)) + + if hasattr(instance, "forename"): + forename_uri = URIRef(appellation_namespace[f"forename_{instance.id}"]) + g.add( + ( + forename_uri, + RDF.type, + crm_namespace.E33_E41_Linguistic_Appellation, + ) + ) + g.add( + ( + self.appellation_uri, + crm_namespace.P106_is_composed_of, + forename_uri, + ) + ) + g.add((forename_uri, RDFS.label, Literal(instance.forename))) + + if hasattr(instance, "surname"): + surname_uri = URIRef(appellation_namespace[f"surname_{instance.id}"]) + g.add( + ( + surname_uri, + RDF.type, + crm_namespace.E33_E41_Linguistic_Appellation, + ) + ) + g.add( + ( + self.appellation_uri, + crm_namespace.P106_is_composed_of, + surname_uri, + ) + ) + g.add((surname_uri, RDFS.label, Literal(instance.surname))) + + if instance.start_date_written: + birth_event = URIRef(attributes_namespace[f"birth_{instance.id}"]) + birth_time_span = URIRef( + attributes_namespace[f"birth_time-span_{instance.id}"] + ) + g.add((birth_event, RDF.type, crm_namespace.E67_Birth)) + g.add( + ( + birth_event, + crm_namespace.P98_brought_into_life, + self.instance_uri, + ) + ) + g.add( + ( + birth_event, + crm_namespace["P4_has_time-span"], + birth_time_span, + ) + ) + g.add((birth_time_span, RDF.type, crm_namespace["E52_Time-Span"])) + g.add( + ( + birth_time_span, + crm_namespace.P82a_begin_of_the_begin, + Literal(instance.start_date, datatype=XSD.date) + if instance.start_date is not None + else Literal(instance.start_date_written), + ) + ) + g.add( + ( + birth_time_span, + crm_namespace.P82b_end_of_the_end, + Literal(instance.start_date, datatype=XSD.date) + if instance.start_date is not None + else Literal(instance.start_date_written), + ) + ) + + if instance.end_date_written: + death_event = URIRef(attributes_namespace[f"death_{instance.id}"]) + death_time_span = URIRef( + attributes_namespace[f"death_time-span_{instance.id}"] + ) + g.add((death_event, RDF.type, crm_namespace.E69_Death)) + g.add( + ( + death_event, + crm_namespace.P100_was_death_of, + self.instance_uri, + ) + ) + g.add( + ( + death_event, + crm_namespace["P4_has_time-span"], + death_time_span, + ) + ) + g.add((death_time_span, RDF.type, crm_namespace["E52_Time-Span"])) + g.add( + ( + death_time_span, + crm_namespace.P82a_begin_of_the_begin, + Literal(instance.end_date, datatype=XSD.date) + if instance.end_date is not None + else Literal(instance.end_date_written), + ) + ) + g.add( + ( + death_time_span, + crm_namespace.P82b_end_of_the_end, + Literal(instance.end_date, datatype=XSD.date) + if instance.end_date is not None + else Literal(instance.end_date_written), + ) + ) + + return g diff --git a/apis_core/generic/renderers.py b/apis_core/generic/renderers.py index 30a300ca6..9c49cd0e2 100644 --- a/apis_core/generic/renderers.py +++ b/apis_core/generic/renderers.py @@ -2,32 +2,81 @@ from rdflib import Graph from rest_framework import renderers +from rest_framework.exceptions import APIException logger = logging.getLogger(__name__) class GenericRDFBaseRenderer(renderers.BaseRenderer): - def render(self, data, accepted_media_type=None, renderer_context=None): - g = Graph() - for result in data.get("results", []): - match result: - case tuple(_, _, _): - g.add(result) - case other: - logger.debug("Could not add %s to RDF graph: not a tuple", other) - return g.serialize(format=self.media_type) + """ + Base class to render RDF graphs to various formats. + This renderer expects the serialized data to either be a rdflib grap **or** + to contain a list of rdflib graphs. If it works with a list of graphs, those + are combined to one graph. + This graph is then serialized and the result is returned. The serialization + format can be set using the `rdflib_format` attribute. If this is not set, the + `format` attribute of the renderer is used as serialization format (this is the + format as it is used by the Django Rest Framework for content negotiation. + """ + format = "ttl" + rdflib_format = None -class GenericRDFXMLRenderer(GenericRDFBaseRenderer): - media_type = "application/rdf+xml" - format = "rdf+xml" + def render(self, data, accepted_media_type=None, renderer_context=None): + result = Graph() + + match data: + case {"results": results, **rest}: # noqa: F841 + # Handle case where data is a dict with multiple graphs + for graph in results: + if isinstance(graph, Graph): + # Merge triples + for triple in graph: + result.add(triple) + # Merge namespace bindings + for prefix, namespace in graph.namespaces(): + result.bind(prefix, namespace, override=False) + case {"detail": detail}: + raise APIException(detail) + case Graph(): + # Handle case where data is a single graph + result = data + # Ensure namespaces are properly bound in the single graph case + for prefix, namespace in data.namespaces(): + result.bind(prefix, namespace, override=False) + case _: + raise ValueError( + "Invalid data format. Expected rdflib Graph or dict with 'results' key containing graphs" + ) + serialization_format = self.rdflib_format or self.format + return result.serialize(format=serialization_format) class GenericRDFTurtleRenderer(GenericRDFBaseRenderer): + format = "ttl" media_type = "text/turtle" - format = "rdf+turtle" + rdflib_format = "turtle" + + +class GenericRDFXMLRenderer(GenericRDFBaseRenderer): + format = "rdf" + media_type = "application/rdf+xml" + rdflib_format = "xml" class GenericRDFN3Renderer(GenericRDFBaseRenderer): + format = "rdf" media_type = "text/n3" - format = "rdf+n3" + rdflib_format = "n3" + + +class CidocTTLRenderer(GenericRDFBaseRenderer): + format = "cidoc" + media_type = "text/ttl" + rdflib_format = "ttl" + + +class CidocXMLRenderer(GenericRDFBaseRenderer): + format = "cidoc" + media_type = "application/rdf+xml" + rdflib_format = "xml" diff --git a/apis_core/generic/serializers.py b/apis_core/generic/serializers.py index 2251083e6..f8972721e 100644 --- a/apis_core/generic/serializers.py +++ b/apis_core/generic/serializers.py @@ -1,7 +1,10 @@ from django.contrib.contenttypes.models import ContentType from django.shortcuts import get_object_or_404 +from rdflib import Graph, Literal, Namespace, URIRef +from rdflib.namespace import GEO, OWL, RDF, RDFS from rest_framework.reverse import reverse from rest_framework.serializers import ( + BaseSerializer, CharField, HyperlinkedModelSerializer, HyperlinkedRelatedField, @@ -10,6 +13,8 @@ SerializerMethodField, ) +from apis_core.utils.settings import apis_base_uri, rdf_namespace_prefix + class GenericHyperlinkedRelatedField(HyperlinkedRelatedField): def get_url(self, obj, view_name, request, format): @@ -87,3 +92,61 @@ def get_content_type_key(self, obj) -> str: def get_content_type_name(self, obj) -> str: content_type = ContentType.objects.get_for_model(obj) return content_type.name + + +class GenericModelCidocSerializer(BaseSerializer): + def __init__(self, *args, **kwargs): + self.base_uri = f"{apis_base_uri()}" + self.rdf_nsp_base = rdf_namespace_prefix() + self.appellation_nsp_prefix = f"{self.rdf_nsp_base}-appellation" + self.attr_nsp_prefix = f"{self.rdf_nsp_base}-attr" + super().__init__(*args, **kwargs) + + def to_representation(self, instance): + g = Graph() + content_type = ContentType.objects.get_for_model(instance) + + crm_namespace = Namespace("http://www.cidoc-crm.org/cidoc-crm/") + g.namespace_manager.bind("crm", crm_namespace, replace=True) + g.namespace_manager.bind("owl", OWL, replace=True) + g.namespace_manager.bind("geo", GEO, replace=True) + + appellation_namespace = Namespace(f"{self.base_uri}/appellation/") + g.namespace_manager.bind( + self.appellation_nsp_prefix, appellation_namespace, replace=True + ) + attributes_namespace = Namespace(f"{self.base_uri}/attributes/") + g.namespace_manager.bind( + self.attr_nsp_prefix, attributes_namespace, replace=True + ) + + self.instance_nsp_prefix = f"{self.rdf_nsp_base}-{content_type.name.lower()}" + instance_namespace = Namespace(self.base_uri + instance.get_listview_url()) + g.namespace_manager.bind(self.instance_nsp_prefix, instance_namespace) + + self.instance_uri = URIRef(instance_namespace[str(instance.id)]) + + # Add sameAs links + for uri in instance.uri_set.all(): + uri_ref = URIRef(uri.uri) + g.add((self.instance_uri, OWL.sameAs, uri_ref)) + + # Add properties + self.appellation_uri = URIRef(appellation_namespace[str(instance.id)]) + g.add( + ( + self.appellation_uri, + RDF.type, + crm_namespace.E33_E41_Linguistic_Appellation, + ) + ) + g.add( + ( + self.instance_uri, + crm_namespace.P1_is_identified_by, + self.appellation_uri, + ) + ) + g.add((self.appellation_uri, RDFS.label, Literal(str(instance)))) + + return g diff --git a/apis_core/utils/settings.py b/apis_core/utils/settings.py index e06d22a41..63ce2f153 100644 --- a/apis_core/utils/settings.py +++ b/apis_core/utils/settings.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: MIT import logging +from urllib.parse import urlparse import tomllib from django.conf import settings @@ -34,7 +35,15 @@ def dict_from_toml_directory(directory: str) -> dict: def internal_uris() -> list[str]: - return set( - [getattr(settings, "APIS_BASE_URI", "https://example.org")] - + getattr(settings, "APIS_FORMER_BASE_URIS", []) - ) + return set([apis_base_uri()] + getattr(settings, "APIS_FORMER_BASE_URIS", [])) + + +def apis_base_uri() -> str: + return getattr(settings, "APIS_BASE_URI", "https://example.org") + + +def rdf_namespace_prefix() -> str: + if hasattr(settings, "APIS_RDF_NAMESPACE_PREFIX"): + return settings.APIS_RDF_NAMESPACE_PREFIX + base_uri = urlparse(apis_base_uri()) + return base_uri.hostname.split(".", 1)[0]