# # steering, Programme/schedule management for AURA # # Copyright (C) 2011-2017, 2020, Ernesto Rico Schmidt # Copyright (C) 2017-2019, Ingo Leindecker # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU Affero General Public License as published by the Free # Software Foundation, either version 3 of the License, or (at your option) any # later version. # # This program is distributed in the hope that it will be useful, but WITHOUT # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # import re from typing import List, TypedDict from rest_framework import serializers from django.contrib.auth.models import User from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from program.models import ( Category, FundingCategory, Host, HostLink, Image, Language, License, LinkType, MusicFocus, Note, NoteLink, RRule, Schedule, Show, ShowLink, TimeSlot, Topic, Type, UserProfile, ) from program.utils import delete_links, get_audio_url from steering.settings import THUMBNAIL_SIZES SOLUTION_CHOICES = { "theirs": "Discard projected timeslot. Keep existing timeslot(s).", "ours": "Create projected timeslot. Delete existing timeslot(s).", "theirs-start": ( "Keep existing timeslot. Create projected timeslot with start time of existing end." ), "ours-start": ( "Create projected timeslot. Change end of existing timeslot to projected start time." ), "theirs-end": ( "Keep existing timeslot. Create projected timeslot with end of existing start time." ), "ours-end": ( "Create projected timeslot. Change start of existing timeslot to projected end time." ), "theirs-both": ( "Keep existing timeslot. " "Create two projected timeslots with end of existing start and start of existing end." ), "ours-both": ( "Create projected timeslot. Split existing timeslot into two: \n\n" "* set existing end time to projected start,\n" "* create another timeslot with start = projected end and end = existing end." ), } class ErrorSerializer(serializers.Serializer): message = serializers.CharField() code = serializers.CharField(allow_null=True) class ProfileSerializer(serializers.ModelSerializer): class Meta: model = UserProfile fields = ( "cba_username", "cba_user_token", "created_at", "created_by", "updated_at", "updated_by", ) read_only_fields = ( "created_at", "created_by", "updated_at", "updated_by", ) class UserSerializer(serializers.ModelSerializer): # Add profile fields to JSON profile = ProfileSerializer(required=False) class Meta: model = User fields = ( "email", "first_name", "id", "is_active", "is_staff", "is_superuser", "last_name", "password", "profile", "username", ) def create(self, validated_data): """ Create and return a new User instance, given the validated data. """ profile_data = validated_data.pop("profile") if "profile" in validated_data else None user = super(UserSerializer, self).create(validated_data) user.date_joined = timezone.now() user.set_password(validated_data["password"]) user.save() if profile_data: profile = UserProfile( cba_username=profile_data.get("cba_username").strip(), cba_user_token=profile_data.get("cba_user_token").strip(), created_by=self.context.get("request").user.username, user=user, ) profile.save() return user def update(self, instance, validated_data): """ Update and return an existing User instance, given the validated data. """ instance.first_name = validated_data.get("first_name", instance.first_name) instance.last_name = validated_data.get("last_name", instance.last_name) instance.email = validated_data.get("email", instance.email) instance.is_active = validated_data.get("is_active", instance.is_active) instance.is_staff = validated_data.get("is_staff", instance.is_staff) instance.is_superuser = validated_data.get("is_superuser", instance.is_superuser) profile_data = validated_data.pop("profile") if "profile" in validated_data else None if profile_data: # TODO: How to hook into this from ProfileSerializer without having to call it here? try: profile = UserProfile.objects.get(user=instance.id) except ObjectDoesNotExist: profile = UserProfile.objects.create(user=instance, **profile_data) profile.cba_username = profile_data.get("cba_username") profile.cba_user_token = profile_data.get("cba_user_token") profile.updated_by = self.context.get("request").user.username profile.save() instance.save() return instance class CategorySerializer(serializers.ModelSerializer): class Meta: model = Category fields = ("description", "id", "is_active", "name", "slug", "subtitle") class LinkTypeSerializer(serializers.ModelSerializer): class Meta: model = LinkType fields = ("name", "type") class LicenseSerializer(serializers.ModelSerializer): class Meta: model = License fields = ("id", "name", "identifier") class HostLinkSerializer(serializers.ModelSerializer): class Meta: model = HostLink 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): license_id = serializers.PrimaryKeyRelatedField( allow_null=True, queryset=License.objects.all(), required=False, source="license" ) ppoi = PPOIField(required=False) thumbnails = serializers.SerializerMethodField() @staticmethod def get_thumbnails(instance) -> List[Thumbnail]: """Returns thumbnails""" thumbnails = [] if instance.image.name and THUMBNAIL_SIZES: for size in THUMBNAIL_SIZES: [width, height] = size.split("x") thumbnails.append( { "width": int(width), "height": int(height), "url": instance.image.crop[size].url, } ) return thumbnails class Meta: model = Image read_only_fields = ( "height", "id", "thumbnails", "width", ) fields = ( "alt_text", "credits", "image", "license_id", "ppoi", ) + read_only_fields def create(self, validated_data): """Create and return a new Image instance, given the validated data.""" image = Image.objects.create( owner=self.context.get("request").user.username, **validated_data ) image.save() return image def update(self, instance, validated_data): """Update and return an existing Image instance, given the validated data.""" # 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.license = validated_data.get("license", instance.license) instance.save() return instance class HostSerializer(serializers.ModelSerializer): image_id = serializers.PrimaryKeyRelatedField( allow_null=True, queryset=Image.objects.all(), required=False, source="image" ) links = HostLinkSerializer(many=True, required=False) class Meta: model = Host read_only_fields = ( "created_at", "created_by", "updated_at", "updated_by", ) fields = ( "biography", "email", "id", "image_id", "is_active", "links", "name", ) + read_only_fields def create(self, validated_data): """ Create and return a new Host instance, given the validated data. """ links_data = validated_data.pop("links", []) validated_data["image"] = validated_data.pop("image_id", None) host = Host.objects.create( created_by=self.context.get("request").user.username, **validated_data ) for link_data in links_data: HostLink.objects.create(host=host, **link_data) host.save() return host def update(self, instance, validated_data): """ Update and return an existing Host instance, given the validated data. """ instance.biography = validated_data.get("biography", instance.biography) instance.email = validated_data.get("email", instance.email) instance.image = validated_data.get("image_id", instance.image) instance.is_active = validated_data.get("is_active", instance.is_active) instance.name = validated_data.get("name", instance.name) if links_data := validated_data.get("links"): instance = delete_links(instance) for link_data in links_data: HostLink.objects.create(host=instance, **link_data) instance.updated_by = self.context.get("request").user.username instance.save() return instance class LanguageSerializer(serializers.ModelSerializer): class Meta: model = Language fields = ("id", "is_active", "name") class TopicSerializer(serializers.ModelSerializer): class Meta: model = Topic fields = ("id", "is_active", "name", "slug") class MusicFocusSerializer(serializers.ModelSerializer): class Meta: model = MusicFocus fields = ("id", "is_active", "name", "slug") class TypeSerializer(serializers.ModelSerializer): class Meta: model = Type fields = ("id", "is_active", "name", "slug") class FundingCategorySerializer(serializers.ModelSerializer): class Meta: model = FundingCategory fields = ("id", "is_active", "name", "slug") class ShowLinkSerializer(serializers.ModelSerializer): class Meta: model = ShowLink fields = ("type", "url") class ShowSerializer(serializers.HyperlinkedModelSerializer): category_ids = serializers.PrimaryKeyRelatedField( many=True, queryset=Category.objects.all(), source="category" ) funding_category_id = serializers.PrimaryKeyRelatedField( queryset=FundingCategory.objects.all() ) host_ids = serializers.PrimaryKeyRelatedField( many=True, queryset=Host.objects.all(), source="hosts" ) image_id = serializers.PrimaryKeyRelatedField( allow_null=True, queryset=Image.objects.all(), required=False ) language_ids = serializers.PrimaryKeyRelatedField( many=True, queryset=Language.objects.all(), source="language" ) links = HostLinkSerializer(many=True, required=False) logo_id = serializers.PrimaryKeyRelatedField( allow_null=True, queryset=Image.objects.all(), required=False ) music_focus_ids = serializers.PrimaryKeyRelatedField( many=True, queryset=MusicFocus.objects.all(), source="music_focus" ) owner_ids = serializers.PrimaryKeyRelatedField( many=True, queryset=User.objects.all(), source="owners" ) predecessor_id = serializers.PrimaryKeyRelatedField( allow_null=True, queryset=Show.objects.all(), required=False ) topic_ids = serializers.PrimaryKeyRelatedField( many=True, queryset=Topic.objects.all(), source="topic" ) type_id = serializers.PrimaryKeyRelatedField(queryset=Type.objects.all()) class Meta: model = Show read_only_fields = ( "created_at", "created_by", "updated_at", "updated_by", ) fields = ( "category_ids", "cba_series_id", "default_playlist_id", "description", "email", "funding_category_id", "host_ids", "id", "image_id", "internal_note", "is_active", "is_public", "language_ids", "links", "logo_id", "music_focus_ids", "name", "owner_ids", "predecessor_id", "short_description", "slug", "topic_ids", "type_id", ) + read_only_fields def create(self, validated_data): """ Create and return a new Show instance, given the validated data. """ owners = validated_data.pop("owners") category = validated_data.pop("category") hosts = validated_data.pop("hosts") language = validated_data.pop("language") topic = validated_data.pop("topic") music_focus = validated_data.pop("music_focus") links_data = validated_data.pop("links", []) # required validated_data["funding_category"] = validated_data.pop("funding_category_id") validated_data["type"] = validated_data.pop("type_id") # optional validated_data["image"] = validated_data.pop("image_id", None) validated_data["logo"] = validated_data.pop("logo_id", None) validated_data["predecessor"] = validated_data.pop("predecessor_id", None) show = Show.objects.create( created_by=self.context.get("request").user.username, **validated_data, ) # Save many-to-many relationships show.owners.set(owners) show.category.set(category) show.hosts.set(hosts) show.language.set(language) show.topic.set(topic) show.music_focus.set(music_focus) for link_data in links_data: ShowLink.objects.create(show=show, **link_data) show.save() return show def update(self, instance, validated_data): """ Update and return an existing Show instance, given the validated data. """ instance.cba_series_id = validated_data.get("cba_series_id", instance.cba_series_id) instance.default_playlist_id = validated_data.get( "default_playlist_id", instance.default_playlist_id ) instance.description = validated_data.get("description", instance.description) instance.email = validated_data.get("email", instance.email) instance.funding_category = validated_data.get( "funding_category_id", instance.funding_category ) instance.image = validated_data.get("image_id", instance.image) instance.internal_note = validated_data.get("internal_note", instance.internal_note) instance.is_active = validated_data.get("is_active", instance.is_active) instance.is_public = validated_data.get("is_public", instance.is_public) instance.logo = validated_data.get("logo_id", instance.logo) instance.name = validated_data.get("name", instance.name) instance.predecessor = validated_data.get("predecessor_id", instance.predecessor) instance.short_description = validated_data.get( "short_description", instance.short_description ) instance.slug = validated_data.get("slug", instance.slug) instance.type = validated_data.get("type_id", instance.type) instance.category.set(validated_data.get("category", instance.category)) instance.hosts.set(validated_data.get("hosts", instance.hosts)) instance.language.set(validated_data.get("language", instance.language)) instance.music_focus.set(validated_data.get("music_focus", instance.music_focus)) instance.owners.set(validated_data.get("owners", instance.owners)) instance.topic.set(validated_data.get("topic", instance.topic)) if links_data := validated_data.get("links"): instance = delete_links(instance) for link_data in links_data: ShowLink.objects.create(show=instance, **link_data) instance.updated_by = self.context.get("request").user.username instance.save() return instance class RRuleSerializer(serializers.ModelSerializer): class Meta: model = RRule fields = ( "id", "name", ) read_only_fields = fields class ScheduleSerializer(serializers.ModelSerializer): class Meta: model = Schedule fields = ( "add_business_days_only", "add_days_no", "by_weekday", "default_playlist_id", "end_time", "first_date", "id", "is_repetition", "last_date", "rrule_id", "show_id", "start_time", ) class UnsavedScheduleSerializer(ScheduleSerializer): id = serializers.IntegerField(allow_null=True) class ScheduleInRequestSerializer(ScheduleSerializer): dryrun = serializers.BooleanField( write_only=True, required=False, help_text=( "Whether to simulate the database changes. If true, no database changes will occur. " "Instead a list of objects that would be created, updated and deleted if dryrun was " "false will be returned." ), ) class Meta: model = Schedule fields = ( "add_business_days_only", "add_days_no", "by_weekday", "default_playlist_id", "dryrun", "end_time", "first_date", "is_repetition", "last_date", "rrule_id", "show_id", "start_time", ) def create(self, validated_data): """Create and return a new Schedule instance, given the validated data.""" validated_data["rrule"] = validated_data.pop("rrule_id") validated_data["show"] = validated_data.pop("show_id") schedule = Schedule.objects.create(**validated_data) schedule.save() return schedule def update(self, instance, validated_data): """Update and return an existing Schedule instance, given the validated data.""" instance.by_weekday = validated_data.get("by_weekday", instance.by_weekday) instance.first_date = validated_data.get("first_date", instance.first_date) instance.start_time = validated_data.get("start_time", instance.start_time) 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.rrule = validated_data.get("rrule_id", instance.rrule) instance.show = validated_data.get("show_id", instance.show) instance.add_days_no = validated_data.get("add_days_no", instance.add_days_no) instance.add_business_days_only = validated_data.get( "add_business_days_only", instance.add_business_days_only ) instance.save() return instance class CollisionSerializer(serializers.Serializer): id = serializers.IntegerField() start = serializers.DateTimeField() end = serializers.DateTimeField() playlist_id = serializers.IntegerField(allow_null=True) show_id = serializers.IntegerField() show_name = serializers.CharField() repetition_of_id = serializers.IntegerField(allow_null=True) schedule_id = serializers.IntegerField() memo = serializers.CharField() note_id = serializers.IntegerField(allow_null=True) class ProjectedTimeSlotSerializer(serializers.Serializer): hash = serializers.CharField() start = serializers.DateTimeField() end = serializers.DateTimeField() collisions = CollisionSerializer(many=True) error = serializers.CharField(allow_null=True) solution_choices = serializers.ListField(child=serializers.ChoiceField(SOLUTION_CHOICES)) class DryRunTimeSlotSerializer(serializers.Serializer): id = serializers.PrimaryKeyRelatedField(queryset=TimeSlot.objects.all(), allow_null=True) schedule_id = serializers.PrimaryKeyRelatedField( queryset=Schedule.objects.all(), allow_null=True ) playlist_id = serializers.IntegerField(allow_null=True) start = serializers.DateField() end = serializers.DateField() repetition_of_id = serializers.IntegerField(allow_null=True) memo = serializers.CharField() class ScheduleCreateUpdateRequestSerializer(serializers.Serializer): schedule = ScheduleInRequestSerializer() solutions = serializers.DictField( child=serializers.ChoiceField(SOLUTION_CHOICES), required=False ) notes = serializers.DictField(child=serializers.IntegerField(), required=False) playlists = serializers.DictField(child=serializers.IntegerField(), required=False) class ScheduleResponseSerializer(serializers.Serializer): projected = ProjectedTimeSlotSerializer(many=True) solutions = serializers.DictField(child=serializers.ChoiceField(SOLUTION_CHOICES)) notes = serializers.DictField(child=serializers.IntegerField()) playlists = serializers.DictField(child=serializers.IntegerField()) schedule = ScheduleSerializer() class ScheduleConflictResponseSerializer(ScheduleResponseSerializer): schedule = UnsavedScheduleSerializer() class ScheduleDryRunResponseSerializer(serializers.Serializer): created = DryRunTimeSlotSerializer(many=True) updated = DryRunTimeSlotSerializer(many=True) deleted = DryRunTimeSlotSerializer(many=True) class TimeSlotSerializer(serializers.ModelSerializer): note_id = serializers.SerializerMethodField() show_id = serializers.SerializerMethodField() schedule_id = serializers.PrimaryKeyRelatedField( queryset=Schedule.objects.all(), required=False ) repetition_of_id = serializers.PrimaryKeyRelatedField( allow_null=True, queryset=TimeSlot.objects.all(), required=False, ) class Meta: model = TimeSlot read_only_fields = ( "end", "id", "note_id", "schedule_id", "show_id", "start", ) fields = ( "memo", "playlist_id", "repetition_of_id", ) + read_only_fields @staticmethod def get_show_id(obj): return obj.schedule.show.id @staticmethod def get_note_id(obj): return obj.note.id if hasattr(obj, "note") else None def update(self, instance, validated_data): """Update and return an existing Show instance, given the validated data.""" # Only save certain fields instance.memo = validated_data.get("memo", instance.memo) instance.repetition_of = validated_data.get("repetition_of_id", instance.repetition_of) instance.playlist_id = validated_data.get("playlist_id", instance.playlist_id) instance.save() return instance class NoteLinkSerializer(serializers.ModelSerializer): class Meta: model = NoteLink fields = ("type", "url") class NoteSerializer(serializers.ModelSerializer): contributor_ids = serializers.PrimaryKeyRelatedField( many=True, queryset=Host.objects.all(), source="contributors" ) image_id = serializers.PrimaryKeyRelatedField( queryset=Image.objects.all(), required=False, allow_null=True ) links = NoteLinkSerializer(many=True, required=False) playlist_id = serializers.IntegerField(required=False) timeslot_id = serializers.PrimaryKeyRelatedField( queryset=TimeSlot.objects.all(), required=False ) class Meta: model = Note read_only_fields = ( "id", "created_at", "created_by", "updated_at", "updated_by", ) fields = ( "cba_id", "content", "contributor_ids", "id", "image_id", "links", "owner_id", "playlist_id", "slug", "summary", "tags", "timeslot_id", "title", ) + read_only_fields def create(self, validated_data): """Create and return a new Note instance, given the validated data.""" links_data = validated_data.pop("links", []) contributors = validated_data.pop("contributors", []) # required if "timeslot_id" in validated_data: validated_data["timeslot"] = validated_data.pop("timeslot_id") else: # TODO: Once we remove nested routes, this hack should be removed timeslot_pk = TimeSlot.objects.get(pk=self.context["request"].path.split("/")[-3]) validated_data["timeslot"] = validated_data.pop("timeslot_id", timeslot_pk) # optional validated_data["image"] = validated_data.pop("image_id", None) # the creator of the note is the owner note = Note.objects.create( created_by=self.context.get("request").user.username, owner=self.context.get("request").user, **validated_data, ) note.contributors.set(contributors) if cba_id := validated_data.get("cba_id"): if audio_url := get_audio_url(cba_id): NoteLink.objects.create(note=note, type="CBA", url=audio_url) for link_data in links_data: NoteLink.objects.create(note=note, **link_data) note.save() return note def update(self, instance, validated_data): """Update and return an existing Note instance, given the validated data.""" 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_id", instance.image) instance.slug = validated_data.get("slug", instance.slug) instance.summary = validated_data.get("summary", instance.summary) instance.tags = validated_data.get("tags", instance.tags) instance.timeslot = validated_data.get("timeslot_id", instance.timeslot) instance.title = validated_data.get("title", instance.title) instance.contributors.set(validated_data.get("contributors", instance.contributors)) if cba_id := validated_data.get("cba_id"): if audio_url := get_audio_url(cba_id): NoteLink.objects.create(note=instance, type="CBA", url=audio_url) if links_data := validated_data.get("links"): instance = delete_links(instance) for link_data in links_data: NoteLink.objects.create(note=instance, **link_data) instance.updated_by = self.context.get("request").user.username instance.save() return instance