diff --git a/api/funkwhale_api/common/mutations.py b/api/funkwhale_api/common/mutations.py index 11624e9f629312ce66b35e021a41efddbb683e2f..c3e92c15b1579675a4ce243029ffe8a93614ab63 100644 --- a/api/funkwhale_api/common/mutations.py +++ b/api/funkwhale_api/common/mutations.py @@ -114,7 +114,14 @@ class UpdateMutationSerializer(serializers.ModelSerializer, MutationSerializer): # to ensure we store ids instead of model instances in our json # payload for field, attr in self.serialized_relations.items(): - data[field] = getattr(data[field], attr) + try: + obj = data[field] + except KeyError: + continue + if obj is None: + data[field] = None + else: + data[field] = getattr(obj, attr) return data def create(self, validated_data): diff --git a/api/funkwhale_api/common/pagination.py b/api/funkwhale_api/common/pagination.py index e5068bce209da72523077a0d1dee0b7938eba422..ec7c27dc4f9cd25fc80a02fc47a156fdb42d061b 100644 --- a/api/funkwhale_api/common/pagination.py +++ b/api/funkwhale_api/common/pagination.py @@ -1,6 +1,29 @@ -from rest_framework.pagination import PageNumberPagination +from rest_framework.pagination import PageNumberPagination, _positive_int class FunkwhalePagination(PageNumberPagination): page_size_query_param = "page_size" - max_page_size = 50 + default_max_page_size = 50 + default_page_size = None + view = None + + def paginate_queryset(self, queryset, request, view=None): + self.view = view + return super().paginate_queryset(queryset, request, view) + + def get_page_size(self, request): + max_page_size = ( + getattr(self.view, "max_page_size", 0) or self.default_max_page_size + ) + page_size = getattr(self.view, "default_page_size", 0) or max_page_size + if self.page_size_query_param: + try: + return _positive_int( + request.query_params[self.page_size_query_param], + strict=True, + cutoff=max_page_size, + ) + except (KeyError, ValueError): + pass + + return page_size diff --git a/api/funkwhale_api/music/mutations.py b/api/funkwhale_api/music/mutations.py index 51efa0ab8cd70f7c241c460937a76b75ee8ab658..4d78b8ea9c086649cd17dbc689a9a562e36c4bbb 100644 --- a/api/funkwhale_api/music/mutations.py +++ b/api/funkwhale_api/music/mutations.py @@ -21,4 +21,4 @@ class TrackMutationSerializer(mutations.UpdateMutationSerializer): class Meta: model = models.Track - fields = ["license", "title", "position"] + fields = ["license", "title", "position", "copyright"] diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index b5242eeb1c1e22a35c65c6390a0987a853e21e9d..2f0e67cb9eda8bd8d0ad286a1953cd1fb444e43a 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -524,6 +524,7 @@ class LicenseViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.LicenseSerializer queryset = models.License.objects.all().order_by("code") lookup_value_regex = ".*" + max_page_size = 1000 def get_queryset(self): # ensure our licenses are up to date in DB diff --git a/api/tests/common/test_pagination.py b/api/tests/common/test_pagination.py new file mode 100644 index 0000000000000000000000000000000000000000..cacbe740c7ddae374ae502ca1ae1a2867edab27b --- /dev/null +++ b/api/tests/common/test_pagination.py @@ -0,0 +1,29 @@ +import pytest + +from funkwhale_api.common import pagination + + +@pytest.mark.parametrize( + "view_max_page_size, view_default_page_size, request_page_size, expected", + [ + (50, 50, None, 50), + (50, 25, None, 25), + (25, None, None, 25), + (50, 25, 100, 50), + (50, None, 100, 50), + (50, 25, 33, 33), + ], +) +def test_funkwhale_pagination_uses_view_page_size( + view_max_page_size, view_default_page_size, request_page_size, expected, mocker +): + p = pagination.FunkwhalePagination() + + p.view = mocker.Mock( + max_page_size=view_max_page_size, default_page_size=view_default_page_size + ) + query = {} + if request_page_size: + query["page_size"] = request_page_size + request = mocker.Mock(query_params=query) + assert p.get_page_size(request) == expected diff --git a/api/tests/music/test_mutations.py b/api/tests/music/test_mutations.py index d6b8223d4efe9397710227028703ca6573155983..bc9e81f8e3e79f3da52c6a1caad11e831cf560e0 100644 --- a/api/tests/music/test_mutations.py +++ b/api/tests/music/test_mutations.py @@ -13,6 +13,18 @@ def test_track_license_mutation(factories, now): assert track.license.code == "cc-by-sa-4.0" +def test_track_null_license_mutation(factories, now): + track = factories["music.Track"](license="cc-by-sa-4.0") + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"license": None} + ) + licenses.load(licenses.LICENSES) + mutation.apply() + track.refresh_from_db() + + assert track.license is None + + def test_track_title_mutation(factories, now): track = factories["music.Track"](title="foo") mutation = factories["common.Mutation"]( @@ -24,6 +36,17 @@ def test_track_title_mutation(factories, now): assert track.title == "bar" +def test_track_copyright_mutation(factories, now): + track = factories["music.Track"](copyright="foo") + mutation = factories["common.Mutation"]( + type="update", target=track, payload={"copyright": "bar"} + ) + mutation.apply() + track.refresh_from_db() + + assert track.copyright == "bar" + + def test_track_position_mutation(factories): track = factories["music.Track"](position=4) mutation = factories["common.Mutation"]( diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 7b12c6c8ffe37c5efa003007a5f3b2a7b45339a3..2c3a61c054b732f2234b51491a31baacb4980fab 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -612,7 +612,7 @@ def test_list_licenses(api_client, preferences, mocker): expected = [ serializers.LicenseSerializer(l.conf).data - for l in models.License.objects.order_by("code")[:25] + for l in models.License.objects.order_by("code") ] url = reverse("api:v1:licenses-list") diff --git a/front/src/components/library/EditForm.vue b/front/src/components/library/EditForm.vue index 0001081192048274ac2c8d3d55f5f809aa29093c..a2df96c0018dcf0d2f9bf5dc461be7cf872632c1 100644 --- a/front/src/components/library/EditForm.vue +++ b/front/src/components/library/EditForm.vue @@ -59,10 +59,28 @@ <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> <input :type="fieldConfig.inputType || 'text'" v-model="values[fieldConfig.id]" :required="fieldConfig.required" :name="fieldConfig.id" :id="fieldConfig.id"> </template> + <template v-else-if="fieldConfig.type === 'license'"> + <label :for="fieldConfig.id">{{ fieldConfig.label }}</label> + + <select + ref="license" + v-model="values[fieldConfig.id]" + :required="fieldConfig.required" + :id="fieldConfig.id" + class="ui fluid search dropdown"> + <option :value="null"><translate translate-context="*/*/*">N/A</translate></option> + <option v-for="license in licenses" :key="license.code" :value="license.code">{{ license.name}}</option> + </select> + <button class="ui tiny basic left floated button" form="noop" @click.prevent="values[fieldConfig.id] = null"> + <i class="x icon"></i> + <translate translate-context="Content/Library/Button.Label">Clear</translate> + </button> + + </template> <div v-if="values[fieldConfig.id] != initialValues[fieldConfig.id]"> <button class="ui tiny basic right floated reset button" form="noop" @click.prevent="values[fieldConfig.id] = initialValues[fieldConfig.id]"> <i class="undo icon"></i> - <translate translate-context="Content/Library/Button.Label" :translate-params="{value: initialValues[fieldConfig.id]}">Reset to initial value: %{ value }</translate> + <translate translate-context="Content/Library/Button.Label" :translate-params="{value: initialValues[fieldConfig.id] || ''}">Reset to initial value: %{ value }</translate> </button> </div> </div> @@ -87,6 +105,7 @@ </template> <script> +import $ from 'jquery' import _ from '@/lodash' import axios from "axios" import EditList from '@/components/library/EditList' @@ -94,7 +113,7 @@ import EditCard from '@/components/library/EditCard' import edits from '@/edits' export default { - props: ["objectType", "object"], + props: ["objectType", "object", "licenses"], components: { EditList, EditCard @@ -113,6 +132,9 @@ export default { created () { this.setValues() }, + mounted() { + $(".ui.dropdown").dropdown({fullTextSearch: true}) + }, computed: { configs: edits.getConfigs, config: edits.getConfig, @@ -182,6 +204,15 @@ export default { } ) } + }, + watch: { + 'values.license' (newValue) { + if (newValue === null) { + $(this.$refs.license).dropdown('clear') + } else { + $(this.$refs.license).dropdown('set selected', newValue) + } + } } } </script> diff --git a/front/src/components/library/TrackEdit.vue b/front/src/components/library/TrackEdit.vue index 7e26d1df10595cbb229f68b58350c715431c6c27..945bae961029f80a29fa878baaefd9d2c91ea8e4 100644 --- a/front/src/components/library/TrackEdit.vue +++ b/front/src/components/library/TrackEdit.vue @@ -6,9 +6,17 @@ <translate v-if="canEdit" key="1" translate-context="Content/*/Title">Edit this track</translate> <translate v-else key="2" translate-context="Content/*/Title">Suggest an edit on this track</translate> </h2> - <edit-form :object-type="objectType" :object="object" :can-edit="canEdit"></edit-form> + <edit-form + v-if="!isLoadingLicenses" + :object-type="objectType" + :object="object" + :can-edit="canEdit" + :licenses="licenses"></edit-form> + <div v-else class="ui inverted active dimmer"> + <div class="ui loader"></div> </div> - </section> + </div> + </section> </template> <script> @@ -19,12 +27,27 @@ export default { props: ["objectType", "object", "libraries"], data() { return { - id: this.object.id + id: this.object.id, + isLoadingLicenses: false, + licenses: [] } }, components: { EditForm }, + created () { + this.fetchLicenses() + }, + methods: { + fetchLicenses () { + let self = this + self.isLoadingLicenses = true + axios.get('licenses/').then((response) => { + self.isLoadingLicenses = false + self.licenses = response.data.results + }) + } + }, computed: { canEdit () { return true diff --git a/front/src/edits.js b/front/src/edits.js index ccc9c16b39ee27a357ba5f6545228fc1be900697..c72cb4b09822bb716a9358e49b046f26141e901b 100644 --- a/front/src/edits.js +++ b/front/src/edits.js @@ -10,13 +10,6 @@ export default { label: this.$pgettext('Content/Track/*/Noun', 'Title'), getValue: (obj) => { return obj.title } }, - { - id: 'license', - type: 'text', - required: false, - label: this.$pgettext('Content/*/*/Noun', 'License'), - getValue: (obj) => { return obj.license } - }, { id: 'position', type: 'text', @@ -24,7 +17,21 @@ export default { required: false, label: this.$pgettext('*/*/*/Short, Noun', 'Position'), getValue: (obj) => { return obj.position } - } + }, + { + id: 'copyright', + type: 'text', + required: false, + label: this.$pgettext('Content/Track/*/Noun', 'Copyright'), + getValue: (obj) => { return obj.copyright } + }, + { + id: 'license', + type: 'license', + required: false, + label: this.$pgettext('Content/*/*/Noun', 'License'), + getValue: (obj) => { return obj.license }, + }, ] } }