diff --git a/.gitignore b/.gitignore index 69da0bf513c4e8ebdb04cd38baa98e3ca5d75a58..76aa668227b7fddd47fc0d70d92af05afc97820c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ db.sqlite3 .mypy_cache *.pyc .pytest_cache +.cache/ static/ steering_data_model.png diff --git a/fixtures/program/show.json b/fixtures/program/show.json index 429f422c32df80a2cef1a002465abb0c0d33b8b8..597026872db92c22c309727ae0f7671cf6eccacc 100644 --- a/fixtures/program/show.json +++ b/fixtures/program/show.json @@ -9,7 +9,7 @@ "name": "Musikprogramm", "slug": "musikprogramm", "image": null, - "logo": "", + "logo": null, "short_description": "Unmoderiertes Musikprogramm", "description": "Unmoderiertes Musikprogramm", "email": "musikredaktion@helsinki.at", diff --git a/poetry.lock b/poetry.lock index 5432cf2b013dbcc6a69dc4f234854858596ae538..09f26e2d25aa9611e4006fe7f2b8f7e92af46b78 100644 --- a/poetry.lock +++ b/poetry.lock @@ -402,18 +402,18 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.10.7" +version = "3.11.0" description = "A platform independent file lock." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "filelock-3.10.7-py3-none-any.whl", hash = "sha256:bde48477b15fde2c7e5a0713cbe72721cb5a5ad32ee0b8f419907960b9d75536"}, - {file = "filelock-3.10.7.tar.gz", hash = "sha256:892be14aa8efc01673b5ed6589dbccb95f9a8596f0507e232626155495c18105"}, + {file = "filelock-3.11.0-py3-none-any.whl", hash = "sha256:f08a52314748335c6460fc8fe40cd5638b85001225db78c2aa01c8c0db83b318"}, + {file = "filelock-3.11.0.tar.gz", hash = "sha256:3618c0da67adcc0506b015fd11ef7faf1b493f0b40d87728e19986b536890c37"}, ] [package.extras] -docs = ["furo (>=2022.12.7)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] +docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] testing = ["covdefaults (>=2.3)", "coverage (>=7.2.2)", "diff-cover (>=7.5)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] [[package]] diff --git a/program/filters.py b/program/filters.py index e263f095a9e821439cf82da9524a7158a9b122da..298111c8b04302ad5ba4ca41981af0b80e6e563f 100644 --- a/program/filters.py +++ b/program/filters.py @@ -231,12 +231,20 @@ class TimeSlotFilterSet(filters.FilterSet): class NoteFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): + show = IntegerInFilter( + field_name="timeslot__show", + help_text="Return only notes that belong to the specified show(s).", + ) + timeslot = IntegerInFilter( + field_name="timeslot", + help_text="Return only notes that belong to the specified timeslot(s).", + ) ids = IntegerInFilter( field_name="id", help_text="Return only notes matching the specified id(s).", ) show_owner = IntegerInFilter( - field_name="show__owners", + field_name="timeslot__show__owners", help_text="Return only notes by show the specified owner(s): all notes the user may edit.", ) @@ -245,7 +253,7 @@ class NoteFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): help_texts = { "owner": "Return only notes created by the specified user.", } - fields = ["ids", "owner", "show_owner"] + fields = ["ids", "owner", "show", "timeslot", "show_owner"] class ActiveFilterSet(StaticFilterHelpTextMixin, filters.FilterSet): diff --git a/program/migrations/0051_remove_show_logo.py b/program/migrations/0051_remove_show_logo.py new file mode 100644 index 0000000000000000000000000000000000000000..9130f374d3cd8df948d146b08e325c698d3befe2 --- /dev/null +++ b/program/migrations/0051_remove_show_logo.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.18 on 2023-04-11 15:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0050_auto_20230404_0037'), + ] + + operations = [ + migrations.RemoveField( + model_name='show', + name='logo', + ), + ] diff --git a/program/migrations/0052_show_logo.py b/program/migrations/0052_show_logo.py new file mode 100644 index 0000000000000000000000000000000000000000..ba7a4c67358e11a8b341cc714d325a4bba3bbf4d --- /dev/null +++ b/program/migrations/0052_show_logo.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.18 on 2023-04-11 15:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0051_remove_show_logo'), + ] + + operations = [ + migrations.AddField( + model_name='show', + name='logo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='logo_shows', to='program.image'), + ), + ] diff --git a/program/migrations/0053_auto_20230411_1855.py b/program/migrations/0053_auto_20230411_1855.py new file mode 100644 index 0000000000000000000000000000000000000000..6a9ad1de44ffd19cfb123d0efc4f3fb844229e6e --- /dev/null +++ b/program/migrations/0053_auto_20230411_1855.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-04-11 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0052_show_logo'), + ] + + operations = [ + migrations.AlterField( + model_name='image', + name='alt_text', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='image', + name='credits', + field=models.TextField(blank=True, default=''), + ), + ] diff --git a/program/models.py b/program/models.py index 2fb10313d894dc8f6f72ce4c27b76f87411ba168..ae93101d1f2430574e71ee2052f04fdc2e23e651 100644 --- a/program/models.py +++ b/program/models.py @@ -131,8 +131,8 @@ class Language(models.Model): class Image(models.Model): - alt_text = models.TextField(blank=True, null=True) - credits = models.TextField(blank=True, null=True) + alt_text = models.TextField(blank=True, default="") + credits = models.TextField(blank=True, default="") height = models.PositiveIntegerField(blank=True, null=True, editable=False) image = VersatileImageField( blank=True, @@ -230,7 +230,14 @@ class Show(ModelWithCreatedUpdatedFields): is_active = models.BooleanField(default=True) is_public = models.BooleanField(default=False) language = models.ManyToManyField(Language, blank=True, related_name="shows") - logo = models.ImageField(blank=True, null=True, upload_to="show_images") + # TODO: is this really necessary? + logo = models.ForeignKey( + Image, + blank=True, + null=True, + on_delete=models.CASCADE, + related_name="logo_shows", + ) music_focus = models.ManyToManyField(MusicFocus, blank=True, related_name="shows") name = models.CharField(max_length=255) owners = models.ManyToManyField(User, blank=True, related_name="shows") diff --git a/program/serializers.py b/program/serializers.py index af0ee1d812b8e92727b7f15c4a8982e0aae47842..9336939785ddcb67ada771c3f9a93da710f666c0 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -18,7 +18,8 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. # -from typing import List +import re +from typing import List, TypedDict from rest_framework import serializers @@ -197,17 +198,47 @@ class HostLinkSerializer(serializers.ModelSerializer): fields = ("type", "url") +class PPOIField(serializers.CharField): + def validate_format(self, value: str): + if not re.match(r"\d(?:\.\d+)?x\d(?:\.\d+)?", value): + raise serializers.ValidationError("PPOI must match format: ${float}x${float}") + + def __init__(self, **kwargs): + kwargs["max_length"] = 20 + kwargs.setdefault("validators", []) + kwargs["validators"].append(self.validate_format) + super().__init__(**kwargs) + + def to_representation(self, value: tuple[float, float]): + [left, top] = value + return f"{left}x{top}" + + +class Thumbnail(TypedDict): + width: float + height: float + url: str + + class ImageSerializer(serializers.ModelSerializer): + ppoi = PPOIField() thumbnails = serializers.SerializerMethodField() @staticmethod - def get_thumbnails(instance) -> List[str]: + def get_thumbnails(instance) -> List[Thumbnail]: """Returns thumbnails""" thumbnails = [] if instance.image.name and THUMBNAIL_SIZES: for size in THUMBNAIL_SIZES: - thumbnails.append(instance.image.crop[size].name) + [width, height] = size.split("x") + thumbnails.append( + { + "width": int(width), + "height": int(height), + "url": instance.image.crop[size].url, + } + ) return thumbnails @@ -216,7 +247,6 @@ class ImageSerializer(serializers.ModelSerializer): read_only_fields = ( "height", "id", - "ppoi", "thumbnails", "width", ) @@ -224,6 +254,7 @@ class ImageSerializer(serializers.ModelSerializer): "alt_text", "credits", "image", + "ppoi", ) + read_only_fields def create(self, validated_data): @@ -237,9 +268,10 @@ class ImageSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): """Update and return an existing Image instance, given the validated data.""" - # Only alt_text and credits can be updated. + # Only these fields can be updated. instance.alt_text = validated_data.get("alt_text", instance.alt_text) instance.credits = validated_data.get("credits", instance.credits) + instance.image.ppoi = validated_data.get("ppoi", instance.ppoi) instance.save() @@ -247,7 +279,11 @@ class ImageSerializer(serializers.ModelSerializer): class HostSerializer(serializers.ModelSerializer): - image = serializers.PrimaryKeyRelatedField(queryset=Image.objects.all(), required=False) + image = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Image.objects.all(), + required=False, + ) links = HostLinkSerializer(many=True, required=False) class Meta: @@ -348,9 +384,18 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): category = serializers.PrimaryKeyRelatedField(queryset=Category.objects.all(), many=True) funding_category = serializers.PrimaryKeyRelatedField(queryset=FundingCategory.objects.all()) hosts = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all(), many=True) - image = serializers.PrimaryKeyRelatedField(queryset=Image.objects.all(), required=False) + image = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Image.objects.all(), + required=False, + ) language = serializers.PrimaryKeyRelatedField(queryset=Language.objects.all(), many=True) links = HostLinkSerializer(many=True, required=False) + logo = serializers.PrimaryKeyRelatedField( + allow_null=True, + queryset=Image.objects.all(), + required=False, + ) music_focus = serializers.PrimaryKeyRelatedField(queryset=MusicFocus.objects.all(), many=True) owners = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), many=True) predecessor = serializers.PrimaryKeyRelatedField( @@ -629,11 +674,14 @@ class ScheduleDryRunResponseSerializer(serializers.Serializer): class TimeSlotSerializer(serializers.ModelSerializer): show = serializers.PrimaryKeyRelatedField(queryset=Show.objects.all()) schedule = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all()) - repetition_of = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all()) + repetition_of = serializers.PrimaryKeyRelatedField( + queryset=TimeSlot.objects.all(), allow_null=True + ) class Meta: model = TimeSlot fields = ( + "id", "end", "memo", "note_id", @@ -667,13 +715,16 @@ class NoteLinkSerializer(serializers.ModelSerializer): class NoteSerializer(serializers.ModelSerializer): contributors = serializers.PrimaryKeyRelatedField(queryset=Host.objects.all(), many=True) - image = serializers.PrimaryKeyRelatedField(queryset=Image.objects.all(), required=False) + image = serializers.PrimaryKeyRelatedField( + queryset=Image.objects.all(), required=False, allow_null=True + ) links = NoteLinkSerializer(many=True, required=False) - timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all()) + timeslot = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), required=False) class Meta: model = Note read_only_fields = ( + "id", "created_at", "created_by", "updated_at", @@ -683,6 +734,7 @@ class NoteSerializer(serializers.ModelSerializer): "cba_id", "content", "contributors", + "id", "image", "links", "owner", @@ -701,9 +753,10 @@ class NoteSerializer(serializers.ModelSerializer): contributors = validated_data.pop("contributors", []) # the creator of the note is the owner - validated_data["owner"] = self.context["user_id"] - - note = Note.objects.create(**validated_data | self.context) # created_by + validated_data["owner"] = self.context["request"].user + note = Note.objects.create( + created_by=self.context["request"].user.username, **validated_data + ) note.contributors.set(contributors) @@ -733,7 +786,6 @@ class NoteSerializer(serializers.ModelSerializer): instance.cba_id = validated_data.get("cba_id", instance.cba_id) instance.content = validated_data.get("content", instance.content) instance.image = validated_data.get("image", instance.image) - instance.show = validated_data.get("show", instance.show) instance.slug = validated_data.get("slug", instance.slug) instance.summary = validated_data.get("summary", instance.summary) instance.timeslot = validated_data.get("timeslot", instance.timeslot) @@ -751,7 +803,7 @@ class NoteSerializer(serializers.ModelSerializer): for link_data in links_data: NoteLink.objects.create(note=instance, **link_data) - instance.updated_by = self.context.get("updated_by") + instance.updated_by = self.context.get("request").user.username instance.save() diff --git a/program/tests/__init__.py b/program/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..eeef18a2d317dfa39aea5752ac257bc20d0e04bd --- /dev/null +++ b/program/tests/__init__.py @@ -0,0 +1,100 @@ +import datetime + +from django.contrib.auth.models import User +from django.utils.text import slugify +from django.utils.timezone import now +from program.models import Note, RRule, Schedule, Show, TimeSlot + + +class SteeringTestCaseMixin: + base_url = "/api/v1" + + def _url(self, *paths, **kwargs): + url = "/".join(str(p) for p in paths) + "/" + return f"{self.base_url}/{url.format(**kwargs)}" + + def _get_client(self, user=None): + client = self.client_class() + if user: + client.force_authenticate(user=user) + return client + + +class UserMixin: + user_admin: User + user_common: User + + def setUp(self): + self.user_admin = User.objects.create_superuser( + "admin", "admin@aura.radio", password="admin" + ) + self.user_common = User.objects.create_user( + "herbert", "herbert@aura.radio", password="herbert" + ) + + +class ShowMixin: + def _create_show(self, name: str, **kwargs): + kwargs["name"] = name + kwargs.setdefault("slug", slugify(name)) + kwargs.setdefault("short_description", f"The {name} show") + owners = kwargs.pop("owners", []) + show = Show.objects.create(**kwargs) + if owners: + show.owners.set(owners) + return show + + +class ScheduleMixin: + def _get_rrule(self): + rrule = RRule.objects.first() + if rrule is None: + rrule = RRule.objects.create(name="once", freq=0) + return rrule + + def _create_schedule(self, show: Show, **kwargs): + _first_date = kwargs.get("first_date", now().date()) + kwargs["show"] = show + kwargs.setdefault("first_date", _first_date) + kwargs.setdefault("start_time", "08:00") + kwargs.setdefault("last_date", _first_date + datetime.timedelta(days=365)) + kwargs.setdefault("end_time", "09:00") + kwargs.setdefault("rrule", self._get_rrule()) + return Schedule.objects.create(**kwargs) + + +class TimeSlotMixin: + def _create_timeslot(self, schedule: Schedule, **kwargs): + _start = kwargs.get("start", now()) + kwargs.setdefault("schedule", schedule) + kwargs.setdefault("show", schedule.show) + kwargs.setdefault("start", _start) + kwargs.setdefault("end", _start + datetime.timedelta(hours=1)) + return TimeSlot.objects.create(**kwargs) + + +class NoteMixin: + def _create_note(self, timeslot: TimeSlot, **kwargs): + note_count = Note.objects.all().count() + _title = kwargs.get("title", f"a random note #{note_count}") + kwargs["timeslot"] = timeslot + kwargs["title"] = _title + kwargs.setdefault("slug", slugify(_title)) + return Note.objects.create(**kwargs) + + def _create_random_note_content(self, **kwargs): + note_count = Note.objects.all().count() + _title = kwargs.get("title", f"a random note #{note_count}") + kwargs["title"] = _title + kwargs.setdefault("slug", slugify(_title)) + kwargs.setdefault("content", "some random content") + kwargs.setdefault("contributors", []) + return kwargs + + +class ProgramModelMixin(ShowMixin, ScheduleMixin, TimeSlotMixin, NoteMixin): + pass + + +class BaseMixin(UserMixin, ProgramModelMixin, SteeringTestCaseMixin): + pass diff --git a/program/tests/test_notes.py b/program/tests/test_notes.py new file mode 100644 index 0000000000000000000000000000000000000000..d21fdadcaabcc7bca81efd6da49eda400df4e325 --- /dev/null +++ b/program/tests/test_notes.py @@ -0,0 +1,124 @@ +from rest_framework.test import APITransactionTestCase + +from program import tests +from program.models import Schedule, Show + + +class NoteViewTestCase(tests.BaseMixin, APITransactionTestCase): + reset_sequences = True + + show_beatbetrieb: Show + schedule_beatbetrieb: Schedule + show_musikrotation: Show + schedule_musikrotation: Schedule + + def setUp(self) -> None: + super().setUp() + self.show_beatbetrieb = self._create_show("Beatbetrieb") + self.schedule_beatbetrieb = self._create_schedule(self.show_beatbetrieb) + self.show_musikrotation = self._create_show("Musikrotation", owners=[self.user_common]) + self.schedule_musikrotation = self._create_schedule( + self.show_musikrotation, start_time="10:00", end_time="12:00" + ) + + def test_everyone_can_read_notes(self): + self._create_note(self._create_timeslot(schedule=self.schedule_beatbetrieb)) + self._create_note(self._create_timeslot(schedule=self.schedule_musikrotation)) + res = self._get_client().get(self._url("notes")) + self.assertEqual(len(res.data), 2) + + def test_common_users_can_create_notes_for_owned_shows(self): + ts = self._create_timeslot(schedule=self.schedule_musikrotation) + client = self._get_client(self.user_common) + endpoint = self._url("notes") + res = client.post( + endpoint, self._create_random_note_content(timeslot=ts.id), format="json" + ) + self.assertEqual(res.status_code, 201) + + def test_common_users_cannot_create_notes_for_foreign_shows(self): + ts = self._create_timeslot(schedule=self.schedule_beatbetrieb) + client = self._get_client(self.user_common) + endpoint = self._url("notes") + res = client.post( + endpoint, self._create_random_note_content(timeslot=ts.id), format="json" + ) + self.assertEqual(res.status_code, 404) + + def test_common_user_can_update_owned_shows(self): + ts = self._create_timeslot(schedule=self.schedule_musikrotation) + note = self._create_note(ts) + client = self._get_client(self.user_common) + new_note_content = self._create_random_note_content(title="meh") + res = client.put(self._url("notes", note.id), new_note_content, format="json") + self.assertEqual(res.status_code, 200) + + def test_common_user_cannot_update_notes_of_foreign_shows(self): + ts = self._create_timeslot(schedule=self.schedule_beatbetrieb) + note = self._create_note(ts) + client = self._get_client(self.user_common) + new_note_content = self._create_random_note_content(title="meh") + res = client.put(self._url("notes", note.id), new_note_content, format="json") + self.assertEqual(res.status_code, 404) + + def test_admin_can_create_notes_for_all_timeslots(self): + timeslot = self._create_timeslot(schedule=self.schedule_musikrotation) + client = self._get_client(self.user_admin) + res = client.post( + self._url("notes"), + self._create_random_note_content(timeslot=timeslot.id), + format="json", + ) + self.assertEqual(res.status_code, 201) + + def test_notes_can_be_created_through_nested_routes(self): + client = self._get_client(self.user_admin) + + # /shows/{pk}/notes/ + ts1 = self._create_timeslot(schedule=self.schedule_musikrotation) + url = self._url("shows", self.show_musikrotation.id, "notes") + note = self._create_random_note_content(title="meh", timeslot=ts1.id) + res = client.post(url, note, format="json") + self.assertEqual(res.status_code, 201) + + # /shows/{pk}/timeslots/{pk}/note/ + ts2 = self._create_timeslot(schedule=self.schedule_musikrotation) + url = self._url("shows", self.show_musikrotation, "timeslots", ts2.id, "note") + note = self._create_random_note_content(title="cool") + res = client.post(url, note, format="json") + self.assertEqual(res.status_code, 201) + + def test_notes_can_be_filtered_through_nested_routes_and_query_params(self): + client = self._get_client() + + ts1 = self._create_timeslot(schedule=self.schedule_musikrotation) + ts2 = self._create_timeslot(schedule=self.schedule_beatbetrieb) + ts3 = self._create_timeslot(schedule=self.schedule_beatbetrieb) + n1 = self._create_note(timeslot=ts1) + n2 = self._create_note(timeslot=ts2) + n3 = self._create_note(timeslot=ts3) + + def _get_ids(res): + return set(ts["id"] for ts in res.data) + + # /shows/{pk}/notes/ + query_res = client.get(self._url("notes") + f"?show={self.show_beatbetrieb.id}") + route_res = client.get(self._url("shows", self.show_beatbetrieb.id, "notes")) + ids = {n2.id, n3.id} + self.assertEqual(_get_ids(query_res), ids) + self.assertEqual(_get_ids(route_res), ids) + + query_res = client.get(self._url("notes") + f"?show={self.show_musikrotation.id}") + route_res = client.get(self._url("shows", self.show_musikrotation.id, "notes")) + ids = {n1.id} + self.assertEqual(_get_ids(query_res), ids) + self.assertEqual(_get_ids(route_res), ids) + + # /shows/{pk}/timeslots/{pk}/note/ + query_res = client.get(self._url("notes") + f"?timeslot={ts2.id}") + route_res = client.get( + self._url("shows", self.show_beatbetrieb.id, "timeslots", ts2.id, "note") + ) + ids = {n2.id} + self.assertEqual(_get_ids(query_res), ids) + self.assertEqual(_get_ids(route_res), ids) diff --git a/program/views.py b/program/views.py index 7e07607ecba358d15cb288394c0f2c83709b3fd0..441f8e955a8739753a98b4aaa851452aa6c4818f 100644 --- a/program/views.py +++ b/program/views.py @@ -26,12 +26,13 @@ from textwrap import dedent from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from rest_framework import mixins, permissions, status, viewsets +from rest_framework.exceptions import ValidationError from rest_framework.pagination import LimitOffsetPagination from rest_framework.response import Response from django.contrib.auth.models import User from django.core.exceptions import FieldError -from django.http import HttpResponse +from django.http import Http404, HttpResponse from django.shortcuts import get_list_or_404, get_object_or_404 from django.utils import timezone from django.utils.translation import gettext as _ @@ -773,87 +774,45 @@ class APINoteViewSet( viewsets.ModelViewSet, ): ROUTE_FILTER_LOOKUPS = { - "show_pk": "show", + "show_pk": "timeslot__show", "timeslot_pk": "timeslot", } - queryset = Note.objects.all() serializer_class = NoteSerializer - permission_classes = [permissions.DjangoModelPermissionsOrAnonReadOnly] + permission_classes = [permissions.IsAuthenticatedOrReadOnly] pagination_class = LimitOffsetPagination - filter_class = filters.NoteFilterSet - - def create(self, request, *args, **kwargs): - """ - Only admins can create new notes. - """ - show_pk, timeslot_pk = get_values(self.kwargs, "show_pk", "timeslot_pk") - - if not request.user.is_superuser and show_pk not in request.user.shows.values_list( - "id", flat=True - ): - return Response(status=status.HTTP_401_UNAUTHORIZED) - - serializer = NoteSerializer( - data={"show": show_pk, "timeslot": timeslot_pk} | request.data, - context={"user_id": request.user.id, "created_by": request.user.username}, - ) - - if serializer.is_valid(): - hosts = Host.objects.filter(shows__in=request.user.shows.values_list("id", flat=True)) - if not request.user.is_superuser and request.data["host"] not in hosts: - serializer.validated_data["host"] = None - - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def update(self, request, *args, **kwargs): - """ - Only admins can update existing notes. - """ - show_pk = get_values(self.kwargs, "show_pk") - - if not request.user.is_superuser and show_pk not in request.user.shows.values_list( - "id", flat=True - ): - return Response(status=status.HTTP_401_UNAUTHORIZED) - - note = self.get_object() - serializer = NoteSerializer( - note, data=request.data, context={"updated_by": request.user.username} - ) - - if serializer.is_valid(): - hosts = Host.objects.filter(shows__in=request.user.shows.values_list("id", flat=True)) - # Don't assign a host the user mustn't edit. Reassign the original value instead - if not request.user.is_superuser and int(request.data["host"]) not in hosts: - serializer.validated_data["host"] = Host.objects.filter(pk=note.host_id)[0] - - serializer.save() - return Response(serializer.data) - - return Response(status=status.HTTP_400_BAD_REQUEST) - - def partial_update(self, request, *args, **kwargs): - kwargs["partial"] = True - return self.update(request, *args, **kwargs) - - def destroy(self, request, *args, **kwargs): - """ - Only admins can delete existing notes. - """ - show_pk = get_values(self.kwargs, "show_pk") - - if not request.user.is_superuser and show_pk not in request.user.shows.values_list( - "id", flat=True - ): - return Response(status=status.HTTP_401_UNAUTHORIZED) - - self.get_object().delete() + filterset_class = filters.NoteFilterSet - return Response(status=status.HTTP_204_NO_CONTENT) + def get_queryset(self): + qs = super().get_queryset().order_by("slug") + # Users should always be able to see notes + if self.request.method not in permissions.SAFE_METHODS: + # If the request is not by an admin, + # check that the timeslot is owned by the current user. + if not self.request.user.is_superuser: + qs = qs.filter(timeslot__show__owners=self.request.user) + return qs + + def _get_timeslot(self): + # TODO: Once we remove nested routes, timeslot ownership + # should be checked in a permission class. + timeslot_pk = self.request.data.get("timeslot", None) + if timeslot_pk is None: + timeslot_pk = get_values(self.kwargs, "timeslot_pk") + if timeslot_pk is None: + raise ValidationError({"timeslot": [_("This field is required.")]}, code="required") + qs = TimeSlot.objects.all() + if not self.request.user.is_superuser: + qs = qs.filter(show__owners=self.request.user) + try: + return qs.get(pk=timeslot_pk) + except TimeSlot.DoesNotExist: + raise Http404() + + def perform_create(self, serializer): + # TODO: Once we remove nested routes, this should be removed + # and timeslot should be required in the serializer again. + serializer.save(timeslot=self._get_timeslot()) class ActiveFilterMixin: diff --git a/steering/urls.py b/steering/urls.py index 46d0e99c8710b23fef890ea9a6688ac93f4487d5..70df93704acabc6716f9789ba03adba5a8f0155e 100644 --- a/steering/urls.py +++ b/steering/urls.py @@ -58,11 +58,11 @@ router.register(r"notes", APINoteViewSet) router.register(r"categories", APICategoryViewSet) router.register(r"topics", APITopicViewSet) router.register(r"types", APITypeViewSet) -router.register(r"music_focus", APIMusicFocusViewSet) -router.register(r"funding_categories", APIFundingCategoryViewSet) +router.register(r"music-focus", APIMusicFocusViewSet) +router.register(r"funding-categories", APIFundingCategoryViewSet) router.register(r"languages", APILanguageViewSet) -router.register(r"license_types", APILicenseTypeViewSet) -router.register(r"link_types", APILinkTypeViewSet) +router.register(r"license-types", APILicenseTypeViewSet) +router.register(r"link-types", APILinkTypeViewSet) router.register(r"rrules", APIRRuleViewSet) router.register(r"images", APIImageViewSet)