Skip to content

Commit a8d7c0c

Browse files
committed
Replace Sound in_use and is_used_by_other_user with fields to avoid n+1 queries on templates
1 parent f84043b commit a8d7c0c

6 files changed

Lines changed: 189 additions & 27 deletions

File tree

src/core/forms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ def clean_file(self):
399399
# Sound already exists, we need to check if it's being used by another user
400400
if self.instance.pk:
401401
if file_has_changed(file, self.instance.file):
402-
if self.instance.is_used_by_other_user():
402+
if self.instance.is_used_by_other_user:
403403
raise forms.ValidationError(
404404
_(
405405
"This sound is being used by another user. You cannot change the source file."

src/core/jinja2/core/templates/sound_thumbnail.jinja2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
controls>
66
</audio>
77
{% if editable %}
8-
{% set can_edit = not sound.is_used_by_other_user() %}
8+
{% set can_edit = not sound.is_used_by_other_user %}
99
{% set can_delete = not sound.in_use %}
1010
{% set edit_reason = _("This sound is used by other users and cannot be edited.") %}
1111
{% set delete_reason = _("This sound is in use and cannot be deleted.") %}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 6.0.6 on 2026-07-11 19:05
2+
3+
import pgtrigger.compiler
4+
import pgtrigger.migrations
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('core', '0042_populate_is_used_by_other_user_field'),
12+
]
13+
14+
operations = [
15+
pgtrigger.migrations.RemoveTrigger(
16+
model_name='sound',
17+
name='insert_insert',
18+
),
19+
pgtrigger.migrations.RemoveTrigger(
20+
model_name='sound',
21+
name='update_update',
22+
),
23+
migrations.AddField(
24+
model_name='sound',
25+
name='in_use',
26+
field=models.BooleanField(default=False),
27+
),
28+
migrations.AddField(
29+
model_name='sound',
30+
name='is_used_by_other_user',
31+
field=models.BooleanField(default=False),
32+
),
33+
migrations.AddField(
34+
model_name='soundevent',
35+
name='in_use',
36+
field=models.BooleanField(default=False),
37+
),
38+
migrations.AddField(
39+
model_name='soundevent',
40+
name='is_used_by_other_user',
41+
field=models.BooleanField(default=False),
42+
),
43+
pgtrigger.migrations.AddTrigger(
44+
model_name='sound',
45+
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "core_soundevent" ("author", "created", "file", "file_extension", "file_name_original", "file_size", "id", "in_use", "is_used_by_other_user", "modified", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title") VALUES (NEW."author", NEW."created", NEW."file", NEW."file_extension", NEW."file_name_original", NEW."file_size", NEW."id", NEW."in_use", NEW."is_used_by_other_user", NEW."modified", NEW."owner_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title"); RETURN NULL;', hash='cc51980a6dcdc729109bf15f4be631e1aa1b398e', operation='INSERT', pgid='pgtrigger_insert_insert_fe2cc', table='core_sound', when='AFTER')),
46+
),
47+
pgtrigger.migrations.AddTrigger(
48+
model_name='sound',
49+
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "core_soundevent" ("author", "created", "file", "file_extension", "file_name_original", "file_size", "id", "in_use", "is_used_by_other_user", "modified", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title") VALUES (NEW."author", NEW."created", NEW."file", NEW."file_extension", NEW."file_name_original", NEW."file_size", NEW."id", NEW."in_use", NEW."is_used_by_other_user", NEW."modified", NEW."owner_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title"); RETURN NULL;', hash='4d24c37be50a81ac25831f917e6380db10013293', operation='UPDATE', pgid='pgtrigger_update_update_8c5ab', table='core_sound', when='AFTER')),
50+
),
51+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 6.0.6 on 2026-07-11 19:06
2+
3+
from django.db import migrations
4+
5+
6+
def populate_sound_flags(apps, schema_editor):
7+
Sound = apps.get_model("core", "Sound")
8+
Artwork = apps.get_model("core", "Artwork")
9+
Object = apps.get_model("core", "Object")
10+
Exhibit = apps.get_model("core", "Exhibit")
11+
12+
for sound in Sound.objects.all():
13+
is_in_use = (
14+
Artwork.objects.filter(sound=sound).exists()
15+
or Object.objects.filter(sound=sound).exists()
16+
or Exhibit.objects.filter(sounds=sound).exists()
17+
)
18+
used_by_other = (
19+
Artwork.objects.filter(sound=sound).exclude(author=sound.owner).exists()
20+
or Object.objects.filter(sound=sound).exclude(owner=sound.owner).exists()
21+
or Exhibit.objects.filter(sounds=sound).exclude(owner=sound.owner).exists()
22+
)
23+
if is_in_use or used_by_other:
24+
Sound.objects.filter(pk=sound.pk).update(
25+
in_use=is_in_use, is_used_by_other_user=used_by_other
26+
)
27+
28+
29+
class Migration(migrations.Migration):
30+
31+
dependencies = [
32+
('core', '0043_add_in_use_fields_to_sound'),
33+
]
34+
35+
operations = [
36+
migrations.RunPython(populate_sound_flags, migrations.RunPython.noop),
37+
]

src/core/models.py

Lines changed: 96 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pghistory
44
from django.core.files.base import ContentFile as CF
55
from django.db import models
6-
from django.db.models.signals import post_delete, post_save, pre_save
6+
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save
77
from django.dispatch import receiver
88
from django.urls import reverse
99
from django.utils.translation import gettext_lazy as _
@@ -75,6 +75,8 @@ class Sound(TimeStampedModel, ContentMixin):
7575
file_extension = models.CharField(
7676
max_length=10, db_index=True, choices=SoundExtensions.choices
7777
)
78+
in_use = models.BooleanField(default=False)
79+
is_used_by_other_user = models.BooleanField(default=False)
7880

7981
@property
8082
def date(self):
@@ -92,27 +94,7 @@ def augmenteds_count(self):
9294
def exhibits_count(self):
9395
return self.exhibits.count()
9496

95-
def is_used_by_other_user(self):
96-
"""
97-
Check if the object is used by another user.
98-
This is done by checking if there are artworks that reference this object
99-
and if the owner of those artworks is not the current user.
100-
"""
101-
return (
102-
self.ar_objects.exclude(owner=self.owner).exists()
103-
or self.artworks.exclude(author=self.owner).exists()
104-
or self.exhibits.exclude(owner=self.owner).exists()
105-
)
10697

107-
@property
108-
def in_use(self):
109-
if self.exhibits_count > 0:
110-
return True
111-
if self.augmenteds_count > 0:
112-
return True
113-
if self.artworks_count > 0:
114-
return True
115-
return False
11698

11799
def used_in_html_string(self):
118100
used_in = "{} {} {} {} {} {} {}".format(
@@ -581,7 +563,7 @@ def remove_source_file(sender, instance, **kwargs):
581563

582564
@receiver(pre_save, sender=Artwork)
583565
def artwork_pre_save(sender, instance, **kwargs):
584-
"""Capture the previous marker/object before an artwork is updated."""
566+
"""Capture the previous marker/object/sound before an artwork is updated."""
585567
if not instance.pk:
586568
return
587569
try:
@@ -590,14 +572,17 @@ def artwork_pre_save(sender, instance, **kwargs):
590572
return
591573
instance._old_marker_id = old.marker_id
592574
instance._old_augmented_id = old.augmented_id
575+
instance._old_sound_id = old.sound_id
593576

594577

595578
@receiver(post_save, sender=Artwork)
596579
def artwork_post_save(sender, instance, **kwargs):
597-
"""Mark the current marker/object as in_use; check if old ones are still used."""
580+
"""Mark the current marker/object/sound as in_use; check if old ones are still used."""
598581
# Mark current references as in use
599582
Marker.objects.filter(pk=instance.marker_id, in_use=False).update(in_use=True)
600583
Object.objects.filter(pk=instance.augmented_id, in_use=False).update(in_use=True)
584+
if instance.sound_id:
585+
Sound.objects.filter(pk=instance.sound_id, in_use=False).update(in_use=True)
601586

602587
# Update is_used_by_other_user for current marker/object
603588
marker = Marker.objects.get(pk=instance.marker_id)
@@ -610,6 +595,13 @@ def artwork_post_save(sender, instance, **kwargs):
610595
if not augmented.is_used_by_other_user:
611596
Object.objects.filter(pk=augmented.pk).update(is_used_by_other_user=True)
612597

598+
# Update is_used_by_other_user for current sound
599+
if instance.sound_id:
600+
sound = Sound.objects.get(pk=instance.sound_id)
601+
if instance.author_id != sound.owner_id:
602+
if not sound.is_used_by_other_user:
603+
Sound.objects.filter(pk=sound.pk).update(is_used_by_other_user=True)
604+
613605
# If marker changed, check if the old one is still in use
614606
old_marker_id = getattr(instance, "_old_marker_id", None)
615607
if old_marker_id and old_marker_id != instance.marker_id:
@@ -632,10 +624,15 @@ def artwork_post_save(sender, instance, **kwargs):
632624
if not still_used_by_other:
633625
Object.objects.filter(pk=old_augmented_id).update(is_used_by_other_user=False)
634626

627+
# If sound changed, check if the old one is still in use
628+
old_sound_id = getattr(instance, "_old_sound_id", None)
629+
if old_sound_id and old_sound_id != instance.sound_id:
630+
_recalculate_sound_flags(old_sound_id)
631+
635632

636633
@receiver(post_delete, sender=Artwork)
637634
def artwork_post_delete(sender, instance, **kwargs):
638-
"""When an artwork is deleted, check if its marker/object are still in use."""
635+
"""When an artwork is deleted, check if its marker/object/sound are still in use."""
639636
if not Artwork.objects.filter(marker_id=instance.marker_id).exists():
640637
Marker.objects.filter(pk=instance.marker_id).update(in_use=False, is_used_by_other_user=False)
641638
else:
@@ -649,3 +646,78 @@ def artwork_post_delete(sender, instance, **kwargs):
649646
obj = Object.objects.get(pk=instance.augmented_id)
650647
if not obj.artworks.exclude(author=obj.owner).exists():
651648
Object.objects.filter(pk=obj.pk).update(is_used_by_other_user=False)
649+
650+
if instance.sound_id:
651+
_recalculate_sound_flags(instance.sound_id)
652+
653+
654+
def _recalculate_sound_flags(sound_id):
655+
"""Recalculate in_use and is_used_by_other_user for a Sound."""
656+
try:
657+
sound = Sound.objects.get(pk=sound_id)
658+
except Sound.DoesNotExist:
659+
return
660+
661+
is_in_use = (
662+
sound.artworks.exists()
663+
or sound.ar_objects.exists()
664+
or sound.exhibits.exists()
665+
)
666+
used_by_other = (
667+
sound.artworks.exclude(author=sound.owner).exists()
668+
or sound.ar_objects.exclude(owner=sound.owner).exists()
669+
or sound.exhibits.exclude(owner=sound.owner).exists()
670+
)
671+
Sound.objects.filter(pk=sound_id).update(
672+
in_use=is_in_use, is_used_by_other_user=used_by_other
673+
)
674+
675+
676+
@receiver(pre_save, sender=Object)
677+
def object_pre_save(sender, instance, **kwargs):
678+
"""Capture the previous sound before an object is updated."""
679+
if not instance.pk:
680+
return
681+
try:
682+
old = Object.objects.get(pk=instance.pk)
683+
except Object.DoesNotExist:
684+
return
685+
instance._old_sound_id = old.sound_id
686+
687+
688+
@receiver(post_save, sender=Object)
689+
def object_post_save(sender, instance, **kwargs):
690+
"""Update sound flags when an object's sound FK changes."""
691+
if instance.sound_id:
692+
sound = Sound.objects.get(pk=instance.sound_id)
693+
updates = {}
694+
if not sound.in_use:
695+
updates["in_use"] = True
696+
if instance.owner_id != sound.owner_id and not sound.is_used_by_other_user:
697+
updates["is_used_by_other_user"] = True
698+
if updates:
699+
Sound.objects.filter(pk=sound.pk).update(**updates)
700+
701+
old_sound_id = getattr(instance, "_old_sound_id", None)
702+
if old_sound_id and old_sound_id != instance.sound_id:
703+
_recalculate_sound_flags(old_sound_id)
704+
705+
706+
@receiver(post_delete, sender=Object)
707+
def object_post_delete_sound(sender, instance, **kwargs):
708+
"""When an object is deleted, recalculate its sound's flags."""
709+
if instance.sound_id:
710+
_recalculate_sound_flags(instance.sound_id)
711+
712+
713+
@receiver(m2m_changed, sender=Exhibit.sounds.through)
714+
def exhibit_sounds_changed(sender, instance, action, pk_set, **kwargs):
715+
"""Update sound flags when exhibits add/remove sounds."""
716+
if action in ("post_add", "post_remove", "post_clear"):
717+
if pk_set:
718+
for sound_id in pk_set:
719+
_recalculate_sound_flags(sound_id)
720+
elif action == "post_clear":
721+
# post_clear doesn't provide pk_set; recalculate all sounds
722+
for sound in Sound.objects.filter(in_use=True):
723+
_recalculate_sound_flags(sound.pk)

src/users/jinja2/users/login.jinja2

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@
3030
{% endfor %}
3131
{% if form.non_field_errors() %}
3232
<ul class="errorlist">
33-
{% for error in form.non_field_errors() %}<li>{{ error }}</li>{% endfor %}
33+
{% for error in form.non_field_errors() %}
34+
<li>{{ error }}</li>
35+
{% endfor %}
3436
</ul>
3537
{% endif %}
3638
<div class="form-options">

0 commit comments

Comments
 (0)