Skip to content

Commit c32d23e

Browse files
committed
feat(entity_editing): enable primary source selection during modification
This commit introduces the ability for users to specify a primary source when saving changes to an existing entity via the 'about' page. - Added a dialog that prompts the user for a primary source URL when saving changes. Users can optionally save this URL as their new default. - Updated the `/apply_changes` API endpoint to receive and process `primary_source` and `save_default_source` parameters from the request. - Integrated the `Editor.set_primary_source` method to apply the chosen source to the editing session before saving changes. - Refactored primary source handling in the entity creation workflow to use the same dialog component. - Centralized primary source logic by utilizing functions from `primary_source_utils` in both `api.py` and `entity.py`. - Resolved an issue in `/apply_changes` where the request data could be a list instead of a dictionary.
1 parent b30cdf5 commit c32d23e

File tree

9 files changed

+304
-136
lines changed

9 files changed

+304
-136
lines changed

heritrace/editor.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,17 @@ def to_posix_timestamp(self, value: str | datetime | None) -> float | None:
308308
elif isinstance(value, str):
309309
dt = datetime.fromisoformat(value)
310310
return dt.timestamp()
311+
312+
def set_primary_source(self, source: str | URIRef) -> None:
313+
"""
314+
Set the primary source for this editor instance.
315+
316+
This will affect all future operations performed by this editor.
317+
318+
Args:
319+
source: The primary source URI to use
320+
"""
321+
if source:
322+
if not isinstance(source, URIRef):
323+
source = URIRef(source)
324+
self.source = source

heritrace/routes/api.py

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from heritrace.utils.strategies import (OrphanHandlingStrategy,
2020
ProxyHandlingStrategy)
2121
from heritrace.utils.uri_utils import generate_unique_uri
22+
from heritrace.utils.primary_source_utils import save_user_default_primary_source
2223
from rdflib import RDF, XSD, Graph, URIRef
2324
from resources.datatypes import DATATYPE_MAPPING
2425
import validators
@@ -458,14 +459,42 @@ def format_entities(entities, is_intermediate=False):
458459
@api_bp.route("/apply_changes", methods=["POST"])
459460
@login_required
460461
def apply_changes():
462+
"""Apply changes to entities.
463+
464+
Request body:
465+
{
466+
"subject": (str) Main entity URI being modified,
467+
"changes": (list) List of changes to apply,
468+
"primary_source": (str) Primary source to use for provenance,
469+
"save_default_source": (bool) Whether to save primary_source as default for current user,
470+
"affected_entities": (list) Entities potentially affected by delete operations,
471+
"delete_affected": (bool) Whether to delete affected entities
472+
}
473+
474+
Responses:
475+
200 OK: Changes applied successfully
476+
400 Bad Request: Invalid request or validation error
477+
500 Internal Server Error: Server error while applying changes
478+
"""
461479
try:
462-
# Remove all debug logging statements
463-
changes = request.json
464-
subject = changes[0]["subject"]
465-
affected_entities = changes[0].get("affected_entities", [])
466-
delete_affected = changes[0].get("delete_affected", False)
480+
changes = request.get_json()
481+
482+
if not changes:
483+
return jsonify({"error": "No request data provided"}), 400
484+
485+
first_change = changes[0] if changes else {}
486+
subject = first_change.get("subject")
487+
affected_entities = first_change.get("affected_entities", [])
488+
delete_affected = first_change.get("delete_affected", False)
489+
primary_source = first_change.get("primary_source")
490+
save_default_source = first_change.get("save_default_source", False)
491+
492+
if primary_source and not validators.url(primary_source):
493+
return jsonify({"error": "Invalid primary source URL"}), 400
494+
495+
if save_default_source and primary_source and validators.url(primary_source):
496+
save_user_default_primary_source(current_user.orcid, primary_source)
467497

468-
# Tieni traccia delle entità già eliminate per evitare duplicazioni
469498
deleted_entities = set()
470499
editor = Editor(
471500
get_dataset_endpoint(),
@@ -477,13 +506,14 @@ def apply_changes():
477506
dataset_is_quadstore=current_app.config["DATASET_IS_QUADSTORE"],
478507
)
479508

480-
# Se c'è un'operazione di eliminazione completa dell'entità, includiamo le entità referenzianti
509+
if primary_source and validators.url(primary_source):
510+
editor.set_primary_source(primary_source)
511+
481512
has_entity_deletion = any(
482513
change["action"] == "delete" and not change.get("predicate")
483514
for change in changes
484515
)
485516

486-
# Import entity con l'opzione per includere le entità referenzianti se necessario
487517
editor = import_entity_graph(
488518
editor,
489519
subject,
@@ -494,12 +524,10 @@ def apply_changes():
494524
graph_uri = None
495525
if editor.dataset_is_quadstore:
496526
for quad in editor.g_set.quads((URIRef(subject), None, None, None)):
497-
# Ottieni direttamente l'identificatore del grafo
498527
graph_context = quad[3]
499528
graph_uri = get_graph_uri_from_context(graph_context)
500529
break
501530

502-
# Gestisci prima le creazioni
503531
temp_id_to_uri = {}
504532
for change in changes:
505533
if change["action"] == "create":
@@ -514,7 +542,6 @@ def apply_changes():
514542
parent_entity_type=None,
515543
)
516544

517-
# Poi gestisci le altre modifiche
518545
orphan_strategy = current_app.config.get(
519546
"ORPHAN_HANDLING_STRATEGY", OrphanHandlingStrategy.KEEP
520547
)

heritrace/routes/entity.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@
3232
from heritrace.utils.uri_utils import generate_unique_uri
3333
from heritrace.utils.virtuoso_utils import (VIRTUOSO_EXCLUDED_GRAPHS,
3434
is_virtuoso)
35+
from heritrace.utils.primary_source_utils import (get_default_primary_source,
36+
save_user_default_primary_source)
3537
from rdflib import RDF, XSD, ConjunctiveGraph, Graph, Literal, URIRef
3638
from resources.datatypes import DATATYPE_MAPPING
3739
from SPARQLWrapper import JSON
3840
from time_agnostic_library.agnostic_entity import AgnosticEntity
3941

4042
entity_bp = Blueprint("entity", __name__)
4143

42-
USER_DEFAULT_SOURCE_KEY = "user:{user_id}:default_primary_source"
43-
4444

4545
@entity_bp.route("/about/<path:subject>")
4646
@login_required
@@ -53,6 +53,8 @@ def about(subject):
5353
"""
5454
# Get necessary services and configurations
5555
change_tracking_config = get_change_tracking_config()
56+
57+
default_primary_source = get_default_primary_source(current_user.orcid)
5658

5759
# Initialize agnostic entity and get its history
5860
agnostic_entity = AgnosticEntity(
@@ -203,24 +205,19 @@ def about(subject):
203205
is_deleted=is_deleted,
204206
context=context_snapshot,
205207
linked_resources=linked_resources,
208+
default_primary_source=default_primary_source,
206209
)
207210

208211

209212
@entity_bp.route("/create-entity", methods=["GET", "POST"])
210213
@login_required
211214
def create_entity():
215+
"""
216+
Create a new entity in the dataset.
217+
"""
212218
form_fields = get_form_fields()
213219

214-
user_default_source = None
215-
if current_user.is_authenticated:
216-
key = USER_DEFAULT_SOURCE_KEY.format(user_id=current_user.orcid)
217-
try:
218-
user_default_source = current_app.redis_client.get(key)
219-
except Exception as e:
220-
current_app.logger.error(f"Failed to get user default primary source from Redis: {e}")
221-
user_default_source = None # Ensure it's None on error
222-
223-
default_primary_source = user_default_source or current_app.config["PRIMARY_SOURCE"]
220+
default_primary_source = get_default_primary_source(current_user.orcid)
224221

225222
entity_types = sorted(
226223
[
@@ -274,12 +271,7 @@ def create_entity():
274271
return jsonify({"status": "error", "errors": [gettext("Invalid primary source URL provided")]}), 400
275272

276273
if save_default_source and primary_source and validators.url(primary_source):
277-
try:
278-
key = USER_DEFAULT_SOURCE_KEY.format(user_id=current_user.orcid)
279-
current_app.redis_client.set(key, primary_source)
280-
current_app.logger.info(f"Saved new default primary source for user {current_user.orcid}: {primary_source}")
281-
except Exception as e:
282-
current_app.logger.error(f"Failed to save user default primary source to Redis: {e}")
274+
save_user_default_primary_source(current_user.orcid, primary_source)
283275

284276
editor = Editor(
285277
get_dataset_endpoint(),

heritrace/static/js/creation_workflow.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,12 +152,18 @@ function initializeMandatoryElements(container) {
152152
});
153153
}
154154

155-
// Function to initialize the form based on the selected entity type
156155
function initializeForm() {
157156
let selectedUri;
158157
if ($('#entity_type').length) {
159-
// In create_entity.jinja
160158
selectedUri = $('#entity_type').val();
159+
160+
if (!selectedUri) {
161+
selectedUri = $('#entity_type option:not(:disabled):first').val();
162+
if (selectedUri) {
163+
$('#entity_type').val(selectedUri);
164+
}
165+
}
166+
161167
$('#entity_type option').prop('selected', function() {
162168
return this.value === selectedUri;
163169
});

heritrace/templates/create_entity.jinja

Lines changed: 5 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292

9393
<script src="{{ url_for('static', filename='js/creation_workflow.js') }}"></script>
9494
{% include 'top_level_search.jinja' %}
95+
{% include 'primary_source_handler.jinja' %}
9596
<script>
9697
window.dataset_db_triplestore = {{ dataset_db_triplestore | tojson | safe }};
9798
window.dataset_db_text_index_enabled = {{ dataset_db_text_index_enabled | tojson | safe }};
@@ -418,105 +419,10 @@
418419
return;
419420
}
420421
421-
Swal.fire({
422-
title: '{{ _("Enter Primary Source") }}',
423-
html: `
424-
<p>{{ _("Please provide the URL of the primary source for this entity, or leave blank if not applicable") }}</p>
425-
<div class="mb-2"><strong id="primary-source-label">{{ _("Current Default:") }}</strong></div>
426-
<div id="primary-source-preview" class="default-source-preview p-2 border rounded bg-light">
427-
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
428-
{{ _("Loading preview...") }}
429-
</div>
430-
`,
431-
input: 'url',
432-
inputValue: '{{ default_primary_source }}',
433-
inputPlaceholder: 'Enter the URL',
434-
showCancelButton: true,
435-
confirmButtonText: '{{ _("Confirm and Create") }}',
436-
cancelButtonText: '{{ _("Cancel") }}',
437-
footer: `
438-
<div class="form-check" style="text-align: left;">
439-
<input type="checkbox" class="form-check-input" id="save-default-source">
440-
<label class="form-check-label" for="save-default-source">{{ _("Save this URL as my default primary source") }}</label>
441-
</div>
442-
`,
443-
didOpen: () => {
444-
const previewContainer = $('#primary-source-preview');
445-
const swalInput = Swal.getInput();
446-
const defaultSourceUrl = '{{ default_primary_source }}';
447-
const labelContainer = $('#primary-source-label');
448-
449-
const updatePrimarySourcePreview = (url) => {
450-
const previewContainer = $('#primary-source-preview');
451-
const labelContainer = $('#primary-source-label');
452-
const originalDefaultSource = '{{ default_primary_source }}';
453-
454-
if (!url) {
455-
labelContainer.text('{{ _("Source (Optional):") }}');
456-
} else if (url === originalDefaultSource) {
457-
labelContainer.text('{{ _("Current Default:") }}');
458-
} else {
459-
labelContainer.text('{{ _("Preview:") }}');
460-
}
461-
462-
if (!url) {
463-
previewContainer.html('{{ _("No primary source will be recorded.") }}');
464-
return;
465-
}
466-
467-
if (!validateUrl(url)) {
468-
previewContainer.html('{{ _("Enter a valid URL or leave blank") }}');
469-
return;
470-
}
471-
472-
previewContainer.html(
473-
`<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> {{ _("Loading...") }}`
474-
);
475-
$.ajax({
476-
url: '{{ url_for("api.format_source_api") }}',
477-
type: 'POST',
478-
contentType: 'application/json',
479-
data: JSON.stringify({ url: url }),
480-
success: function(response) {
481-
previewContainer.html(response.formatted_html);
482-
},
483-
error: function() {
484-
previewContainer.html(`<a href="${url}" target="_blank">${url}</a> <small class="text-danger">(${ '{{ _("Preview failed to load") }}' })</small>`);
485-
}
486-
});
487-
};
488-
489-
const debouncedUpdatePreview = debounce(updatePrimarySourcePreview, 500);
490-
491-
if (defaultSourceUrl) {
492-
updatePrimarySourcePreview(defaultSourceUrl);
493-
} else {
494-
previewContainer.html('{{ _("No default source set") }}');
495-
labelContainer.text('{{ _("Source:") }}');
496-
}
497-
498-
if (swalInput) {
499-
$(swalInput).on('input', function() {
500-
debouncedUpdatePreview($(this).val());
501-
});
502-
}
503-
},
504-
inputValidator: (value) => {
505-
if (!value) {
506-
return null;
507-
}
508-
try {
509-
new URL(value);
510-
} catch (_) {
511-
return '{{ _("Please enter a valid URL or leave blank.") }}';
512-
}
513-
return null;
514-
}
515-
}).then((result) => {
516-
if (result.isConfirmed) {
517-
const primarySourceUrl = result.value;
518-
const saveAsDefault = $('#save-default-source').is(':checked');
519-
422+
showPrimarySourceDialog({
423+
defaultPrimarySource: '{{ default_primary_source }}',
424+
formatSourceApiUrl: '{{ url_for("api.format_source_api") }}',
425+
onConfirm: function(primarySourceUrl, saveAsDefault) {
520426
let structuredData = {};
521427
522428
if ('{{shacl}}' === 'True') {

heritrace/templates/entity/about.jinja

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,16 @@
226226
</div>
227227
{# End New Similar Resources Section #}
228228

229+
{% include 'primary_source_handler.jinja' %}
229230
<script>
230231
let originalOrder = {};
231232
const optional_values = {{ optional_values|tojson|safe }};
232233
const shacl = {{ shacl|tojson|safe }};
233234
234235
const entity_type = "{{ entity_type }}";
235236
const subject = "{{ subject }}";
237+
const default_primary_source = "{{ default_primary_source }}";
238+
const format_source_api_url = "{{ url_for('api.format_source_api') }}";
236239
237240
window.dataset_db_triplestore = {{ dataset_db_triplestore | tojson | safe }};
238241
window.dataset_db_text_index_enabled = {{ dataset_db_text_index_enabled | tojson | safe }};
@@ -889,14 +892,29 @@
889892
});
890893
return;
891894
}
895+
892896
processOrphanCheck(pendingChanges, entity_type, function(affectedEntities, shouldDelete) {
893897
// Aggiorna il primo elemento di pendingChanges con le informazioni sulle entità affette
894898
if (affectedEntities && affectedEntities.length > 0) {
895899
pendingChanges[0].affected_entities = affectedEntities;
896900
pendingChanges[0].delete_affected = shouldDelete;
897901
}
898-
applyChangesToEntity(pendingChanges, function() {
899-
window.location.href = "{{ url_for('entity.about', subject=subject) }}";
902+
903+
// Use the new primary source dialog
904+
showPrimarySourceDialog({
905+
defaultPrimarySource: default_primary_source,
906+
formatSourceApiUrl: format_source_api_url,
907+
onConfirm: function(primarySourceUrl, saveAsDefault) {
908+
// Add primary source information to the first change
909+
if (pendingChanges.length > 0) {
910+
pendingChanges[0].primary_source = primarySourceUrl;
911+
pendingChanges[0].save_default_source = saveAsDefault;
912+
}
913+
914+
applyChangesToEntity(pendingChanges, function() {
915+
window.location.href = "{{ url_for('entity.about', subject=subject) }}";
916+
});
917+
}
900918
});
901919
});
902920
});
@@ -935,14 +953,12 @@ $(document).ready(function() {
935953
limit: 5 // Adjust limit as needed
936954
},
937955
success: function(response) {
938-
console.log("API Response:", response); // Log the whole response
939956
loadingIndicator.remove();
940957
if (response.status === 'success' && response.results && response.results.length > 0) {
941958
similarResourcesContainer.empty(); // Clear previous results
942959
noSimilarResourcesMessage.hide();
943960
944961
response.results.forEach(function(simRes, index) {
945-
console.log(`Processing item ${index}:`, simRes);
946962
let typesHtml = '';
947963
if (simRes.types && simRes.types.length > 0) {
948964
// Use the pre-fetched type_labels from the API response
@@ -953,7 +969,6 @@ $(document).ready(function() {
953969
let compareUrl = "{{ url_for('merge.compare_and_merge', subject='SUBJECT_PLACEHOLDER', other_subject='OTHER_PLACEHOLDER') }}";
954970
compareUrl = compareUrl.replace('SUBJECT_PLACEHOLDER', encodeURIComponent(subjectUri));
955971
compareUrl = compareUrl.replace('OTHER_PLACEHOLDER', encodeURIComponent(simRes.uri));
956-
console.log(compareUrl);
957972
const itemHtml = `
958973
<div class="list-group-item" style="position: relative;">
959974
<div class="d-flex flex-wrap gap-2 justify-content-between align-items-start">

0 commit comments

Comments
 (0)