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 @@