diff --git a/web-app/django/VIM/apps/instruments/admin.py b/web-app/django/VIM/apps/instruments/admin.py index fa511e3c..43bc3669 100644 --- a/web-app/django/VIM/apps/instruments/admin.py +++ b/web-app/django/VIM/apps/instruments/admin.py @@ -1,5 +1,11 @@ from django.contrib import admin -from VIM.apps.instruments.models import Instrument, InstrumentName, Language, AVResource +from VIM.apps.instruments.models import ( + Instrument, + InstrumentName, + Language, + AVResource, + HornbostelSachs, +) admin.site.register(Instrument) admin.site.register(Language) @@ -30,3 +36,23 @@ def get_readonly_fields(self, request, obj=None): "on_wikidata", ) return super().get_readonly_fields(request, obj) + + +@admin.register(HornbostelSachs) +class HornbostelSachsAdmin(admin.ModelAdmin): + list_filter = ("review_status",) + search_fields = ( + "instrument__wikidata_id", + "hbs_class", + ) + + def get_readonly_fields(self, request, obj=None): + """ + For users in the 'reviewer' group, allow only 'review_status', 'hbs_class', and 'is_main' to be editable. + """ + if request.user.groups.filter(name="reviewer").exists(): + return ( + "instrument", + "contributor", + ) + return super().get_readonly_fields(request, obj) diff --git a/web-app/django/VIM/apps/instruments/management/commands/import_instruments.py b/web-app/django/VIM/apps/instruments/management/commands/import_instruments.py index 21076ce1..05304608 100644 --- a/web-app/django/VIM/apps/instruments/management/commands/import_instruments.py +++ b/web-app/django/VIM/apps/instruments/management/commands/import_instruments.py @@ -8,7 +8,13 @@ from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from django.db import transaction -from VIM.apps.instruments.models import Instrument, InstrumentName, Language, AVResource +from VIM.apps.instruments.models import ( + Instrument, + InstrumentName, + Language, + AVResource, + HornbostelSachs, +) class Command(BaseCommand): @@ -124,7 +130,6 @@ def create_database_objects( instrument, _ = Instrument.objects.update_or_create( wikidata_id=instrument_attrs["wikidata_id"], defaults={ - "hornbostel_sachs_class": instrument_attrs["hornbostel_sachs_class"], "mimo_class": instrument_attrs["mimo_class"], }, ) @@ -179,6 +184,19 @@ def create_database_objects( }, ) + hbs_value = ( + instrument_attrs["hornbostel_sachs_class"] or settings.EMPTY_HBS_CATEGORY + ) + hbs_obj = None + if hbs_value and hbs_value != settings.EMPTY_HBS_CATEGORY: + hbs_obj = HornbostelSachs.objects.create( + instrument=instrument, + hbs_class=hbs_value, + is_main=True, + review_status="verified", + contributor=self.default_contributor, + ) + instrument.hornbostel_sachs_class = hbs_obj img_obj = AVResource.objects.create( instrument=instrument, type="image", diff --git a/web-app/django/VIM/apps/instruments/management/commands/index_data.py b/web-app/django/VIM/apps/instruments/management/commands/index_data.py index a61cfeff..67cb69c6 100644 --- a/web-app/django/VIM/apps/instruments/management/commands/index_data.py +++ b/web-app/django/VIM/apps/instruments/management/commands/index_data.py @@ -32,8 +32,8 @@ def handle(self, *args, **options): Instrument.objects.annotate( sid=Concat(V("instrument-"), "id", output_field=CharField()), wikidata_id_s=F("wikidata_id"), - hornbostel_sachs_class_s=F("hornbostel_sachs_class"), - hbs_prim_cat_s=Left(F("hornbostel_sachs_class"), 1), + hornbostel_sachs_class_s=F("hornbostel_sachs_class__hbs_class"), + hbs_prim_cat_s=Left(F("hornbostel_sachs_class__hbs_class"), 1), mimo_class_s=F("mimo_class"), type=V("instrument"), thumbnail_url=F("thumbnail__url"), diff --git a/web-app/django/VIM/apps/instruments/migrations/0012_hornbostelsachs_and_more.py b/web-app/django/VIM/apps/instruments/migrations/0012_hornbostelsachs_and_more.py new file mode 100644 index 00000000..d49f7d0f --- /dev/null +++ b/web-app/django/VIM/apps/instruments/migrations/0012_hornbostelsachs_and_more.py @@ -0,0 +1,87 @@ +# Generated by Django 4.2.5 on 2026-01-29 23:13 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("instruments", "0011_language_html_direction"), + ] + + operations = [ + migrations.CreateModel( + name="HornbostelSachs", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "hbs_class", + models.CharField( + help_text="Hornbostel-Sachs classification", + max_length=50, + null=True, + ), + ), + ( + "is_main", + models.BooleanField( + default=False, + help_text="Is this the main HBS classification for this instrument?", + ), + ), + ( + "review_status", + models.CharField( + choices=[ + ("verified", "Verified"), + ("unverified", "Unverified"), + ("under_review", "Under Review"), + ("needs_additional_review", "Needs Additional Review"), + ("rejected", "Rejected"), + ], + default="unverified", + max_length=50, + ), + ), + ( + "contributor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "instrument", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="hbs_entries", + to="instruments.instrument", + ), + ), + ], + ), + migrations.AlterField( + model_name="instrument", + name="hornbostel_sachs_class", + field=models.ForeignKey( + blank=True, + help_text="Currently selected Hornbostel–Sachs classification", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="main_for", + to="instruments.hornbostelsachs", + ), + ), + ] diff --git a/web-app/django/VIM/apps/instruments/models/__init__.py b/web-app/django/VIM/apps/instruments/models/__init__.py index 54813b80..3361ecdf 100644 --- a/web-app/django/VIM/apps/instruments/models/__init__.py +++ b/web-app/django/VIM/apps/instruments/models/__init__.py @@ -2,3 +2,4 @@ from VIM.apps.instruments.models.instrument_name import InstrumentName from VIM.apps.instruments.models.language import Language from VIM.apps.instruments.models.avresource import AVResource +from VIM.apps.instruments.models.hornbostel_sachs import HornbostelSachs diff --git a/web-app/django/VIM/apps/instruments/models/hornbostel_sachs.py b/web-app/django/VIM/apps/instruments/models/hornbostel_sachs.py new file mode 100644 index 00000000..66b1e7fd --- /dev/null +++ b/web-app/django/VIM/apps/instruments/models/hornbostel_sachs.py @@ -0,0 +1,57 @@ +from django.db import models + + +class HornbostelSachs(models.Model): + instrument = models.ForeignKey( + "Instrument", + on_delete=models.CASCADE, + related_name="hbs_entries", + ) + + hbs_class = models.CharField( + max_length=50, null=True, help_text="Hornbostel-Sachs classification" + ) + + is_main = models.BooleanField( + default=False, + help_text="Is this the main HBS classification for this instrument?", + ) + + review_status = models.CharField( + max_length=50, + choices=[ + ("verified", "Verified"), + ("unverified", "Unverified"), + ("under_review", "Under Review"), + ("needs_additional_review", "Needs Additional Review"), + ("rejected", "Rejected"), + ], + default="unverified", + ) + + contributor = models.ForeignKey( + "auth.User", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + + # TODO: add verified_by field to track who verified the name + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + if self.is_main: + Instrument = self._meta.get_field("instrument").related_model + instrument = self.instrument + if instrument.hornbostel_sachs_class_id != self.id: + instrument.hornbostel_sachs_class = self + instrument.save(update_fields=["hornbostel_sachs_class"]) + + # If there is another HBS object set as main for this instrument, unset others + other_mains = ( + type(self) + .objects.filter(instrument=self.instrument, is_main=True) + .exclude(pk=self.pk) + ) + if other_mains.exists(): + other_mains.update(is_main=False) diff --git a/web-app/django/VIM/apps/instruments/models/instrument.py b/web-app/django/VIM/apps/instruments/models/instrument.py index bcabbfe9..b37b9c53 100644 --- a/web-app/django/VIM/apps/instruments/models/instrument.py +++ b/web-app/django/VIM/apps/instruments/models/instrument.py @@ -17,8 +17,13 @@ class Instrument(models.Model): null=True, related_name="thumbnail_of", ) - hornbostel_sachs_class = models.CharField( - max_length=50, blank=True, help_text="Hornbostel-Sachs classification" + hornbostel_sachs_class = models.ForeignKey( + "HornbostelSachs", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="main_for", + help_text="Currently selected Hornbostel–Sachs classification", ) mimo_class = models.CharField( max_length=50, diff --git a/web-app/django/VIM/apps/instruments/views/instrument_detail.py b/web-app/django/VIM/apps/instruments/views/instrument_detail.py index 942b2429..353cbfb0 100644 --- a/web-app/django/VIM/apps/instruments/views/instrument_detail.py +++ b/web-app/django/VIM/apps/instruments/views/instrument_detail.py @@ -1,5 +1,5 @@ from django.views.generic import DetailView -from VIM.apps.instruments.models import Instrument, Language +from VIM.apps.instruments.models import Instrument, Language, HornbostelSachs class InstrumentDetail(DetailView): @@ -13,10 +13,11 @@ class InstrumentDetail(DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) + instrument = context["instrument"] # Query the instrument names in all languages - instrument_names = ( - context["instrument"].instrumentname_set.all().select_related("language") + instrument_names = instrument.instrumentname_set.all().select_related( + "language" ) if self.request.user.is_authenticated: # Show all names for authenticated users @@ -61,4 +62,30 @@ def get_context_data(self, **kwargs): context["active_tab"] = "instruments" + # Add user HBS to the context, if present + user_hbs = None + user = self.request.user + if user.is_authenticated: + user_hbs_qs = HornbostelSachs.objects.filter( + instrument=instrument, contributor=user + ).order_by( + "-is_main", "-id" + ) # prioritize main if more than one, fallback to latest + if user_hbs_qs.exists(): + user_hbs = user_hbs_qs.first() + context["user_hbs"] = user_hbs + + # Add HBS proposals for this instrument to the context, if instrument has no HBS + if not instrument.hornbostel_sachs_class: + hbs_proposals_qs = ( + HornbostelSachs.objects.filter(instrument=instrument, is_main=False) + .order_by("-id") + .values_list("hbs_class", flat=True) + ) + # Deduplicate and sort + hbs_proposals = sorted(set(hbs_proposals_qs)) + context["hbs_proposals"] = hbs_proposals + else: + context["hbs_proposals"] = None + return context diff --git a/web-app/django/VIM/apps/instruments/views/update_umil_db.py b/web-app/django/VIM/apps/instruments/views/update_umil_db.py index 74a52731..2f317922 100644 --- a/web-app/django/VIM/apps/instruments/views/update_umil_db.py +++ b/web-app/django/VIM/apps/instruments/views/update_umil_db.py @@ -5,7 +5,12 @@ from django.views.decorators.http import require_http_methods from django.http import HttpRequest, JsonResponse from django.shortcuts import get_object_or_404 -from VIM.apps.instruments.models import Instrument, Language, InstrumentName +from VIM.apps.instruments.models import ( + Instrument, + Language, + InstrumentName, + HornbostelSachs, +) from typing import Any, Dict, List @@ -168,11 +173,102 @@ def delete_name(request: HttpRequest) -> JsonResponse: ) +def add_hbs(request, pk: int) -> JsonResponse: + """ + Add a Hornbostel-Sachs classification for an instrument. + + Expects JSON: + { + "wikidata_id": "Q12345", + "hornbostel_sachs_class": "111.242.12" + } + """ + + try: + data: Dict[str, Any] = json.loads(request.body) + except Exception as e: + return JsonResponse( + {"status": "error", "message": f"Invalid or missing JSON: {e}"}, + status=400, + ) + + wikidata_id = data.get("wikidata_id") + hbs_class = data.get("hornbostel_sachs_class") + + if not wikidata_id or not hbs_class: + return JsonResponse( + {"status": "error", "message": f"Missing required data"}, + status=400, + ) + + # Get instrument from pk (since path sends pk) + instrument = get_object_or_404(Instrument, wikidata_id=wikidata_id) + + # Check if the user has already provided an HBS classification for this instrument + existing_hbs = HornbostelSachs.objects.filter( + instrument=instrument, contributor=request.user + ) + + if existing_hbs.exists(): + # There is at least one existing HBS entry by this user for this instrument + for hbs in existing_hbs: + if hbs.hbs_class == hbs_class: + # If the submitted class is the same as an existing one, reject as duplicate + return JsonResponse( + { + "status": "error", + "message": f"You have already submitted this Hornbostel-Sachs class for this instrument.", + }, + status=400, + ) + # If the user already submitted an HBS for this instrument, update their previous entry with the new hbs_class + prev_hbs = existing_hbs.first() + prev_hbs.hbs_class = hbs_class + prev_hbs.review_status = "under_review" + prev_hbs.save() + return JsonResponse( + { + "status": "success", + "message": "Your Hornbostel-Sachs classification was updated to the new value.", + "hbs_id": prev_hbs.id, + }, + status=200, + ) + else: + # No previous HBS for this user/instrument + new_hbs = HornbostelSachs.objects.create( + instrument=instrument, + hbs_class=hbs_class, + contributor=request.user, + is_main=False, + review_status="under_review", + ) + return JsonResponse( + { + "status": "success", + "message": "Hornbostel-Sachs classification added successfully.", + "hbs_id": new_hbs.id, + }, + status=200, + ) + + @login_required @require_http_methods(["POST", "DELETE"]) def update_umil_db(request: HttpRequest, pk: int) -> JsonResponse: if request.method == "POST": - return add_name(request) + try: + data = json.loads(request.body) + except Exception: + return JsonResponse( + {"status": "error", "message": "Malformed JSON."}, + status=400, + ) + # If the payload contains 'hornbostel_sachs_class', it is an add_class operation + if data.get("hornbostel_sachs_class") is not None: + return add_hbs(request, pk) + else: + return add_name(request) elif request.method == "DELETE": return delete_name(request) diff --git a/web-app/django/VIM/settings.py b/web-app/django/VIM/settings.py index c5b98e51..91e36e64 100644 --- a/web-app/django/VIM/settings.py +++ b/web-app/django/VIM/settings.py @@ -221,7 +221,7 @@ SOLR_URL = "http://solr:8983/solr/virtual-instrument-museum" SOLR_TIMEOUT = 10 -EMPTY_HBS_CATEGORY = "0" +EMPTY_HBS_CATEGORY = None # DEFAULT PAGE SETTINGS DEFAULT_LANGUAGE = "English" diff --git a/web-app/django/VIM/templates/instruments/detail.html b/web-app/django/VIM/templates/instruments/detail.html index a3e5f3b6..f4df6ee1 100644 --- a/web-app/django/VIM/templates/instruments/detail.html +++ b/web-app/django/VIM/templates/instruments/detail.html @@ -11,6 +11,7 @@ {% vite_asset 'src/instruments/JumpToTop.ts' %} {% vite_asset 'src/instruments/AddName.ts' %} {% vite_asset 'src/instruments/DeleteName.ts' %} + {% vite_asset 'src/instruments/AddClass.ts' %} {% endblock ts_files %} {% block css_files %} @@ -39,7 +40,39 @@

{{ active_instrument_label.name }}

Hornbostel-Sachs Classification - {{ instrument.hornbostel_sachs_class }} + {% if instrument.hornbostel_sachs_class %} + {{ instrument.hornbostel_sachs_class.hbs_class }} + {% else %} + {% if user.is_authenticated %} + {% if user_hbs %} + {{ user_hbs.hbs_class }} + + + {% else %} + + {% endif %} + {% endif %} + {% endif %} @@ -255,6 +288,7 @@

{{ active_instrument_label.name }}

{% include "instruments/includes/deleteName.html" %} {% include "instruments/includes/addName.html" %} + {% include "instruments/includes/addClass.html" %} {% include "instruments/includes/statusInfo.html" %} diff --git a/web-app/django/VIM/templates/instruments/includes/addClass.html b/web-app/django/VIM/templates/instruments/includes/addClass.html new file mode 100644 index 00000000..0aab375c --- /dev/null +++ b/web-app/django/VIM/templates/instruments/includes/addClass.html @@ -0,0 +1,98 @@ +{% load static %} + + + + + diff --git a/web-app/frontend/src/instruments/AddClass.ts b/web-app/frontend/src/instruments/AddClass.ts new file mode 100644 index 00000000..ccde1a5a --- /dev/null +++ b/web-app/frontend/src/instruments/AddClass.ts @@ -0,0 +1,143 @@ +import { Modal } from 'bootstrap'; +import { NameValidator } from './helpers/NameValidator'; + +// Initialize NameValidator +const nameValidator = new NameValidator(); + +// Handle modal show event - populate instrument data +const addClassModal = document.getElementById('addClassModal'); +const proposalsContainer = document.getElementById('ProposalsContainer'); +addClassModal?.addEventListener('show.bs.modal', (event) => { + const triggerButton = (event as any).relatedTarget; + if (!triggerButton) return; + + const instrumentName = triggerButton.getAttribute('data-instrument-name'); + const instrumentWikidataId = triggerButton.getAttribute( + 'data-instrument-wikidata-id', + ); + const instrumentPk = triggerButton.getAttribute('data-instrument-pk'); + const className = triggerButton.getAttribute('data-class-name'); + + addClassModal.querySelector('#instrumentNameInModal')!.textContent = + instrumentName; + addClassModal.querySelector('#instrumentWikidataIdInModal')!.textContent = + instrumentWikidataId; + (addClassModal.querySelector( + '#instrumentPkInClassModal', + ) as HTMLInputElement)!.value = instrumentPk; + addClassModal.querySelector('#instrumentClassNameInModal')!.textContent = + className; + + if (className === 'Hornbostel-Sachs Class') { + proposalsContainer.style.display = 'block'; + } else { + proposalsContainer.style.display = 'none'; + } +}); + +// Reset modal on hide +addClassModal?.addEventListener('hide.bs.modal', () => { + const form = document.getElementById('addClassForm') as HTMLFormElement; + form?.reset(); + + const classInput = document.getElementById('classInput') as HTMLInputElement; + if (classInput) classInput.value = ''; + + const container = classInput?.closest('.class-input'); + container?.classList.remove('is-valid', 'is-invalid'); + + const resultMsg = document.getElementById('publishClassResults'); + if (resultMsg) resultMsg.textContent = ''; +}); + +// Initialize DOM events +document.addEventListener('DOMContentLoaded', () => { + const addClassForm = document.getElementById( + 'addClassForm', + ) as HTMLFormElement; + if (!addClassForm) return; + + // Handle form submission + addClassForm.addEventListener('submit', (e) => { + e.preventDefault(); + + const classInputElem = document.getElementById( + 'classInput', + ) as HTMLInputElement; + const classInput = classInputElem?.value?.trim() || ''; + const container = classInputElem.closest('.class-input'); + + const validationResult = nameValidator.validateHBSClassInput(classInput); + nameValidator.displayFeedback(container!, validationResult); + + if (!validationResult.isValid) return; + + // Show confirmation modal + const confirmationModal = new Modal( + document.getElementById('confirmationClassModal')!, + ); + confirmationModal.show(); + }); + + // Handle confirm publish button + const confirmBtn = document.getElementById('confirmPublishClassBtn'); + confirmBtn?.addEventListener('click', () => { + const classInputElem = document.getElementById( + 'classInput', + ) as HTMLInputElement; + const hbsClass = classInputElem.value.trim(); + const container = classInputElem.closest('.class-input'); + + const validationResult = nameValidator.validateHBSClassInput(hbsClass); + nameValidator.displayFeedback(container!, validationResult); + if (!validationResult.isValid) return; + + const wikidataId = + addClassModal + ?.querySelector('#instrumentWikidataIdInModal') + ?.textContent?.trim() || ''; + const instrumentPk = ( + document.getElementById('instrumentPkInClassModal') as HTMLInputElement + ).value; + const resultMsg = document.getElementById('publishClassResults'); + + const csrfToken = ( + document.querySelector('[name=csrfmiddlewaretoken]') as HTMLInputElement + ).value; + + fetch(`/instrument/${instrumentPk}/names/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken, + }, + body: JSON.stringify({ + wikidata_id: wikidataId, + hornbostel_sachs_class: hbsClass, + }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.status === 'success') { + // Close modals + Modal.getInstance(document.getElementById('addClassModal'))?.hide(); + Modal.getInstance( + document.getElementById('confirmationClassModal'), + )?.hide(); + window.location.reload(); + } else { + if (resultMsg) { + resultMsg.textContent = 'Error: ' + data.message; + resultMsg.classList.add('text-danger'); + } + } + }) + .catch((err) => { + if (resultMsg) { + resultMsg.textContent = + 'An error occurred while publishing: ' + err.message; + resultMsg.classList.add('text-danger'); + } + }); + }); +}); diff --git a/web-app/frontend/src/instruments/helpers/NameValidator.ts b/web-app/frontend/src/instruments/helpers/NameValidator.ts index c3a7d880..4f404811 100644 --- a/web-app/frontend/src/instruments/helpers/NameValidator.ts +++ b/web-app/frontend/src/instruments/helpers/NameValidator.ts @@ -1,6 +1,21 @@ import { WikidataLanguage, ValidationResult } from '../Types'; import { WikidataService } from './WikidataService'; +// Helper function to validate HBS class input +export function isValidHBSClass(input: string): boolean { + // Only allow digits, dot, dash, and plus + if (!/^[1-9.\-+]+$/.test(input)) return false; + // The first and second characters must be a digit between 1 and 5 + if (input.length < 1) return false; + const firstChar = input.charAt(0); + if (!/[1-5]/.test(firstChar)) return false; + if (input.length > 1) { + const secondChar = input.charAt(1); + if (!/[1-5]/.test(secondChar)) return false; + } + return true; +} + export class NameValidator { private languages: WikidataLanguage[]; @@ -91,6 +106,40 @@ export class NameValidator { }; } + /** + * Validates Hornbostel-Sachs class input (for Add Class form) + */ + validateHBSClassInput(hbsClassInput: string): ValidationResult { + const input = hbsClassInput?.trim(); + if (!input) { + return { + isValid: false, + message: 'Please input an HBS number.', + type: 'error', + }; + } + if (input.length > 50) { + return { + isValid: false, + message: 'HBS number must be at most 50 characters.', + type: 'error', + }; + } + if (!isValidHBSClass(input)) { + return { + isValid: false, + message: + 'Only use digits (first two characters 1–5), and the symbols ".", "-", "+".', + type: 'error', + }; + } + return { + isValid: true, + message: `Proposed Classification: ${input}`, + type: 'success', + }; + } + /** * Validates that we have a valid name ID for deletion */ diff --git a/web-app/frontend/vite.config.js b/web-app/frontend/vite.config.js index 15fb7a76..59cf686e 100644 --- a/web-app/frontend/vite.config.js +++ b/web-app/frontend/vite.config.js @@ -14,6 +14,7 @@ export default defineConfig({ input: { main: resolve(__dirname, 'src/main.ts'), instrumentDetail: resolve(__dirname, 'src/instruments/AddName.ts'), + addClass: resolve(__dirname, 'src/instruments/AddClass.ts'), paginationTools: resolve( __dirname, 'src/instruments/PaginationTools.ts',