diff --git a/conftest.py b/conftest.py index 199f5471250b2108720e00ceb1fd60a52d778ca7..7f5672d3dc54a4cedcb4ad9a477aad0454067858 100644 --- a/conftest.py +++ b/conftest.py @@ -14,6 +14,8 @@ from program.models import ( License, LinkType, MusicFocus, + Playlist, + PlaylistEntry, Profile, RadioSettings, RRule, @@ -34,6 +36,8 @@ from program.tests.factories import ( LinkTypeFactory, MusicFocusFactory, OwnerFactory, + PlaylistEntryFactory, + PlaylistFactory, ProfileFactory, RadioSettingsFactory, RRuleFactory, @@ -147,6 +151,17 @@ def user_with_note_perms() -> User: return UserWithPermissionsFactory.create(user_permissions=permissions) +@pytest.fixture +def user_with_playlist_perms() -> User: + """User with add_playlist, change_playlist, delete_playlist permission""" + + permissions = Permission.objects.filter( + codename__in=["add_playlist", "change_playlist", "delete_playlist"] + ) + + return UserWithPermissionsFactory.create(user_permissions=permissions) + + @pytest.fixture def common_api_client1(api_client, common_user1) -> APIClient: """Authenticated common user 1 API client""" @@ -174,6 +189,15 @@ def api_client_note_perms(api_client, user_with_note_perms) -> APIClient: api_client.force_authenticate() +@pytest.fixture +def api_client_playlist_perms(api_client, user_with_playlist_perms) -> APIClient: + """Authenticated API client for user with {add,change,delete}_playlist permissions""" + + api_client.force_authenticate(user_with_playlist_perms) + yield api_client + api_client.force_authenticate() + + @pytest.fixture def once_rrule() -> RRule: return RRuleFactory(freq=0) @@ -219,6 +243,16 @@ def owned_show(common_user1, show) -> Show: return show +@pytest.fixture +def owned_show_playlist_perms(user_with_playlist_perms, show) -> Show: + """Show owned by user with playlist permissions""" + + show.owners.set([user_with_playlist_perms]) + show.save() + + return show + + @pytest.fixture def owned_show_once_timeslot(common_user1, show, once_schedule) -> TimeSlot: """Timeslot of a once schedule for a show owned by a common user""" @@ -303,6 +337,21 @@ def owner() -> User: return OwnerFactory() +@pytest.fixture +def default_playlist(show) -> Playlist: + return PlaylistFactory(description="default playlist", show=show) + + +@pytest.fixture +def playlist(show) -> Playlist: + return PlaylistFactory(show=show) + + +@pytest.fixture +def playlist_entry(playlist) -> PlaylistEntry: + return PlaylistEntryFactory(playlist=playlist) + + @pytest.fixture def license_() -> License: return LicenseFactory() diff --git a/program/filters.py b/program/filters.py index d442c2acc34d4669dcc5c90c4ad08bcf857b82a2..026f2059c0309ebf7928d14d6a0f52cd15ae9dcb 100644 --- a/program/filters.py +++ b/program/filters.py @@ -334,3 +334,18 @@ class VirtualTimeslotFilterSet(filters.FilterSet): class Meta: model = models.TimeSlot fields = ["start", "end", "include_virtual"] + + +class PlaylistFilter(filters.FilterSet): + contains_file_ids = IntegerInFilter( + field_name="entries__file_id", + help_text="Return only playlists that use to the specified file ID(s).", + ) + show_ids = IntegerInFilter( + field_name="show_id", + help_text="Return only playlists for the specified show ID.", + ) + + class Meta: + fields = ("contains_file_ids", "show_ids") + model = models.Playlist diff --git a/program/management/commands/addpermissions.py b/program/management/commands/addpermissions.py index 745b46f4c184c275b48a62da8d122f2f4fd89dc7..fcfdc354e0abdbd6bfa7f6c5ee915df9a5138754 100644 --- a/program/management/commands/addpermissions.py +++ b/program/management/commands/addpermissions.py @@ -20,6 +20,13 @@ PERMISSIONS = { ], ), "default change profile": Permission.objects.filter(codename="change_profile"), + "default add/change/delete playlist": Permission.objects.filter( + codename__in=[ + "add_playlist", + "change_playlist", + "delete_playlist", + ] + ), "custom add media-source": Permission.objects.filter( codename__in=[ "add__file", @@ -53,6 +60,13 @@ PERMISSIONS = { "change_timeslot", ], ), + "default add/change/delete playlist": Permission.objects.filter( + codename__in=[ + "add_playlist", + "change_playlist", + "delete_playlist", + ] + ), "custom add media-source": Permission.objects.filter( codename__in=[ "add__file", diff --git a/program/migrations/0118_delete_playlist.py b/program/migrations/0118_delete_playlist.py new file mode 100644 index 0000000000000000000000000000000000000000..df20e6b217a0c3a554293b2960337d2fab7bfe83 --- /dev/null +++ b/program/migrations/0118_delete_playlist.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.16 on 2024-10-29 20:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0117_alter_timeslot_options"), + ] + + operations = [ + migrations.DeleteModel( + name="Playlist", + ), + ] diff --git a/program/migrations/0119_playlist.py b/program/migrations/0119_playlist.py new file mode 100644 index 0000000000000000000000000000000000000000..355f224ceecfb0674ba8f1b235b3d0cd882f6335 --- /dev/null +++ b/program/migrations/0119_playlist.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.16 on 2024-10-29 20:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0118_delete_playlist"), + ] + + operations = [ + migrations.CreateModel( + name="Playlist", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("created_by", models.CharField(default="root", max_length=150)), + ("description", models.TextField(blank=True)), + ("playout_mode", models.CharField(blank=True, default="linear", max_length=8)), + ("updated_at", models.DateTimeField(auto_now=True, null=True)), + ("updated_by", models.CharField(blank=True, default="", max_length=150)), + ( + "show", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="playlists", + to="program.show", + ), + ), + ], + options={ + "permissions": [ + ("add__file", "Can add file media-source"), + ("add__import", "Can add import media-source"), + ("add__line", "Can add line media-source"), + ("add__m3ufile", "Can add m3u media-source"), + ("add__stream", "Can add stream media-source"), + ], + }, + ), + ] diff --git a/program/migrations/0120_playlistentry.py b/program/migrations/0120_playlistentry.py new file mode 100644 index 0000000000000000000000000000000000000000..fd1713a272021c464e8131acb525df920be9105c --- /dev/null +++ b/program/migrations/0120_playlistentry.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.16 on 2024-10-29 20:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0119_playlist"), + ] + + operations = [ + migrations.CreateModel( + name="PlaylistEntry", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("duration", models.FloatField(null=True)), + ("file_id", models.IntegerField(null=True)), + ("line_num", models.IntegerField()), + ("uri", models.CharField(blank=True, max_length=1024)), + ( + "playlist", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="entries", + to="program.playlist", + ), + ), + ], + ), + ] diff --git a/program/migrations/0121_remove_schedule_default_playlist_id_and_more.py b/program/migrations/0121_remove_schedule_default_playlist_id_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..f6b9aff5391e74714b8c21d421c816a2bf92d38d --- /dev/null +++ b/program/migrations/0121_remove_schedule_default_playlist_id_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.16 on 2024-10-29 21:28 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0120_playlistentry"), + ] + + operations = [ + migrations.RemoveField( + model_name="schedule", + name="default_playlist_id", + ), + migrations.RemoveField( + model_name="show", + name="default_playlist_id", + ), + migrations.AddField( + model_name="schedule", + name="default_playlist", + field=models.ForeignKey( + help_text="Playlist in case a timeslot’s playlist_id of this schedule is empty.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="program.playlist", + ), + ), + migrations.AddField( + model_name="show", + name="default_playlist", + field=models.ForeignKey( + help_text="Playlist in case a timeslot’s playlist_id of this show is empty.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="program.playlist", + ), + ), + ] diff --git a/program/migrations/0122_remove_timeslot_playlist_id_timeslot_playlist.py b/program/migrations/0122_remove_timeslot_playlist_id_timeslot_playlist.py new file mode 100644 index 0000000000000000000000000000000000000000..11cbc55557e638bdbcf5483b10fe2b024d29583b --- /dev/null +++ b/program/migrations/0122_remove_timeslot_playlist_id_timeslot_playlist.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.16 on 2024-10-29 21:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0121_remove_schedule_default_playlist_id_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="timeslot", + name="playlist_id", + ), + migrations.AddField( + model_name="timeslot", + name="playlist", + field=models.ForeignKey( + blank=True, + help_text="Playlist for this timeslot.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="program.playlist", + ), + ), + ] diff --git a/program/migrations/0123_alter_playlist_options.py b/program/migrations/0123_alter_playlist_options.py new file mode 100644 index 0000000000000000000000000000000000000000..df1001cbc26ee979f3671394e0c8765e20ce97d7 --- /dev/null +++ b/program/migrations/0123_alter_playlist_options.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.16 on 2024-11-01 19:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0122_remove_timeslot_playlist_id_timeslot_playlist"), + ] + + operations = [ + migrations.AlterModelOptions( + name="playlist", + options={ + "permissions": [ + ("add__file", "Can add file media-source"), + ("add__import", "Can add import media-source"), + ("add__line", "Can add line media-source"), + ("add__m3ufile", "Can add m3u media-source"), + ("add__stream", "Can add stream media-source"), + ("create_playlist", "Can create playlist"), + ("update_playlist", "Can update playlist"), + ] + }, + ), + ] diff --git a/program/migrations/0124_alter_playlist_options.py b/program/migrations/0124_alter_playlist_options.py new file mode 100644 index 0000000000000000000000000000000000000000..54a772f7b2668e8c450a7eb0e8ec54c1cbf461f4 --- /dev/null +++ b/program/migrations/0124_alter_playlist_options.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2024-11-06 15:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0123_alter_playlist_options"), + ] + + operations = [ + migrations.AlterModelOptions( + name="playlist", + options={ + "permissions": [ + ("add__file", "Can add file media-source"), + ("add__import", "Can add import media-source"), + ("add__line", "Can add line media-source"), + ("add__m3ufile", "Can add m3u media-source"), + ("add__stream", "Can add stream media-source"), + ("create_playlist", "Can create playlist"), + ("destroy_playlist", "Can destroy playlist"), + ("update_playlist", "Can update playlist"), + ] + }, + ), + ] diff --git a/program/migrations/0125_alter_playlistentry_duration.py b/program/migrations/0125_alter_playlistentry_duration.py new file mode 100644 index 0000000000000000000000000000000000000000..7a2cb14869746bfe99377e4f4edc49cc1a8037e0 --- /dev/null +++ b/program/migrations/0125_alter_playlistentry_duration.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.16 on 2024-11-12 16:39 + +import program.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0124_alter_playlist_options"), + ] + + operations = [ + migrations.AlterField( + model_name="playlistentry", + name="duration", + field=models.FloatField( + null=True, validators=[program.models.validate_positive_duration] + ), + ), + ] diff --git a/program/migrations/0126_playlistentry_file_id_or_uri.py b/program/migrations/0126_playlistentry_file_id_or_uri.py new file mode 100644 index 0000000000000000000000000000000000000000..d50754321bf39e9b9425e9ef013ea2152f949fbd --- /dev/null +++ b/program/migrations/0126_playlistentry_file_id_or_uri.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.16 on 2024-11-12 19:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0125_alter_playlistentry_duration"), + ] + + operations = [ + migrations.AddConstraint( + model_name="playlistentry", + constraint=models.CheckConstraint( + check=models.Q( + ("file_id__isnull", False), + models.Q(("uri", ""), _negated=True), + _connector="OR", + ), + name="file-id-or-uri", + ), + ), + ] diff --git a/program/migrations/0127_alter_playlistentry_options_and_more.py b/program/migrations/0127_alter_playlistentry_options_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..61fd1beb999ae2c397572831b55d603370a8f1bb --- /dev/null +++ b/program/migrations/0127_alter_playlistentry_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.16 on 2024-11-15 18:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("program", "0126_playlistentry_file_id_or_uri"), + ] + + operations = [ + migrations.AlterModelOptions( + name="playlistentry", + options={"ordering": ["playlist", "order"]}, + ), + migrations.RenameField( + model_name="playlistentry", + old_name="line_num", + new_name="order", + ), + ] diff --git a/program/models.py b/program/models.py index 6984ea9a0dd150487e6a60cd7eedc9673009b79e..23ec5bf24b742c2e49c7182acb35b80da347832b 100644 --- a/program/models.py +++ b/program/models.py @@ -238,7 +238,13 @@ class Show(models.Model): cba_series_id = models.IntegerField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) created_by = models.CharField(max_length=150) - default_playlist_id = models.IntegerField(blank=True, null=True) + default_playlist = models.ForeignKey( + "Playlist", + help_text="Playlist in case a timeslot’s playlist_id of this show is empty.", + null=True, + on_delete=models.SET_NULL, + related_name="+", + ) description = models.TextField(blank=True, help_text="Description of this show.") email = models.EmailField(blank=True, null=True, help_text="Email address of this show.") funding_category = models.ForeignKey( @@ -404,10 +410,12 @@ class Schedule(models.Model): ], null=True, ) - default_playlist_id = models.IntegerField( - blank=True, + default_playlist = models.ForeignKey( + "Playlist", + help_text="Playlist in case a timeslot’s playlist_id of this schedule is empty.", null=True, - help_text="A tank ID in case the timeslot's playlist_id is empty.", + on_delete=models.SET_NULL, + related_name="+", ) end_time = models.TimeField(null=True, help_text="End time of schedule.") first_date = models.DateField(help_text="Start date of schedule.") @@ -451,7 +459,14 @@ class Schedule(models.Model): class TimeSlot(models.Model): end = models.DateTimeField() memo = models.TextField(blank=True, help_text="Memo for this timeslot.") - playlist_id = models.IntegerField(null=True, help_text="Playlist ID of this timeslot.") + playlist = models.ForeignKey( + "Playlist", + blank=True, + help_text="Playlist for this timeslot.", + null=True, + on_delete=models.SET_NULL, + related_name="+", + ) repetition_of = models.ForeignKey( "self", blank=True, null=True, on_delete=models.SET_NULL, related_name="repetitions" ) @@ -563,6 +578,16 @@ class CBA(models.Model): class Playlist(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.CharField(max_length=150, default="root") + description = models.TextField(blank=True) + playout_mode = models.CharField(blank=True, null=False, default="linear", max_length=8) + show = models.ForeignKey( + "Show", null=False, on_delete=models.CASCADE, related_name="playlists" + ) + updated_at = models.DateTimeField(auto_now=True, blank=True, null=True) + updated_by = models.CharField(blank=True, default="", max_length=150) + class Meta: permissions = [ ("add__file", "Can add file media-source"), @@ -570,7 +595,41 @@ class Playlist(models.Model): ("add__line", "Can add line media-source"), ("add__m3ufile", "Can add m3u media-source"), ("add__stream", "Can add stream media-source"), + # overrules ownership + ("create_playlist", "Can create playlist"), + ("destroy_playlist", "Can destroy playlist"), + ("update_playlist", "Can update playlist"), + ] + + def __str__(self): + return f"{self.show.name} - {self.description}" if self.description else self.show.name + + +def validate_positive_duration(value: float) -> None: + """Validates that the duration is positive. Raises a Django `ValidationError` if negative.""" + + if value < 0.0: + raise DjangoValidationError("duration must be positive") + + +class PlaylistEntry(models.Model): + duration = models.FloatField(null=True, validators=[validate_positive_duration]) + file_id = models.IntegerField(null=True) + order = models.IntegerField(null=False) + playlist = models.ForeignKey(Playlist, on_delete=models.CASCADE, related_name="entries") + uri = models.CharField(blank=True, max_length=1024) + + class Meta: + constraints = [ + models.CheckConstraint( + check=Q(file_id__isnull=False) | ~Q(uri=""), + name="file-id-or-uri", + ) ] + ordering = ["playlist", "order"] + + def __str__(self): + return f"{self.uri} - {self.duration}" if self.duration else self.uri class ImageAspectRadioField(models.CharField): diff --git a/program/serializers.py b/program/serializers.py index 08ecfcb8ab5c565b76daf7710b41ba8c78d0bb57..e9481b51e98b1e21286d2a00ee5fbee7366fbc7f 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -30,6 +30,7 @@ from rest_framework.permissions import exceptions from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist +from django.db import IntegrityError, transaction from django.db.models import Q from django.utils import text, timezone from program.models import ( @@ -43,6 +44,8 @@ from program.models import ( MusicFocus, Note, NoteLink, + Playlist, + PlaylistEntry, Profile, ProfileLink, ProgramEntry, @@ -543,8 +546,12 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): cba_series_id = serializers.IntegerField( allow_null=True, required=False, help_text="CBA series ID." ) - default_playlist_id = serializers.IntegerField( - allow_null=True, required=False, help_text="Default `Playlist` ID for this show." + default_playlist_id = serializers.PrimaryKeyRelatedField( + allow_null=True, + help_text="Default `Playlist` ID for this show.", + queryset=Playlist.objects.all(), + required=False, + source="default_playlist", ) funding_category_id = serializers.PrimaryKeyRelatedField( queryset=FundingCategory.objects.all(), @@ -678,6 +685,7 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): validated_data["type"] = validated_data.pop("type") # optional foreign key + validated_data["default_playlist"] = validated_data.pop("default_playlist", None) validated_data["image"] = validated_data.pop("image", None) validated_data["logo"] = validated_data.pop("logo", None) validated_data["predecessor"] = validated_data.pop("predecessor", None) @@ -741,8 +749,8 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): if "cba_series_id" in validated_data: instance.cba_series_id = validated_data.get("cba_series_id") - if "default_playlist_id" in validated_data: - instance.default_playlist_id = validated_data.get("default_playlist_id") + if "default_playlist" in validated_data: + instance.default_playlist = validated_data.get("default_playlist") if "email" in validated_data: instance.email = validated_data.get("email") @@ -873,6 +881,7 @@ class ScheduleInRequestSerializer(ScheduleSerializer): def create(self, validated_data): """Create and return a new Schedule instance, given the validated data.""" + validated_data["default_playlist"] = validated_data.pop("default_playlist_id") validated_data["rrule"] = validated_data.pop("rrule_id") validated_data["show"] = validated_data.pop("show_id") @@ -890,8 +899,8 @@ class ScheduleInRequestSerializer(ScheduleSerializer): instance.end_time = validated_data.get("end_time", instance.end_time) instance.last_date = validated_data.get("last_date", instance.last_date) instance.is_repetition = validated_data.get("is_repetition", instance.is_repetition) - instance.default_playlist_id = validated_data.get( - "default_playlist_id", instance.default_playlist_id + instance.default_playlist = validated_data.get( + "default_playlist_id", instance.default_playlist ) instance.rrule = validated_data.get("rrule_id", instance.rrule) instance.show = validated_data.get("show_id", instance.show) @@ -986,6 +995,13 @@ class TimeSlotSerializer(serializers.ModelSerializer): source="repetition_of", help_text="This timeslot is a repetition of `Timeslot` ID.", ) + playlist_id = serializers.PrimaryKeyRelatedField( + allow_null=True, + help_text="", + queryset=Playlist.objects.all(), + required=False, + source="playlist", + ) class Meta: model = TimeSlot @@ -1049,7 +1065,7 @@ class TimeSlotSerializer(serializers.ModelSerializer): instance.repetition_of = validated_data.get("repetition_of") if "playlist_id" in validated_data: - instance.playlist_id = validated_data.get("playlist_id") + instance.playlist = validated_data.get("playlist_id") instance.save() @@ -1487,3 +1503,122 @@ class ApplicationStatePurgeSerializer(serializers.Serializer): "Selects all models if set to true and no other filters have been set." ), ) + + +class PlaylistSerializer(serializers.ModelSerializer): + class PlaylistEntrySerializer(serializers.ModelSerializer): + class Meta: + model = PlaylistEntry + fields = ( + "duration", + "file_id", + "uri", + ) + + entries = PlaylistEntrySerializer(many=True, required=False) + show_id = serializers.PrimaryKeyRelatedField( + queryset=Show.objects.all(), + required=True, + source="show", + ) + + class Meta: + model = Playlist + read_only_fields = ( + "id", + "created_at", + "created_by", + "updated_at", + "updated_by", + ) + fields = ( + "description", + "entries", + "playout_mode", + "show_id", + ) + read_only_fields + + def create(self, validated_data): + """Create a new Playlist instance, given the validated data. + + A ValidationError is raised if a playlist entry is invalid or if multiple null duration + entries are present.""" + + user = self.context["request"].user + user_is_owner = user in validated_data.get("show").owners.all() + + # having the create_playlist permission overrules the ownership + if not (user.has_perm("program.create_playlist") or user_is_owner): + raise exceptions.PermissionDenied(detail="You are not allowed to create a playlist.") + + entries = validated_data.pop("entries", []) + + with transaction.atomic(): + playlist = Playlist.objects.create(created_by=user.username, **validated_data) + + for order, entry_data in enumerate(entries, start=1): + entry_data.update({"order": order}) + + try: + PlaylistEntry.objects.create(playlist=playlist, **entry_data) + except IntegrityError: + raise exceptions.ValidationError( + code="playlist-entry-file-id-or-uri", + detail="playlist entries must either have file id or uri.", + ) + + if playlist.entries.filter(duration__isnull=True).count() > 1: + raise exceptions.ValidationError( + code="multiple-null-duration-playlist-entries", + detail="playlist may only have one entry without duration", + ) + + return playlist + + def update(self, instance, validated_data): + """Update an existing Playlist instance, given the validated data. + + A ValidationError is raised if a playlist entry is invalid or if multiple null duration + entries are present.""" + + user = self.context["request"].user + user_is_owner = user in instance.show.owners.all() + + # having the update_playlist permission overrules the ownership + if not (user.has_perm("program.update_playlist") or user_is_owner): + raise exceptions.PermissionDenied( + detail="You are not allowed to update this playlist." + ) + + with transaction.atomic(): + if "description" in validated_data: + instance.description = validated_data.pop("description") + + if "playout_mode" in validated_data: + instance.playout_mode = validated_data.pop("playout_mode") + + if "entries" in validated_data: + if instance.entries.count() > 0: + for entry in instance.entries.all(): + entry.delete(keep_parents=True) + + for order, entry_data in enumerate(validated_data.get("entries"), start=1): + entry_data.update({"order": order}) + + try: + PlaylistEntry.objects.create(playlist=instance, **entry_data) + except IntegrityError: + raise exceptions.ValidationError( + code="playlist-entry-file-id-or-uri", + detail="playlist entries must either have file id or uri.", + ) + + if instance.entries.filter(duration__isnull=True).count() > 1: + raise exceptions.ValidationError( + code="multiple-null-duration-playlist-entries", + detail="playlist may only have one entry without duration", + ) + + instance.save() + + return instance diff --git a/program/tests/factories.py b/program/tests/factories.py index da6043228682ecce75072e65fd41b28a94c935ef..6e008fb17daf2b35d8cc5d75ddd9d0adcf13b14d 100644 --- a/program/tests/factories.py +++ b/program/tests/factories.py @@ -16,6 +16,8 @@ from program.models import ( LinkType, MusicFocus, Note, + Playlist, + PlaylistEntry, Profile, RadioSettings, RRule, @@ -182,3 +184,18 @@ class CBAFactory(DjangoModelFactory): username = Sequence(lambda n: "username_%d" % n) user_token = Sequence(lambda n: "user_token_%d" % n) + + +class PlaylistFactory(DjangoModelFactory): + class Meta: + model = Playlist + + +class PlaylistEntryFactory(DjangoModelFactory): + class Meta: + model = PlaylistEntry + + duration = 73 + file_id = 42 + order = Sequence(lambda n: int(n)) + uri = f"file://0/{file_id}" diff --git a/program/tests/test_playlists.py b/program/tests/test_playlists.py new file mode 100644 index 0000000000000000000000000000000000000000..3b4b656607c53cdb9195aee5f174c4ad2593e427 --- /dev/null +++ b/program/tests/test_playlists.py @@ -0,0 +1,114 @@ +import random + +import pytest + +from conftest import assert_data +from program.tests.factories import PlaylistFactory + +pytestmark = pytest.mark.django_db + + +def url(playlist=None): + if playlist: + return f"/api/v1/playlists/{playlist.id}/" + else: + return "/api/v1/playlists/" + + +def playlist_data(owned_show): + return { + "description": "DESCRIPTION", + "show_id": owned_show.id, + } + + +def playlist_entries_data(show, file_id, entries=1): + return [ + { + "duration": random.uniform(1975, 2024), + "file_id": file_id, + "order": n + 1, + "uri": f"file://{show.id}/{file_id}", + } + for n in range(entries) + ] + + +def test_create_playlist( + user_with_playlist_perms, api_client_playlist_perms, owned_show_playlist_perms +): + data = playlist_data(owned_show_playlist_perms) + + response = api_client_playlist_perms.post(url(), data=data) + + assert response.status_code == 201 + + assert_data(response, data) + + +def test_retrieve_playlist(user_with_playlist_perms, api_client_playlist_perms, show, playlist): + response = api_client_playlist_perms.get(url(playlist=playlist)) + + assert response.status_code == 200 + + +def test_list_playlists(api_client_playlist_perms, show): + PLAYLISTS = 3 + PlaylistFactory.create_batch(size=PLAYLISTS, show=show) + + response = api_client_playlist_perms.get(url()) + + assert response.status_code == 200 + assert len(response.data) == PLAYLISTS + + +def test_delete_playlist(api_client_playlist_perms, owned_show_playlist_perms, playlist): + response = api_client_playlist_perms.delete(url(playlist=playlist)) + + print(response.data) + + assert response.status_code == 204 + + +def test_update_description(api_client_playlist_perms, owned_show_playlist_perms, playlist): + update = {"description": "DESCRIPTION"} + + response = api_client_playlist_perms.patch(url(playlist=playlist), data=update) + + assert response.status_code == 200 + assert response.data["description"] == update["description"] + + +def test_update_playout_mode(api_client_playlist_perms, owned_show_playlist_perms, playlist): + update = {"playout_mode": "LINEAR"} + + response = api_client_playlist_perms.patch(url(playlist=playlist), data=update, format="json") + print(response.data) + assert response.status_code == 200 + assert response.data["playout_mode"] == update["playout_mode"] + + +def test_update_entries(api_client_playlist_perms, owned_show_playlist_perms, playlist): + playlist_entries = playlist_entries_data(owned_show_playlist_perms, 42, 3) + + update = {"entries": playlist_entries} + + response = api_client_playlist_perms.patch(url(playlist=playlist), data=update, format="json") + + assert response.status_code == 200 + + for entry, update in zip(response.data["entries"], update["entries"]): + assert entry["duration"] == update["duration"] + assert entry["file_id"] == update["file_id"] + assert entry["uri"] == update["uri"] + + +def test_clear_entries( + api_client_playlist_perms, owned_show_playlist_perms, playlist, playlist_entry +): + update = {"entries": []} + + response = api_client_playlist_perms.patch(url(playlist=playlist), data=update, format="json") + + assert response.status_code == 200 + assert len(response.data["entries"]) == 0 diff --git a/program/tests/test_schedules.py b/program/tests/test_schedules.py index 0416a0f56a45ccb7b3e22f8ac8ed3a9fef1f628e..bf06d4b51f0c51a3bee7ccd919aa4f24bfbc9e6e 100644 --- a/program/tests/test_schedules.py +++ b/program/tests/test_schedules.py @@ -218,8 +218,8 @@ def test_update_schedule(admin_api_client, once_schedule): assert_data(response, update) -def test_patch_set_default_playlist_id(admin_api_client, once_schedule): - update = {"default_playlist_id": 42} +def test_patch_set_default_playlist_id(admin_api_client, once_schedule, default_playlist, show): + update = {"default_playlist_id": default_playlist.id} response = admin_api_client.patch(url(schedule=once_schedule), data=update) diff --git a/program/tests/test_shows.py b/program/tests/test_shows.py index 8d498b8113a5eb30fa67e642ff2a92e26fdc6e3b..e0e1855c48d6217e5a6afc26c39995c87cc997a4 100644 --- a/program/tests/test_shows.py +++ b/program/tests/test_shows.py @@ -178,6 +178,29 @@ def test_update_show_forbidden_for_common_user( assert response.status_code == 403 +def test_update_default_playlist_id(admin_api_client, show, default_playlist): + update = {"default_playlist_id": default_playlist.id} + + response = admin_api_client.patch(url(show=show), data=update) + + assert response.status_code == 200 + + assert response.data["default_playlist_id"] == update["default_playlist_id"] + + +def test_clear_default_playlist_id(admin_api_client, show, default_playlist): + show.default_playlist = default_playlist + show.save() + + update = {"default_playlist_id": None} + + response = admin_api_client.patch(url(show=show), data=update, format="json") + + assert response.status_code == 200 + + assert response.data["default_playlist_id"] == update["default_playlist_id"] + + def test_redacted_fields_for_unauthenticated_requests(api_client, show): response = api_client.get(url(show)) data = response.json() diff --git a/program/tests/test_timeslots.py b/program/tests/test_timeslots.py index af923e819b4c797c963f21d20a41600d6f1492d7..a2f4ad70eed84f68d7e2b9e6d5aea361b908bfb8 100644 --- a/program/tests/test_timeslots.py +++ b/program/tests/test_timeslots.py @@ -10,7 +10,12 @@ def url(timeslot=None) -> str: return "/api/v1/timeslots/" -def timeslot_data() -> dict[str, str | int]: +def timeslot_data(default_playlist=None) -> dict[str, str | int]: + if default_playlist is None: + return { + "memo": "MEMO", + "repetition_of": 1, + } return { "memo": "MEMO", "playlist_id": 1, @@ -50,8 +55,8 @@ def test_update_memo_as_admin(admin_api_client, once_timeslot): assert response.status_code == 200 -def test_update_playlist_id_as_admin(admin_api_client, once_timeslot): - update = {"playlist_id": 1} +def test_update_playlist_id_as_admin(admin_api_client, once_timeslot, default_playlist, show): + update = {"playlist_id": default_playlist.id} response = admin_api_client.patch(url(timeslot=once_timeslot), data=update) @@ -65,8 +70,8 @@ def test_update_repetition_of_as_admin(admin_api_client, once_timeslot): assert response.status_code == 200 -def test_update_as_admin(admin_api_client, once_timeslot): - update = timeslot_data() +def test_update_as_admin(admin_api_client, once_timeslot, default_playlist, show): + update = timeslot_data(default_playlist=default_playlist) response = admin_api_client.put(url(timeslot=once_timeslot), data=update) diff --git a/program/views.py b/program/views.py index 433b940f8a3442698813da48af001b9cf01aec7e..ce6c317c52f4e616644831659e0f0a231c80fb84 100644 --- a/program/views.py +++ b/program/views.py @@ -35,6 +35,7 @@ from rest_framework import decorators from rest_framework import filters as drf_filters from rest_framework import mixins, permissions, status, views, viewsets from rest_framework.pagination import LimitOffsetPagination +from rest_framework.permissions import exceptions from rest_framework.request import Request from rest_framework.response import Response @@ -54,6 +55,7 @@ from program.models import ( LinkType, MusicFocus, Note, + Playlist, Profile, RadioSettings, RRule, @@ -79,6 +81,7 @@ from program.serializers import ( LinkTypeSerializer, MusicFocusSerializer, NoteSerializer, + PlaylistSerializer, PlayoutProgramEntrySerializer, ProfileSerializer, RadioSettingsSerializer, @@ -1648,3 +1651,66 @@ class APIApplicationStateView(TestOperationViewMixin, views.APIView): invert_selection=params.validated_data["invert_selection"], ) return Response(status=status.HTTP_200_OK, data=deleted) + + +@extend_schema_view( + create=extend_schema(summary="Create a new playlist."), + retrieve=extend_schema(summary="Retrieve a single playlist."), + update=extend_schema(summary="Update an existing playlist."), + partial_update=extend_schema(summary="Partially update an existing playlist."), + destroy=extend_schema(summary="Delete an existing playlist."), + list=extend_schema(summary="List all playlists."), +) +class APIPlaylistViewSet(viewsets.ModelViewSet): + filterset_class = filters.PlaylistFilter + serializer_class = PlaylistSerializer + + def get_queryset(self): + """The queryset is empty if the request is not authenticated. Otherwise, it contains all + the playlists.""" + + if not self.request.user.is_authenticated: + return Playlist.objects.none() + + return Playlist.objects.all() + + def create(self, request, *args, **kwargs): + serializer = PlaylistSerializer( + context={"request": request}, + data=request.data, + ) + + if serializer.is_valid(raise_exception=True): + serializer.save() + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + playlist = self.get_object() + + data = request.data + data.update({"show_id": playlist.show_id}) # we already know it at this point + + serializer = PlaylistSerializer( + context={"request": request}, + data=data, + instance=playlist, + ) + + if serializer.is_valid(raise_exception=True): + serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + + def destroy(self, request, *args, **kwargs): + playlist = self.get_object() + + user = request.user + user_is_owner = user in playlist.show.owners.all() + + if not (user.has_perm("program.destroy_playlist") or user_is_owner): + raise exceptions.PermissionDenied("You are not allowed to delete this playlist.") + + playlist.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/steering/urls.py b/steering/urls.py index d863f58424d8ae9f88f2326d531e5b461d8b9d89..194e6993d94c3ed7a94821cca7c677752fda9d7f 100644 --- a/steering/urls.py +++ b/steering/urls.py @@ -35,6 +35,7 @@ from program.views import ( APILinkTypeViewSet, APIMusicFocusViewSet, APINoteViewSet, + APIPlaylistViewSet, APIProfileViewSet, APIProgramBasicViewSet, APIProgramCalendarViewSet, @@ -72,6 +73,7 @@ router.register(r"settings", APIRadioSettingsViewSet, basename="settings") router.register(r"program/basic", APIProgramBasicViewSet, basename="program-basic") router.register(r"program/playout", APIProgramPlayoutViewSet, basename="program-playout") router.register(r"program/calendar", APIProgramCalendarViewSet, basename="program-calendar") +router.register(r"playlists", APIPlaylistViewSet, basename="playlists") urlpatterns = [ path("openid/", include("oidc_provider.urls", namespace="oidc_provider")),