Skip to content

Collections #1810

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 48 commits into
base: master
Choose a base branch
from
Draft

Collections #1810

wants to merge 48 commits into from

Conversation

quimmrc
Copy link
Contributor

@quimmrc quimmrc commented Jan 16, 2025

Issue(s)
#1029

Description
App creation and first implementations of basic functionalitites for collections.

class Collection(models.Model):

author = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=128, default="BookmarkCollection") #add restrictions
Copy link
Member

Choose a reason for hiding this comment

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

No need to specify a default

def delete_collection(request, collection_id):
collection = get_object_or_404(Collection, id=collection_id)

if request.user==collection.user:
Copy link
Member

Choose a reason for hiding this comment

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

Consider else case, return 404 or reload edit page but with a message saying that user is not allowed

@@ -73,11 +88,14 @@ const prepareAddSoundsModalAndFields = (container) => {
const newSoundIds = serializedIdListToIntList(selectedSoundIds);
const combinedIds = combineIdsLists(currentSoundIds, newSoundIds);
selectedSoundsHiddenInput.value = combinedIds.join(',');
if(selectedSoundsHiddenInput.value.split(',').length >= 4 && selectedSoundsHiddenInput.id === "collection_sounds"){
Copy link
Contributor Author

@quimmrc quimmrc Apr 1, 2025

Choose a reason for hiding this comment

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

there's an error here, this 4 should take the value of settings.MAX_SOUNDS_PER_COLLECTION (something that happens again a few lines of code above)

Copy link
Member

Choose a reason for hiding this comment

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

You should take the value from some data property as I commented above. Is there a problem with that?

const modalUrlSplitted = element.dataset.modalContentUrl.split('/')
const soundId = parseInt(modalUrlSplitted[modalUrlSplitted.length - 3], 10)
if (!evt.altKey) {
handleGenericModalWithForm(element.dataset.modalContentUrl, () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

in here, bookmarks used a custom saveCollection function, however I found more intuitive and standard to use a handleGenericModalWithForm to properly handle the submission of the SelectCollectionOrNewCollection form, which is the one used to add sounds to Collections from Sound URLS.

updateObjectSelectorDataProperties, the "call stack" states that originally, the function is called from here (line 175).
Therefore, the definition of selectedMaintainersDestinationElement somehow is wrong or confused with the selectedSoundsDestinationElement.
I tried to filter this using the above queryselector but it does not work. I guess this might have something to do with the file
collectionEdit.js.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This piece of comment might be interesting but the issue prompted by this is already solved. Basically the query for users does not exclude any, and instead "validation" for users who are added as maintaienrs is done from server-side.

fields = CollectionEditForm.Meta.fields + ['collection_sounds'] + ['maintainers']

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this might need a good review, it was a topic of discussion in our last meeting and I think that my proposal differs a bit from your main idea. In here this forms inherits everything so that it can be displayed in the edit url but all fields get disabled as soon as the form is initalized.

status = models.CharField(db_index=True, max_length=2, choices=STATUS_CHOICES, default="PE")
#sound won't be added to collection until maintainers approve the sound

@receiver(post_save, sender=CollectionSound)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

num_sounds are now updated using CollectionSounds triggers, not only in the save collection method


register = template.Library()

@register.simple_tag
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I should've deleted this but I forgot, my mistake

# the collections_for_user can be reused to display ONE collection so give it a thought on full collections display
return render(request, 'collections/your_collections.html', tvars)

def collection_stats_section(request, collection_id):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

In here I tried to imitate the pack stats behaviour but I was not much familiar with it and how the cache is handled so this is not properly updated (needs further review by me)

Copy link
Member

Choose a reason for hiding this comment

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

I'll have a look when testing the PR. This async loading of stats has to do with caching computation of stats, but maybe if stats computation is fast enough, then this might not be needed. We added some recent changes to compute some of the stats in DB rather than in python with good results, so we might apply this here and not do the stats section async.

if form.is_valid():
form.save(user_adding_sound=request.user)
return HttpResponseRedirect(reverse('collection', args=[collection.id]))
else:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this part might need a good review since I did something unusual here, but I still believe it's solid enough

Copy link
Member

Choose a reason for hiding this comment

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

I'll have a detailed look into this when I can test the PR. At first sight I don't think we need to anything unusual here.

{% load static %}
{% load bw_templatetags %}

{% block id %}collectSoundModal{% endblock %}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should be named "AddSoundToCollectionModal" or similar, I forgot to change this, my bad

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this allows to differentiate the sound selector in the edit collections page from the users selector (for maintainers)

Copy link
Member

@ffont ffont left a comment

Choose a reason for hiding this comment

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

Thanks @quimmrc for the PR! I had a quick look, but couldn't test it yet, I'll do it soon. This is long and complex code so some things still need to be looked at in detail, but it looks good generally! I'll did leave some comments of some small things to improve. You might want to look at those in the next days before I do a test and further review (if you have time).

def users_selector(context, users, selected_user_ids=[], show_select_all_buttons=False):
if users:
if not isinstance(users[0], User):
# users are passed as a list of user ids, retrieve the Sound objects from DB
Copy link
Member

Choose a reason for hiding this comment

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

...retrieve the User objects...

'system_prefers_dark_theme': request.COOKIES.get('systemPrefersDarkTheme', 'no') == 'yes' # Determine the user's system preference for dark/light theme (for non authenticated users, always use light theme)
'system_prefers_dark_theme': request.COOKIES.get('systemPrefersDarkTheme', 'no') == 'yes', # Determine the user's system preference for dark/light theme (for non authenticated users, always use light theme)
'enable_collections': settings.ENABLE_COLLECTIONS,
'max_sounds_per_collection': settings.MAX_SOUNDS_PER_COLLECTION,
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we should add enable_collections and max_sounds_per_collection in the global context processor as this will only be used in collections-related pages. This should probably only be added in tvars where needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree for max_sounds_per_collection, however, for the enable_collections case it is used in a couple of html templates which are the navigation bar and the sound url ones, which have a more global behavior rather than collections-related-pages, do you think we could leave enable_collections as a context processor?


</p>
</div>
<div id="config" data-max-sounds="{{max_sounds_per_collection}}"></div>
Copy link
Member

Choose a reason for hiding this comment

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

This is quite ugly here :)
data-max-sounds could be a property of the object selector, and place it somewhere there. I left other comments that should clarify this.

}

if(soundsInput.id === "collection_sounds"){
const maxSounds = document.getElementById('config').dataset.maxSounds;
Copy link
Member

Choose a reason for hiding this comment

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

Don't hardcode this thing here if(soundsInput.id === "collection_sounds"). I think you could set data-max-elements maybe as a property of selectedSoundsDestinationElement(because it already has the data-type property), and here you check if there is any maximum set, and if there is, act accordingly and disable the add button if selected elements is above the maximum.

@@ -62,6 +78,9 @@ const prepareAddSoundsModalAndFields = (container) => {
updateObjectSelectorDataProperties(selectedSoundsDestinationElement);
const selectedSoundsHiddenInput = document.getElementById(addSoundsButton.dataset.selectedSoundsHiddenInputId);
selectedSoundsHiddenInput.value = selectedSoundsDestinationElement.dataset.unselectedIds;
if(selectedSoundsHiddenInput.value.split(',').length < maxSounds && selectedSoundsHiddenInput.id === "collection_sounds"){
addSoundsButton.disabled = false;
Copy link
Member

Choose a reason for hiding this comment

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

This should also be updated per the comments above.

# the collections_for_user can be reused to display ONE collection so give it a thought on full collections display
return render(request, 'collections/your_collections.html', tvars)

def collection_stats_section(request, collection_id):
Copy link
Member

Choose a reason for hiding this comment

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

I'll have a look when testing the PR. This async loading of stats has to do with caching computation of stats, but maybe if stats computation is fast enough, then this might not be needed. We added some recent changes to compute some of the stats in DB rather than in python with good results, so we might apply this here and not do the stats section async.

collection = get_object_or_404(Collection, id=collection_id)

if request.method=="POST" and request.user==collection.user:
collection.delete()
Copy link
Member

Choose a reason for hiding this comment

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

Add a message like "collection successfully deleted"? See how django messages app works, we do it in many other places

collection.delete()
return HttpResponseRedirect(reverse('your-collections'))
else:
return HttpResponseRedirect(reverse('collection', args=[collection.id]))
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we should return "not allowed" response. Did you check how similar things are handled elsewhere in Freesound?

if form.is_valid():
form.save(user_adding_sound=request.user)
return HttpResponseRedirect(reverse('collection', args=[collection.id]))
else:
Copy link
Member

Choose a reason for hiding this comment

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

I'll have a detailed look into this when I can test the PR. At first sight I don't think we need to anything unusual here.

qs = qs.filter(
moderation_state="OK",
processing_state="OK",
collections=collection_id
Copy link
Member

Choose a reason for hiding this comment

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

Does this work?? I guess it is some feature of the through model, but I'm a bit surprised here... I'd like to look into this with @alastair

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think it does, so far it is only used to render the sounds selector for the edit collection URL

Copy link
Member

Choose a reason for hiding this comment

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

yes, this should be fine. it's not specifically related to the through, just the ManyToManyField

}
if (soundsLabel){
soundsLabel.innerHTML = "Sounds in collection (" + selectedSoundsDestinationElement.children.length + ")"}
Copy link
Contributor Author

@quimmrc quimmrc Apr 24, 2025

Choose a reason for hiding this comment

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

A part from removing the hard-coded part and retrieving the number of elements from the sound selector, I automated the display of the help text for this field (informing of maximum number of sounds permitted) and the label of this field (which shows the number of sounds in collection). Although this is also used for pack edits, it has no conflicts (queries for help text and label won't find anything).


if self.instance.num_sounds >= settings.MAX_SOUNDS_PER_COLLECTION:
self.fields['collection_sounds'].help_text=f"You have reached the maximum number of sounds available for a collection ({settings.MAX_SOUNDS_PER_COLLECTION}). " \
"In order to add new sounds, first remove some of the current ones."
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this could not be updated if done from server-side, which was solved with the comment above

@register.inclusion_tag('accounts/display_user_selectable.html', takes_context=True)
def display_user_small_selectable(context, user, selected=False):
context = context.get('original_context', context) # This is to allow passing context in nested inclusion tags
tvars = display_user(context, user, size='basic')
Copy link
Member

Choose a reason for hiding this comment

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

double-check this - to me it seems a bit weird to call a function which is registered as a templatetag that returns HTML. Does this actually work?
It might be better to take the code for display_user and move it into a separate util function that can be used by both of these template tags


@register.inclusion_tag('accounts/display_user_selectable.html', takes_context=True)
def display_user_small_selectable(context, user, selected=False):
context = context.get('original_context', context) # This is to allow passing context in nested inclusion tags
Copy link
Member

Choose a reason for hiding this comment

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

unnecessary comment

@@ -0,0 +1,27 @@
# Authors:
Copy link
Member

Choose a reason for hiding this comment

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

a bit weird to have just the authors block but no copyright?



@register.inclusion_tag('molecules/object_selector.html', takes_context=True)
def users_selector(context, users, selected_user_ids=[], show_select_all_buttons=False):
Copy link
Member

Choose a reason for hiding this comment

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

using =[] in a function isn't a good idea, set this to None and only iterate through it if it's not NOne

'objects': users,
'type': 'users',
'show_select_all_buttons': show_select_all_buttons,
'original_context': context # This will be used so a nested inclusion tag can get the original context
Copy link
Member

Choose a reason for hiding this comment

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

unneeded comment

</div>
{% else %}
<div class="v-spacing-top-3">
<span class=text-light-grey> You don't have any collection yet...</span>
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
<span class=text-light-grey> You don't have any collection yet...</span>
<span class=text-light-grey> You don't have any collections yet...</span>

</div>
{% else %}
<div class="v-spacing-top-3">
<span class=text-light-grey> You're not a maintainer in any collection yet...</span>
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
<span class=text-light-grey> You're not a maintainer in any collection yet...</span>
<span class=text-light-grey> You're not a maintainer of any collection yet...</span>

#sound won't be added to collection until maintainers approve the sound

@receiver(post_save, sender=CollectionSound)
def update_collection_num_sounds(**kwargs):
Copy link
Member

Choose a reason for hiding this comment

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

It'd be better to have the explicit list of parameters to these signals: https://docs.djangoproject.com/en/5.2/ref/signals/#django.db.models.signals.pre_save

Suggested change
def update_collection_num_sounds(**kwargs):
def update_collection_num_sounds(sender, instance, raw, using, update_fields):

Then you don't need to set isntance to False in the next line (this will already be None if you're in this situation)

Collection.objects.filter(collectionsound=collectionsound).update(num_sounds=Greatest(F('num_sounds') + 1, 0))

@receiver(post_delete, sender=CollectionSound)
def update_collection_num_sounds_sound_removal(**kwargs):
Copy link
Member

Choose a reason for hiding this comment

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

Note that django doesn't send post_save/post_delete signals when you directly update a ManyToMany field: see https://docs.djangoproject.com/en/4.2/ref/signals/#m2m-changed
I see that you create these objects manually anyway (this is necessary if you have a through model with extra data, so this is fine), but we may be in a situation where we bulk remove sounds from a collection and this signal isn't triggered. Review if this might happen in any place

Comment on lines +253 to +255
for snd in current_sounds:
sound = Sound.objects.get(id=snd)
CollectionSound.objects.get(collection=collection, sound=sound).delete()
Copy link
Member

Choose a reason for hiding this comment

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

this makes 3 queries per item to delete. better to do either:

sounds = Sound.objects.filter(id__in=current_sounds)
collection.sounds.remove(*sounds)

(but careful with this option, because it won't trigger a delete signal, because it modifies the m2m field)

or maybe

sounds = Sound.objects.filter(id__in=current_sounds)
CollectionSound.objects.filter(collection=collection, sound__in=sounds).delete()

because the filter/delete will do only a delete query and no SELECT.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants