From e96ce04cd318d4ed3bf0d749cad6dc50107ce157 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 18:45:51 -0500 Subject: [PATCH 1/9] feat: add Hornbostel-Sachs model for crowdsource handeling - Added HornbostelSachs model with fields: instrument FK, hornbostel_sachs_class, contributor, is_main, review_status, on_wikidata - Linked HornbostelSachs to Instrument model via FK - Auto-update instrument main HBS and unset others on save --- .../VIM/apps/instruments/models/__init__.py | 1 + .../instruments/models/hornbostel_sachs.py | 55 +++++++++++++++++++ .../VIM/apps/instruments/models/instrument.py | 9 ++- 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 web-app/django/VIM/apps/instruments/models/hornbostel_sachs.py diff --git a/web-app/django/VIM/apps/instruments/models/__init__.py b/web-app/django/VIM/apps/instruments/models/__init__.py index 54813b80..70505039 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 \ No newline at end of file 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..7eb7f2a3 --- /dev/null +++ b/web-app/django/VIM/apps/instruments/models/hornbostel_sachs.py @@ -0,0 +1,55 @@ +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, From 19b2b929bce669de19e5347fbe995add7736f773 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 18:48:34 -0500 Subject: [PATCH 2/9] fest: add migration table --- .../0012_hornbostelsachs_and_more.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 web-app/django/VIM/apps/instruments/migrations/0012_hornbostelsachs_and_more.py 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", + ), + ), + ] From 63fc48009ff065d39471b2a979fa30318d109653 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 19:04:01 -0500 Subject: [PATCH 3/9] feat: add HBS support to importing instruments and indexing --- .../management/commands/import_instruments.py | 14 ++++++++++++-- .../instruments/management/commands/index_data.py | 5 +++-- web-app/django/VIM/settings.py | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) 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..f4cbb702 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,7 @@ 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 +124,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 +178,17 @@ 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..014c017f 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"), @@ -56,6 +56,7 @@ def handle(self, *args, **options): ) ) + for instrument in instruments: hbs_code = instrument["hbs_prim_cat_s"] instrument["hbs_prim_cat_label_s"] = self.HBS_LABEL_MAP.get(hbs_code, "") 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" From 96314a44b3268cf2301038b487e923c42dc4220f Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 19:06:09 -0500 Subject: [PATCH 4/9] feat: add UI support for publishing HBS class - Handle add-class modal and form - Update NameValidator to validate HBS input - Posts classification through instrument//names/ endpoint --- web-app/frontend/src/instruments/AddClass.ts | 111 ++++++++++++++++++ .../src/instruments/helpers/NameValidator.ts | 49 ++++++++ web-app/frontend/vite.config.js | 1 + 3 files changed, 161 insertions(+) create mode 100644 web-app/frontend/src/instruments/AddClass.ts diff --git a/web-app/frontend/src/instruments/AddClass.ts b/web-app/frontend/src/instruments/AddClass.ts new file mode 100644 index 00000000..cb9c1cb8 --- /dev/null +++ b/web-app/frontend/src/instruments/AddClass.ts @@ -0,0 +1,111 @@ +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'); +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; +}); + +// 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', From a8a109f86889d2461f844de8b2134efe740d74a3 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 19:14:53 -0500 Subject: [PATCH 5/9] feat: add HBS display - show main Hornbostel-Sachs class on instrument detail page - add buttons to propose or edit HBS if authenticated - include addClass.html partial --- .../VIM/templates/instruments/detail.html | 34 +++++++- .../instruments/includes/addClass.html | 81 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 web-app/django/VIM/templates/instruments/includes/addClass.html diff --git a/web-app/django/VIM/templates/instruments/detail.html b/web-app/django/VIM/templates/instruments/detail.html index a3e5f3b6..06d93171 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,37 @@

{{ 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 +286,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..36015380 --- /dev/null +++ b/web-app/django/VIM/templates/instruments/includes/addClass.html @@ -0,0 +1,81 @@ +{% load static %} + + + + + From 71d595b36960fe298dfb24a83ec3dac88d50aa43 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 19:17:52 -0500 Subject: [PATCH 6/9] chore: run code formatters --- .../management/commands/import_instruments.py | 12 ++- .../management/commands/index_data.py | 1 - .../VIM/apps/instruments/models/__init__.py | 2 +- .../instruments/models/hornbostel_sachs.py | 20 ++-- .../apps/instruments/views/update_umil_db.py | 100 +++++++++++++++++- 5 files changed, 120 insertions(+), 15 deletions(-) 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 f4cbb702..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, HornbostelSachs +from VIM.apps.instruments.models import ( + Instrument, + InstrumentName, + Language, + AVResource, + HornbostelSachs, +) class Command(BaseCommand): @@ -178,7 +184,9 @@ def create_database_objects( }, ) - hbs_value = instrument_attrs["hornbostel_sachs_class"] or settings.EMPTY_HBS_CATEGORY + 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( 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 014c017f..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 @@ -56,7 +56,6 @@ def handle(self, *args, **options): ) ) - for instrument in instruments: hbs_code = instrument["hbs_prim_cat_s"] instrument["hbs_prim_cat_label_s"] = self.HBS_LABEL_MAP.get(hbs_code, "") diff --git a/web-app/django/VIM/apps/instruments/models/__init__.py b/web-app/django/VIM/apps/instruments/models/__init__.py index 70505039..3361ecdf 100644 --- a/web-app/django/VIM/apps/instruments/models/__init__.py +++ b/web-app/django/VIM/apps/instruments/models/__init__.py @@ -2,4 +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 \ No newline at end of file +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 index 7eb7f2a3..66b1e7fd 100644 --- a/web-app/django/VIM/apps/instruments/models/hornbostel_sachs.py +++ b/web-app/django/VIM/apps/instruments/models/hornbostel_sachs.py @@ -1,5 +1,6 @@ from django.db import models + class HornbostelSachs(models.Model): instrument = models.ForeignKey( "Instrument", @@ -7,7 +8,9 @@ class HornbostelSachs(models.Model): related_name="hbs_entries", ) - hbs_class = models.CharField(max_length=50, null=True, help_text="Hornbostel-Sachs classification") + hbs_class = models.CharField( + max_length=50, null=True, help_text="Hornbostel-Sachs classification" + ) is_main = models.BooleanField( default=False, @@ -35,21 +38,20 @@ class HornbostelSachs(models.Model): # 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._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']) + 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) + 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/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) From 84c14d7f38376dadc1447e47457e34395dfe4e9b Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 19:19:01 -0500 Subject: [PATCH 7/9] feat: add HornbostelSachs model to Django admin - Enable list filtering by `review_status` - Restrict editable fields for users in the 'reviewer' group --- web-app/django/VIM/apps/instruments/admin.py | 29 +++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/web-app/django/VIM/apps/instruments/admin.py b/web-app/django/VIM/apps/instruments/admin.py index fa511e3c..b325f553 100644 --- a/web-app/django/VIM/apps/instruments/admin.py +++ b/web-app/django/VIM/apps/instruments/admin.py @@ -1,5 +1,12 @@ 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 +37,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) From c316ef902586778cfef681861cd05716a0f51629 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 29 Jan 2026 19:22:39 -0500 Subject: [PATCH 8/9] feat: add user's HBS contribution to the context --- .../instruments/views/instrument_detail.py | 20 ++++++- web-app/frontend/src/instruments/AddClass.ts | 55 ++++++++++++++----- 2 files changed, 57 insertions(+), 18 deletions(-) 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..a2ec30a2 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,17 @@ 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 + return context diff --git a/web-app/frontend/src/instruments/AddClass.ts b/web-app/frontend/src/instruments/AddClass.ts index cb9c1cb8..c5bb5d90 100644 --- a/web-app/frontend/src/instruments/AddClass.ts +++ b/web-app/frontend/src/instruments/AddClass.ts @@ -11,14 +11,21 @@ addClassModal?.addEventListener('show.bs.modal', (event) => { if (!triggerButton) return; const instrumentName = triggerButton.getAttribute('data-instrument-name'); - const instrumentWikidataId = triggerButton.getAttribute('data-instrument-wikidata-id'); + 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; + addClassModal.querySelector('#instrumentNameInModal')!.textContent = + instrumentName; + addClassModal.querySelector('#instrumentWikidataIdInModal')!.textContent = + instrumentWikidataId; + (addClassModal.querySelector( + '#instrumentPkInClassModal', + ) as HTMLInputElement)!.value = instrumentPk; + addClassModal.querySelector('#instrumentClassNameInModal')!.textContent = + className; }); // Reset modal on hide @@ -38,14 +45,18 @@ addClassModal?.addEventListener('hide.bs.modal', () => { // Initialize DOM events document.addEventListener('DOMContentLoaded', () => { - const addClassForm = document.getElementById('addClassForm') as HTMLFormElement; + 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 classInputElem = document.getElementById( + 'classInput', + ) as HTMLInputElement; const classInput = classInputElem?.value?.trim() || ''; const container = classInputElem.closest('.class-input'); @@ -55,14 +66,18 @@ document.addEventListener('DOMContentLoaded', () => { if (!validationResult.isValid) return; // Show confirmation modal - const confirmationModal = new Modal(document.getElementById('confirmationClassModal')!); + 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 classInputElem = document.getElementById( + 'classInput', + ) as HTMLInputElement; const hbsClass = classInputElem.value.trim(); const container = classInputElem.closest('.class-input'); @@ -70,11 +85,18 @@ document.addEventListener('DOMContentLoaded', () => { nameValidator.displayFeedback(container!, validationResult); if (!validationResult.isValid) return; - const wikidataId = addClassModal?.querySelector('#instrumentWikidataIdInModal')?.textContent?.trim() || ''; - const instrumentPk = (document.getElementById('instrumentPkInClassModal') as HTMLInputElement).value; + 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; + const csrfToken = ( + document.querySelector('[name=csrfmiddlewaretoken]') as HTMLInputElement + ).value; fetch(`/instrument/${instrumentPk}/names/`, { method: 'POST', @@ -86,13 +108,15 @@ document.addEventListener('DOMContentLoaded', () => { 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(); + Modal.getInstance( + document.getElementById('confirmationClassModal'), + )?.hide(); window.location.reload(); } else { if (resultMsg) { @@ -103,7 +127,8 @@ document.addEventListener('DOMContentLoaded', () => { }) .catch((err) => { if (resultMsg) { - resultMsg.textContent = 'An error occurred while publishing: ' + err.message; + resultMsg.textContent = + 'An error occurred while publishing: ' + err.message; resultMsg.classList.add('text-danger'); } }); From 06e6e3cbf639989afae055d937e63640ee299165 Mon Sep 17 00:00:00 2001 From: PouyaMohseni Date: Thu, 5 Feb 2026 16:54:50 -0500 Subject: [PATCH 9/9] feat: show the proposals of the hbs class in addClass.html - proposals are linked to the input to make them easier to be edit --- web-app/django/VIM/apps/instruments/admin.py | 1 - .../apps/instruments/views/instrument_detail.py | 13 +++++++++++++ .../VIM/templates/instruments/detail.html | 6 ++++-- .../instruments/includes/addClass.html | 17 +++++++++++++++++ web-app/frontend/src/instruments/AddClass.ts | 7 +++++++ 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/web-app/django/VIM/apps/instruments/admin.py b/web-app/django/VIM/apps/instruments/admin.py index b325f553..43bc3669 100644 --- a/web-app/django/VIM/apps/instruments/admin.py +++ b/web-app/django/VIM/apps/instruments/admin.py @@ -7,7 +7,6 @@ HornbostelSachs, ) - admin.site.register(Instrument) admin.site.register(Language) admin.site.register(AVResource) 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 a2ec30a2..353cbfb0 100644 --- a/web-app/django/VIM/apps/instruments/views/instrument_detail.py +++ b/web-app/django/VIM/apps/instruments/views/instrument_detail.py @@ -75,4 +75,17 @@ def get_context_data(self, **kwargs): 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/templates/instruments/detail.html b/web-app/django/VIM/templates/instruments/detail.html index 06d93171..f4df6ee1 100644 --- a/web-app/django/VIM/templates/instruments/detail.html +++ b/web-app/django/VIM/templates/instruments/detail.html @@ -54,7 +54,8 @@

{{ active_instrument_label.name }}

data-instrument-name="{{ active_instrument_label.name }}" data-instrument-wikidata-id="{{ instrument.wikidata_id }}" data-instrument-pk="{{ instrument.pk }}" - data-class-name="Hornbostel-Sachs Class"> + data-class-name="Hornbostel-Sachs Class" + data-proposals='[{% for proposal in hbs_proposals %}"{{ proposal }}"{% if not forloop.last %}, {% endif %}{% endfor %}]'> Edit HBS {% else %} @@ -65,7 +66,8 @@

{{ active_instrument_label.name }}

data-instrument-name="{{ active_instrument_label.name }}" data-instrument-wikidata-id="{{ instrument.wikidata_id }}" data-instrument-pk="{{ instrument.pk }}" - data-class-name="Hornbostel-Sachs Class"> + data-class-name="Hornbostel-Sachs Class" + data-proposals='[{% for proposal in hbs_proposals %}"{{ proposal }}"{% if not forloop.last %}, {% endif %}{% endfor %}]'> Propose an HBS {% endif %} diff --git a/web-app/django/VIM/templates/instruments/includes/addClass.html b/web-app/django/VIM/templates/instruments/includes/addClass.html index 36015380..0aab375c 100644 --- a/web-app/django/VIM/templates/instruments/includes/addClass.html +++ b/web-app/django/VIM/templates/instruments/includes/addClass.html @@ -37,6 +37,23 @@