Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web-app/django/VIM/apps/instruments/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

@admin.register(InstrumentName)
class InstrumentNameAdmin(admin.ModelAdmin):
list_filter = ("verification_status", "on_wikidata") # Filter by status
list_filter = ("verification_status", "on_wikidata", "deleted") # Filter by status
search_fields = (
"name",
"source_name",
Expand All @@ -25,8 +25,8 @@ def get_readonly_fields(self, request, obj=None):
"language",
"name",
"source_name",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on this function is now out of date.

"umil_label",
"contributor",
"on_wikidata",
"deleted",
)
return super().get_readonly_fields(request, obj)
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated by Django 4.2.5 on 2025-08-20 15:50

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("instruments", "0010_alter_instrumentname_contributor"),
]

operations = [
migrations.RemoveConstraint(
model_name="instrumentname",
name="unique_umil_label_per_instrument_language",
),
migrations.AddField(
model_name="instrumentname",
name="deleted",
field=models.BooleanField(
default=False,
help_text="Soft delete flag. If true, this name is considered deleted but retained in the database.",
),
),
migrations.AddConstraint(
model_name="instrumentname",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("umil_label", True), _negated=True),
models.Q(("verification_status", "verified"), ("deleted", False)),
_connector="OR",
),
name="umil_label=true only if name is Verified and not Deleted",
),
),
]
76 changes: 70 additions & 6 deletions web-app/django/VIM/apps/instruments/models/instrument_name.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.db import models
from django.db.models import Q


class InstrumentName(models.Model):
Expand Down Expand Up @@ -34,17 +35,80 @@ class InstrumentName(models.Model):
default=False,
help_text="Is this name already on Wikidata?",
)
deleted = models.BooleanField(
default=False,
help_text="Soft delete flag. If true, this name is considered deleted but retained in the database.",
)

# Custom validation to ensure at most one UMIL label per instrument language
# Constrain umil_label to be true only if the name is verified and not deleted
class Meta:
constraints = [
models.UniqueConstraint(
fields=["instrument", "language"],
condition=models.Q(umil_label=True),
name="unique_umil_label_per_instrument_language",
)
models.CheckConstraint(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So are you getting rid of the UniqueConstraint?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that with my reassignment logic, where if an admin sets umil_label=true on a name and then the existing umil_label name is set as false, there is a brief point in time where there are two names with umil_label=true which is prevented by this Constraint.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean by "reassignment logic"? I think this would just require a different order (set umil_label = False on existing label name and then umil_label = True on the new one)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wall I've run into here regarding the Unique Constraint is that partial unique constraints (unique constraints with a condition) cannot be deferred.
The issue is that if we enforce the Unique constraint of AT MOST one umil_label per instrument language, this is always broken when an admin sets another name's umil_label=true regardless if in the custom save function swaps the existing umil_label before calling the super().save

I thought I could get around this by deferring the Unique Constraint (https://docs.djangoproject.com/en/5.2/ref/models/constraints/#deferrable), so that the constraint is not enforced until the end of the transaction. Otherwise the constraint is immediate and enforced after every command.
With a bit of debugging it seems like when I click save on the django admin interface for an instrument name object, we do not seem to ever enter the custom save function so I think that although the state is not written to the database, there is still some sort of updating of the object that triggers the constraint.
I'm not finding much documentation specifically on partial unique constraints and deferral but after I compose up, the app fails to build with a Value Error indicating that partial unique constraints cannot be deferred.

The solution to this is implementing a check in the custom save function where a ValueError is raised if we detect more than one umil_label for names in the instrument language, evaluated after a potential swap/reassignment happens. But this will require the removal of the Unique Constraint.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: looking into the difference between the save_model() in admin vs. save() in the view...
Assuming they must be different processes because the delete_name function is able to bypass the Check Constraint (when a contributer deletes a name on the frontend)

name="umil_label=true only if name is Verified and not Deleted",
check=~Q(umil_label=True)
| (Q(verification_status="verified") & Q(deleted=False)),
),
]

# TODO: add verified_by field to track who verified the name
def __str__(self):
return f"{self.name} ({self.language.en_label}) - {self.instrument.wikidata_id}"

def save(self, *args, **kwargs):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we give the user/superuser a choice in this case? Like alert them that they are deleting an instrument label and give them a choice of selecting another one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since a user can only delete their own name entries, umil_label reassignment would only be done on the admin interface page.
We can display a warning message on the django admin page that the admin is deleting the label and should choose another, however it opens the possibility that no verified instrument names are assigned as the label, which doesn't make sense on the frontend (visible verified names, but "no label available").
I think that Unique Constraint only ensures that "at most" one object with umil_label=true, which does not solve this problem (but then also it would be impossible to choose a different label)

In the current implementation when umil_label is unassigned, the custom save function will reassign umil_label to the earliest available verified, non-deleted name. If an admin assigns a name as the umil_label, it will unset the existing label. This ensures that there is always ONE umil_label at all times if possible.

What could be more informative to an admin is to:

  • reinstate the unique constraint
  • maintain the current check constraint to prevent impossible states
  • if an admin unassigns a umil_label, provide a warning message but allow
  • if an admin wants to reassign the umil_label, they must unassign the current umil_label (unique constraint)
    The only downside to this is the case mentioned above (no automatic reassignment)

If a user deletes a label from the front end, the page will have to exist with "No verified label in [language]", until a new label is assigned manually (even if there are other verified names in that language displayed/available).

I think that the best final option to maintain clarity to the admin is to keep my current automatic reassignment logic, but provide a warning messages for unassignment and reassignment of the umil_label, where an admin it notified what name gained or lost umil_label=true respectively.

Copy link
Copy Markdown
Contributor

@dchiller dchiller Aug 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I think I was confused about the purpose of this a bit.

So, it seems like the only case where we need any custom "save" logic is when the object in question is trying to save with a change in umil_label from its previous value or if the object is being saved with umil_label = True and deleted = True.

Is that right?

If so, we can exit early from this function in all other cases.

I'm less concerned about giving a choice because I reviewed the code for adding a name and it looks like we randomly assign a umil_label to initial names, so I think this is fine.

Since this process involves multiple database operations/modifications, you will need to think about database consistency and atomicity (what happens if one of the database operations fails? is your database ever in an invalid state?) You will need to use database transactions: https://docs.djangoproject.com/en/5.2/topics/db/transactions/.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I see what you mean ! I can check if the in memory status of the name regarding umil_label does not match the database status, which will trigger the custom save logic.
In addition to the case of umil_label = True and deleted = True, I think a final case to consider would be when the first non-deleted instrument name is verified (by a reviewer). In this case, the reviewer must be forced to save the instrument name as the umil_label.

I'm a bit confused by what you mean regarding adding a name, because another change to this process is that umil_label will always be False with a new name because it is unverified.
This opens another possible case of an admin adding an instrument name on the backend, and setting it as the umil_label, which would not be considered as the label status changing. However, I think that this can be flagged by the Unique Constraint, which I will try to reinstate with what you pointed out in here.

So to summarize, I will modify my save function so that it only considers:

  • umil_label has changed

  • umil_label=True and deleted=True (contributor has deleted the label from the frontend)

  • umil_label=False, verification_status=True and deleted=False, and there is no other verified name entry

  • and reinstate the Unique Constraint so that it prevents an admin from adding an instrument name as umil_label when there is an existing umil_label for that instrument language.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused by what you mean regarding adding a name, because another change to this process is that umil_label will always be False with a new name because it is unverified.

Ah, good point.

Makes sense.

existing_umil_label_qs = InstrumentName.objects.filter(
instrument=self.instrument,
language=self.language,
umil_label=True,
).exclude(id=self.id)

existing_umil_label = existing_umil_label_qs.first()
print("Existing UMIL label:", existing_umil_label)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this print statement in a deployed setting? Was this just for debugging in development?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry ! Forgot to delete this, was part of debugging !


replacement_umil_label = (
InstrumentName.objects.filter(
instrument=self.instrument,
language=self.language,
verification_status="verified",
deleted=False,
)
.exclude(id=self.id)
.order_by("id")
.first()
)
print("Replacement UMIL label:", replacement_umil_label)

# If setting umil_label=True
if self.umil_label:
if self.deleted:
# Assign replacement if possible, else just unset
self.umil_label = False
if replacement_umil_label:
replacement_umil_label.umil_label = True
replacement_umil_label.save()

return super().save(*args, **kwargs)
else:
# Unset other umil_labels in one query
existing_umil_label_qs.update(umil_label=False)

# If a verified name is removing itself as umil_label
if not self.umil_label:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be an else statement following from line 82 right?

# check if there is an existing label
if existing_umil_label:
pass
# If the existing label is not found, check for a replacement
elif replacement_umil_label:
print("Replacement label found.", flush=True)
replacement_umil_label.umil_label = True
replacement_umil_label.save()
# If there is no replacement umil_label, only set umil_label=False if the current name is also deleted
else:
# Otherwise, a viewer will see a verified name on the detail page without a label
if self.verification_status == "verified" and not self.deleted:
# raise ValueError(
# "This is the only verified, non-deleted name for this instrument and language, it must be set as umil_label."
# )
self.umil_label = True # Comment this out and uncomment the above after initial instrument upload.

super().save(*args, **kwargs)
18 changes: 9 additions & 9 deletions web-app/django/VIM/apps/instruments/views/instrument_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ class InstrumentDetail(DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

# Query the instrument names in all languages
instrument_names = context["instrument"].instrumentname_set.select_related(
"language"
# Query the instrument names in all languages, excluding deleted names
instrument_names = (
context["instrument"]
.instrumentname_set.select_related("language")
.filter(deleted=False)
)

if self.request.user.is_authenticated:
# Show all names for authenticated users
context["instrument_names"] = instrument_names.all()
Expand All @@ -37,15 +40,12 @@ def get_context_data(self, **kwargs):
)

# Get the instrument label in the active language
# Set label to the first instrument name added in the language if there is no "umil_label" set
active_labels = context["instrument_names"].filter(
language=context["active_language"]
)
umil_label = active_labels.filter(umil_label=True).first()
if umil_label:
context["active_instrument_label"] = umil_label
else:
context["active_instrument_label"] = active_labels.first()
context["active_instrument_label"] = active_labels.filter(
umil_label=True
).first()

# Get all languages for the dropdown
context["languages"] = Language.objects.all()
Expand Down
22 changes: 3 additions & 19 deletions web-app/django/VIM/apps/instruments/views/update_umil_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ def add_name(request: HttpRequest) -> JsonResponse:
# create dictionary to map language codes to Language objects
language = {lang.wikidata_code: lang for lang in Language.objects.all()}

# considering entries with multiple of the same language, create a dictionary to track if a label has
# been assigned to a previous entry
entry_labels = {entry["language"]: False for entry in entries}

instrument_names_to_create = []

for entry in entries:
Expand All @@ -75,27 +71,13 @@ def add_name(request: HttpRequest) -> JsonResponse:
# Find language object from language code dictionary
language_obj: Language = language.get(language_code)

# Within the entries, check if the language already has a name
# if it does, set umil_label to False
# otherwise, check against the UMILdb
if entry_labels[language_code]:
umil_label = False
else:
umil_label: bool = not (
instrument.instrumentname_set.filter(
language__wikidata_code=language_code
).exists()
)
entry_labels[language_code] = True # Mark that this language now has a name

# Prepare the InstrumentName object
instrument_names_to_create.append(
InstrumentName(
instrument=instrument,
language=language_obj,
name=name,
source_name=source,
umil_label=umil_label,
contributor=request.user,
)
)
Expand Down Expand Up @@ -133,7 +115,9 @@ def delete_name(request: HttpRequest) -> JsonResponse:

# If user is a superuser or created the name, allow deletion
if request.user.is_superuser or instrument_name.contributor == request.user:
instrument_name.delete()
instrument_name.deleted = True # Soft delete the name
instrument_name.save() # Don't forget to save the changes

return JsonResponse(
{
"status": "success",
Expand Down
8 changes: 7 additions & 1 deletion web-app/django/VIM/templates/instruments/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
<div class="container d-flex align-items-center justify-content-center min-vh-80 py-4">
<div class="container-border bg-white p-4 d-flex flex-column align-items-center flex-nowrap">
<div class="w-100">
<h2 class="notranslate">{{ active_instrument_label.name|title }}</h2>
{% if active_instrument_label is None %}
<h2 class="fst-italic opacity-75">
No verified names in {{ active_language.en_label }}
</h2>
{% else %}
<h2 class="notranslate">{{ active_instrument_label.name|title }}</h2>
{% endif %}
<hr class="w-100 mt-0" />
</div>
<div class="w-100">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ <h5 class="modal-title" id="deleteNameModalLabel">
<div class="modal-body">
<div class="mb-3">
<p>
The following will be deleted from the UMIL database. The entry will persist on Wikidata.
The following will be deleted from UMIL. The entry will persist on Wikidata.
</p>
</div>
<div class="mb-3 p-2 border rounded bg-light">
Expand Down