diff --git a/.env_app-example b/.env_app-example index ff2ecc82..9b937388 100644 --- a/.env_app-example +++ b/.env_app-example @@ -15,3 +15,5 @@ export BASE_URL="http://localhost:8000/" # Make sure APP_ROOT variable ends with a / # this is the prefix that vogon will be deployed under (e.g. "vogon-2" for deployment at my-server.org/vogon-2) export APP_ROOT= + +export QUADRIGA_ENDPOINT= \ No newline at end of file diff --git a/annotations/admin.py b/annotations/admin.py index 888c5a97..052ed509 100755 --- a/annotations/admin.py +++ b/annotations/admin.py @@ -1,7 +1,6 @@ from django.contrib import admin from annotations.forms import * from annotations.models import * -from annotations.tasks import submit_relationsets_to_quadriga from itertools import groupby @@ -50,81 +49,12 @@ def created_by(self, obj): return obj.createdBy -def submit_relationsets(modeladmin, request, queryset): - """ - Submit selected :class:`.RelationSet`\s to Quadriga. - - Will quietly skip any :class:`.RelationSet`\s that have already been - submitted. - """ - - queryset = queryset.filter(submitted=False, pending=False) - - # Do not submit a relationset to Quadriga if the constituent interpretations - # involve concepts that are not resolved. - all_rsets = [rs for rs in queryset if rs.ready()] - - project_grouper = lambda rs: getattr(rs.project, 'quadriga_id', -1) - for project_id, project_group in groupby(sorted(all_rsets, key=project_grouper), key=project_grouper): - for text_id, text_group in groupby(project_group, key=lambda rs: rs.occursIn.id): - text = Text.objects.get(pk=text_id) - for user_id, user_group in groupby(text_group, key=lambda rs: rs.createdBy.id): - user = VogonUser.objects.get(pk=user_id) - # We lose the iterator after the first pass, so we want a list here. - rsets = [] - for rs in user_group: - rsets.append(rs.id) - rs.pending = True - rs.save() - kwargs = {} - if project_id: - kwargs.update({ - 'project_id': project_id, - }) - submit_relationsets_to_quadriga.delay(rsets, text.id, user.id, **kwargs) - - -def submit_relationsets_synch(modeladmin, request, queryset): - """ - Submit selected :class:`.RelationSet`\s to Quadriga. - - Will quietly skip any :class:`.RelationSet`\s that have already been - submitted. - """ - - queryset = queryset.filter(submitted=False, pending=False) - - # Do not submit a relationset to Quadriga if the constituent interpretations - # involve concepts that are not resolved. - all_rsets = [rs for rs in queryset if rs.ready()] - - project_grouper = lambda rs: getattr(rs.project, 'quadriga_id', -1) - for project_id, project_group in groupby(sorted(all_rsets, key=project_grouper), key=project_grouper): - for text_id, text_group in groupby(project_group, key=lambda rs: rs.occursIn.id): - text = Text.objects.get(pk=text_id) - for user_id, user_group in groupby(text_group, key=lambda rs: rs.createdBy.id): - user = VogonUser.objects.get(pk=user_id) - # We lose the iterator after the first pass, so we want a list here. - rsets = [] - for rs in user_group: - rsets.append(rs.id) - rs.pending = True - rs.save() - kwargs = {} - if project_id and project_id > 0: - kwargs.update({ - 'project_id': project_id, - }) - submit_relationsets_to_quadriga(rsets, text.id, user.id, **kwargs) - - class RelationSetAdmin(admin.ModelAdmin): class Meta: model = RelationSet list_display = ('id', 'createdBy', 'occursIn', 'created', 'ready', - 'pending', 'submitted', ) - actions = (submit_relationsets, submit_relationsets_synch) + 'submitted', 'status',) class AppellationAdmin(admin.ModelAdmin): diff --git a/annotations/annotators.py b/annotations/annotators.py index cff338ca..d617d7f8 100644 --- a/annotations/annotators.py +++ b/annotations/annotators.py @@ -50,15 +50,9 @@ def my_view(request, text_id): """ - -import requests from django.shortcuts import get_object_or_404, render from django.http import Http404 -from annotations.tasks import tokenize -from annotations.utils import basepath -from annotations.models import TextCollection, VogonUserDefaultProject, Text -from urllib.parse import urlparse -import chardet +from annotations.models import TextCollection, Text class Annotator(object): diff --git a/annotations/migrations/0042_relationset_status.py b/annotations/migrations/0042_relationset_status.py new file mode 100644 index 00000000..9bf691b3 --- /dev/null +++ b/annotations/migrations/0042_relationset_status.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.20 on 2024-10-29 16:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0041_delete_importedcitesphereitem'), + ] + + operations = [ + migrations.AddField( + model_name='relationset', + name='status', + field=models.CharField(choices=[('not_ready', 'Not Ready'), ('ready_to_submit', 'Ready to Submit'), ('submitted', 'Submitted')], default='not_ready', max_length=20), + ), + ] diff --git a/annotations/migrations/0043_remove_relationset_pending.py b/annotations/migrations/0043_remove_relationset_pending.py new file mode 100644 index 00000000..4e4d63db --- /dev/null +++ b/annotations/migrations/0043_remove_relationset_pending.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.20 on 2024-10-29 18:22 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0042_relationset_status'), + ] + + operations = [ + migrations.RemoveField( + model_name='relationset', + name='pending', + ), + ] diff --git a/annotations/migrations/0044_merge_20241213_1539.py b/annotations/migrations/0044_merge_20241213_1539.py new file mode 100644 index 00000000..c78608f1 --- /dev/null +++ b/annotations/migrations/0044_merge_20241213_1539.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.20 on 2024-12-13 15:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('annotations', '0042_textcollection_collaborators'), + ('annotations', '0043_remove_relationset_pending'), + ] + + operations = [ + ] diff --git a/annotations/models.py b/annotations/models.py index 4508e5b2..f89fd25f 100644 --- a/annotations/models.py +++ b/annotations/models.py @@ -776,6 +776,10 @@ class RelationSet(models.Model): A :class:`.RelationSet` organizes :class:`.Relation`\s into complete statements. """ + # RelationSet statuses + STATUS_NOT_READY = 'not_ready' + STATUS_READY_TO_SUBMIT = 'ready_to_submit' + STATUS_SUBMITTED = 'submitted' project = models.ForeignKey('TextCollection', related_name='relationsets', null=True, blank=True, on_delete=models.CASCADE) @@ -799,12 +803,15 @@ class RelationSet(models.Model): occursIn = models.ForeignKey('Text', related_name='relationsets', on_delete=models.CASCADE) """The text on which this RelationSet is based.""" - pending = models.BooleanField(default=False) - """ - A :class:`.RelationSet` is pending if it has been selected for submission, - but the submission process has not yet completed. The primary purpose of - this field is to prevent duplicate submissions. - """ + STATUS_CHOICES = [ + (STATUS_NOT_READY, 'Not Ready'), + (STATUS_READY_TO_SUBMIT, 'Ready to Submit'), + (STATUS_SUBMITTED, 'Submitted'), + ] + + status = models.CharField( + max_length=20, choices=STATUS_CHOICES, default=STATUS_NOT_READY + ) submitted = models.BooleanField(default=False) """ @@ -920,6 +927,20 @@ def ready(self): return all(map(criteria, values)) ready.boolean = True # So that we can display a nifty icon in changelist. + def update_status(self): + """ + Check if the RelationSet is ready and update the status accordingly. + """ + if self.ready(): # Check readiness based on the concepts + if self.status != self.STATUS_SUBMITTED: # Avoid overriding submitted status + self.status = self.STATUS_READY_TO_SUBMIT + self.submitted = False + else: + self.status = self.STATUS_NOT_READY + self.submitted = False + + self.save() + def appellations(self): """ Get all non-predicate appellations in child :class:`.Relation`\s. @@ -1160,4 +1181,4 @@ class DocumentPosition(models.Model): If :attr:`.position_type` is :attr:`.WHOLE_DOCUMENT`\, then this can be blank. - """ + """ \ No newline at end of file diff --git a/annotations/quadriga.py b/annotations/quadriga.py index 606950db..627fe9f4 100644 --- a/annotations/quadriga.py +++ b/annotations/quadriga.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType from django.conf import settings +from django.utils import timezone from annotations.models import Relation, Appellation, DateAppellation, DocumentPosition @@ -7,9 +8,6 @@ import datetime import re import uuid -import requests -from requests.auth import HTTPBasicAuth - def _created_element(element, annotation): ET.SubElement(element, 'id') @@ -245,26 +243,6 @@ def to_quadruples(relationsets, text, user, network_label=None, return project, params -def submit_relationsets(relationsets, text, user, - userid=settings.QUADRIGA_USERID, - password=settings.QUADRIGA_PASSWORD, - endpoint=settings.QUADRIGA_ENDPOINT, **kwargs): - """ - Submit the :class:`.RelationSet`\s in ``relationsets`` to Quadriga. - """ - payload, params = to_quadruples(relationsets, text, user, toString=True, **kwargs) - auth = HTTPBasicAuth(userid, password) - headers = {'Accept': 'application/xml'} - r = requests.post(endpoint, data=payload, auth=auth, headers=headers) - - if r.status_code == requests.codes.ok: - response_data = parse_response(r.text) - response_data.update(params) - return True, response_data - - return False, r.text - - def parse_response(raw_response): QDNS = '{http://www.digitalhps.org/Quadriga}' root = ET.fromstring(raw_response) @@ -275,3 +253,112 @@ def parse_response(raw_response): tag = child.tag.replace(QDNS, '') data[tag] = child.text return data + + + +def build_concept_node(concept, user, creation_time, source_uri): + """ + Helper function to build a concept node dictionary. + """ + return { + "label": concept.label or "", + "metadata": { + "type": "appellation_event", + "interpretation": concept.uri, + "termParts": [ + { + "position": 1, + "expression": concept.label or "", + "normalization": "", + "formattedPointer": "", + "format": "" + } + ] + }, + "context": { + "creator": user.username, + "creationTime": creation_time.strftime('%Y-%m-%d'), + "creationPlace": "", + "sourceUri": source_uri + } + } + +def get_relation_node(user, creation_time, source_uri): + """ + Helper function to build a relation node. + """ + return { + "label": "", + "metadata": { + "type": "relation_event" + }, + "context": { + "creator": user.username, + "creationTime": creation_time.strftime('%Y-%m-%d'), + "creationPlace": "", + "sourceUri": source_uri + } + } + +def generate_graph_data(relationset, user): + nodes = {} + edges = [] + node_counter = 0 + source_uri = relationset.occursIn.uri + + def get_node_id(): + nonlocal node_counter + node_id = str(node_counter) + node_counter += 1 + return node_id + + for relation in relationset.constituents.all(): + subject = getattr(relation.source_content_object, 'interpretation', None) + obj = getattr(relation.object_content_object, 'interpretation', None) + predicate = getattr(relation.predicate, 'interpretation', None) + + # Extract the creation time from the appellation + creation_time = relationset.occursIn.created + + if subject and subject.uri not in nodes: + subject_id = get_node_id() + nodes[subject_id] = build_concept_node(subject, user, creation_time, source_uri) + + if obj and obj.uri not in nodes: + obj_id = get_node_id() + nodes[obj_id] = build_concept_node(obj, user, creation_time, source_uri) + + if predicate and predicate.uri not in nodes: + predicate_id = get_node_id() + nodes[predicate_id] = build_concept_node(predicate, user, creation_time, source_uri) + + relation_id = get_node_id() + nodes[relation_id] = get_relation_node(user, creation_time, source_uri) + + if subject and predicate: + edges.append({"source": subject_id, "relation": "subject", "target": predicate_id}) + + if predicate and obj: + edges.append({"source": predicate_id, "relation": "predicate", "target": obj_id}) + + edges.append({"source": relation_id, "relation": "object", "target": obj_id}) + + return { + "graph": { + "metadata": { + "defaultMapping": { + "subject": {"type": "REF", "reference": "0"}, + "predicate": {"type": "URI", "uri": "", "label": ""}, + "object": {"type": "REF", "reference": "3"} + }, + "context": { + "creator": user.username, + "creationTime": timezone.now().strftime('%Y-%m-%d'), + "creationPlace": "", + "sourceUri": source_uri + } + }, + "nodes": nodes, + "edges": edges + } + } diff --git a/annotations/serializers.py b/annotations/serializers.py index 996a0923..dfd5e69d 100644 --- a/annotations/serializers.py +++ b/annotations/serializers.py @@ -1,5 +1,6 @@ from rest_framework import serializers from annotations.models import VogonUser +from annotations.utils import tokenize from .models import * from concepts.models import Concept, Type @@ -122,7 +123,7 @@ class Meta: model = RelationSet fields = ('id', 'label', 'created', 'template', 'createdBy', 'occursIn', 'appellations', 'concepts', 'project', - 'representation', 'date_appellations') # + 'representation', 'date_appellations', 'status') # class TemporalBoundsSerializer(serializers.ModelSerializer): diff --git a/annotations/static/annotations/css/annotators/text.css b/annotations/static/annotations/css/annotators/text.css index 3b6a4b2d..e5a4039d 100644 --- a/annotations/static/annotations/css/annotators/text.css +++ b/annotations/static/annotations/css/annotators/text.css @@ -198,6 +198,10 @@ background-color: rgb(250, 250, 250); } + .relation-submitted { + border-left: 4px solid #7aef7a; + } + a[data-tooltip] { position: relative; cursor: pointer; diff --git a/annotations/static/annotations/js/annotators/relationlist.js b/annotations/static/annotations/js/annotators/relationlist.js index 40169417..46dd103e 100644 --- a/annotations/static/annotations/js/annotators/relationlist.js +++ b/annotations/static/annotations/js/annotators/relationlist.js @@ -1,64 +1,202 @@ -RelationListItem = { +// RelationListItem Component +const RelationListItem = { props: ['relation'], - template: `