Skip to content
Snippets Groups Projects
models.py 25.4 KiB
Newer Older
  • Learn to ignore specific revisions
  • #
    # 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 dataclasses
    import datetime
    
    
    from rest_framework.exceptions import ValidationError
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    from versatileimagefield.fields import PPOIField, VersatileImageField
    
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    from django.contrib.auth.models import User
    
    from django.core.exceptions import ValidationError as DjangoValidationError
    
    from django.core.validators import RegexValidator
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    from django.db import models
    
    from django.utils import timezone
    
    from django.utils.translation import gettext_lazy as _
    
    class ScheduleConflictError(ValidationError):
        def __init__(self, *args, conflicts=None, **kwargs):
            super().__init__(*args, **kwargs)
            self.conflicts = conflicts
    
    
    
        is_active = models.BooleanField(default=True, help_text="True if type is active.")
        name = models.CharField(max_length=32, help_text="Name of the type.")
        slug = models.SlugField(max_length=32, unique=True, help_text="Slug of the type.")
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("name",)
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            return self.name
    
    
        description = models.TextField(blank=True, help_text="Description of the category.")
        is_active = models.BooleanField(default=True, help_text="True if category is active.")
        name = models.CharField(max_length=32, help_text="Name of the category.")
        slug = models.SlugField(max_length=32, unique=True, help_text="Slug of the category.")
        subtitle = models.CharField(blank=True, max_length=32, help_text="Subtitle of the category.")
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("name",)
    
            verbose_name_plural = "Categories"
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            return self.name
    
    
        is_active = models.BooleanField(default=True, help_text="True if topic is active.")
        name = models.CharField(max_length=32, help_text="Name of the topic.")
        slug = models.SlugField(max_length=32, unique=True, help_text="Slug of the topic.")
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("name",)
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            return self.name
    
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    class MusicFocus(models.Model):
    
        is_active = models.BooleanField(default=True, help_text="True if music focus is active.")
        name = models.CharField(max_length=32, help_text="Name of the music focus.")
        slug = models.SlugField(max_length=32, unique=True, help_text="Slug of the music focus.")
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("name",)
    
            verbose_name_plural = "Music Focus"
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            return self.name
    
    
    class FundingCategory(models.Model):
    
        is_active = models.BooleanField(default=True, help_text="True if funding category is active.")
        name = models.CharField(max_length=32, help_text="Name of the funding category.")
        slug = models.SlugField(max_length=32, unique=True, help_text="Slug of the funding category.")
    
            ordering = ("name",)
    
            verbose_name_plural = "Funding Categories"
    
        def __str__(self):
            return self.name
    
    
    class Language(models.Model):
    
        is_active = models.BooleanField(default=True, help_text="True if language is active.")
        name = models.CharField(max_length=32, help_text="Name of the language.")
    
    
        class Meta:
    
        def __str__(self):
            return self.name
    
    
    class License(models.Model):
        identifier = models.CharField(max_length=32, help_text="Identifier of the license")
    
        name = models.CharField(max_length=64, help_text="Name of the license")
    
        needs_author = models.BooleanField(default=True, help_text="True if license needs an author.")
        requires_express_permission_for_publication = models.BooleanField(
            default=True, help_text="True if express permission for publication required."
        )
        url = models.URLField(default="", blank=True, help_text="URL of the licence.")
    
    
        class Meta:
            ordering = ("name",)
    
        def __str__(self):
            return self.identifier
    
    
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    class Image(models.Model):
    
        alt_text = models.TextField(blank=True, default="", help_text="Alternate text for the image.")
        credits = models.TextField(blank=True, default="", help_text="Credits of the image")
        is_use_explicitly_granted_by_author = models.BooleanField(
            default=False, help_text="True if use is explicitly granted by author."
        )
    
        height = models.PositiveIntegerField(blank=True, null=True)
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
        image = VersatileImageField(
            blank=True,
            height_field="height",
            null=True,
            ppoi_field="ppoi",
            upload_to="images",
            width_field="width",
    
            help_text="The URI of the image.",
    
        license = models.ForeignKey(
            License, null=True, on_delete=models.SET_NULL, related_name="images"
        )
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
        owner = models.CharField(max_length=150)
        ppoi = PPOIField()
    
        width = models.PositiveIntegerField(blank=True, null=True)
    
        def render(self, width: int | None = None, height: int | None = None):
            if width is None and height is None:
                return self.image.url
            elif width and height:
                return self.image.crop[f"{width}x{height}"].url
            aspect_ratio = self.width / self.height
            if width is None:
                width = int(height * aspect_ratio)
            if height is None:
                height = int(width / aspect_ratio)
            return self.image.thumbnail[f"{width}x{height}"].url
    
    
    class Profile(models.Model):
        biography = models.TextField(blank=True, help_text="Biography of the profile.")
    
        created_at = models.DateTimeField(auto_now_add=True)
        created_by = models.CharField(max_length=150)
    
        email = models.EmailField(blank=True, help_text="Email address of the profile.")
        image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="profiles")
        is_active = models.BooleanField(default=True, help_text="True if the profile is active.")
        name = models.CharField(max_length=128, help_text="Display name of the profile.")
    
        owners = models.ManyToManyField(
    
            User, blank=True, related_name="profiles", help_text="User ID(s) that own this profile."
    
        updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
    
        updated_by = models.CharField(blank=True, default="", max_length=150)
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("name",)
    
            permissions = [
    
                ("edit__profile__biography", "Can edit biography field"),
                ("edit__profile__email", "Can edit email field"),
                ("edit__profile__image", "Can edit image field"),
                ("edit__profile__links", "Can edit links field"),
                ("edit__profile__name", "Can edit name field"),
                ("edit__profile__owners", "Can edit owners field"),
    
                # overrides ownership
    
                ("update_profile", "Can update profile"),
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            return self.name
    
    
    class LinkType(models.Model):
    
        is_active = models.BooleanField(default=True, help_text="True if link type is active.")
    
        name = models.CharField(max_length=32, help_text="Name of the link type")
    
    
        class Meta:
            ordering = ("name",)
    
        def __str__(self):
    
    class Link(models.Model):
    
        type = models.ForeignKey(LinkType, default=1, on_delete=models.CASCADE)
    
        url = models.URLField()
    
    
        def __str__(self):
            return self.url
    
    
    class ProfileLink(Link):
        profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="links")
    
    class ShowManager(models.Manager):
        def with_max_timeslot_start(self):
            return (
                super().get_queryset().annotate(max_timeslot_start=Max("schedules__timeslots__start"))
            )
    
    
    
    class Show(models.Model):
    
        category = models.ManyToManyField(Category, blank=True, related_name="shows")
    
        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)
    
        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(
    
            FundingCategory, blank=True, null=True, on_delete=models.CASCADE, related_name="shows"
    
        hosts = models.ManyToManyField(Profile, blank=True, related_name="shows")
    
        image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="shows")
    
        internal_note = models.TextField(blank=True, help_text="Internal note for this show.")
        is_active = models.BooleanField(default=True, help_text="True if this show is active.")
        is_public = models.BooleanField(default=False, help_text="True if this show is public.")
    
        language = models.ManyToManyField(Language, blank=True, related_name="shows")
    
        # 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, help_text="Name of this Show.")
    
        owners = models.ManyToManyField(User, blank=True, related_name="shows")
        predecessor = models.ForeignKey(
    
            "self", blank=True, null=True, on_delete=models.CASCADE, related_name="successors"
    
        short_description = models.TextField(help_text="Short description of this show.")
        slug = models.SlugField(
            blank=True, max_length=255, unique=True, help_text="Slug of this show."
        )
    
        topic = models.ManyToManyField(Topic, blank=True, related_name="shows")
        type = models.ForeignKey(
            Type, blank=True, null=True, on_delete=models.CASCADE, related_name="shows"
        )
    
        updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
    
        updated_by = models.CharField(blank=True, default="", max_length=150)
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
        class Meta:
    
            ordering = ("slug",)
    
            permissions = [
    
                ("display__show__internal_note", "Can display internal note field"),
                ("edit__show__categories", "Can edit category field"),
                ("edit__show__cba_series_id", "Can edit cba series id field"),
    
                ("edit__show__default_playlist_id", "Can edit default media source"),
    
                ("edit__show__description", "Can edit description field"),
                ("edit__show__email", "Can edit email field"),
                ("edit__show__funding_categories", "Can edit funding category field"),
                ("edit__show__hosts", "Can edit hosts field"),
                ("edit__show__image", "Can edit image field"),
                ("edit__show__internal_note", "Can edit internal note field"),
                ("edit__show__is_active", "Can edit is active field"),
                ("edit__show__languages", "Can edit language field"),
                ("edit__show__links", "Can edit links field"),
                ("edit__show__logo", "Can edit logo field"),
                ("edit__show__music_focuses", "Can edit music focus field"),
                ("edit__show__name", "Can edit name field"),
                ("edit__show__owners", "Can edit owners field"),
                ("edit__show__predecessor", "Can edit predecessor field"),
    
                ("edit__show__short_description", "Can edit short description field"),
    
                ("edit__show__slug", "Can edit slug field"),
                ("edit__show__topics", "Can edit topic field"),
                ("edit__show__type", "Can edit type field"),
    
                # overrides ownership
                ("update_show", "Can update show"),
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            return self.name
    
    
        def save(self, *args, **kwargs):
            now = timezone.datetime.now()
            today = now.date()
    
            if self.pk and self.is_active is False:
                # deactivating a show means:
                # - **delete all* the timeslots that belong to a schedule of this show the after now
                # - **update all** the schedules of this show have today as `last_date`
                TimeSlot.objects.filter(schedule__show=self, start__gt=now).delete()
                self.schedules.filter(Q(last_date__gt=today) | Q(last_date=None)).update(
                    last_date=today
                )
            super().save(*args, **kwargs)
    
    
    class ShowLink(Link):
        show = models.ForeignKey(Show, on_delete=models.CASCADE, related_name="links")
    
    
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    class RRule(models.Model):
    
        by_set_pos = models.IntegerField(
            blank=True,
            choices=[
                (1, "first"),
                (2, "second"),
                (3, "third"),
                (4, "fourth"),
                (5, "fifth"),
                (-1, "last"),
            ],
            null=True,
        )
        by_weekdays = models.CharField(
            blank=True,
            choices=[
                (None, ""),
                ("0,1,2,3,4", "business days"),
                ("5,6", "weekends"),
            ],
            null=True,
            max_length=9,
        )
    
        count = models.IntegerField(
            blank=True,
            null=True,
            help_text="How many occurrences should be generated.",
        )
    
        freq = models.IntegerField(
            choices=[
                (0, "once"),
                (1, "monthly"),
                (2, "weekly"),
                (3, "daily"),
            ]
        )
        interval = models.IntegerField(
            default=1,
            help_text="The interval between each freq iteration.",
        )
        name = models.CharField(max_length=32, unique=True)
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("pk",)
    
            unique_together = ("freq", "interval", "by_set_pos", "by_weekdays")
    
            verbose_name = _("recurrence rule")
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            return self.name
    
    
        add_business_days_only = models.BooleanField(
            default=False,
            help_text=(
                "Whether to add add_days_no but skipping the weekends. "
                "E.g. if weekday is Friday, the date returned will be the next Monday."
            ),
    
        add_days_no = models.IntegerField(
            blank=True,
            null=True,
            help_text=(
                "Add a number of days to the generated dates. "
                "This can be useful for repetitions, like 'On the following day'."
            ),
    
        )
        by_weekday = models.IntegerField(
            help_text="Number of the Weekday.",
            choices=[
                (0, "Monday"),
                (1, "Tuesday"),
                (2, "Wednesday"),
                (3, "Thursday"),
                (4, "Friday"),
                (5, "Saturday"),
                (6, "Sunday"),
            ],
    
        default_playlist_id = models.IntegerField(
            blank=True,
            null=True,
            help_text="A tank ID in case the timeslot's playlist_id is empty.",
        )
    
        end_time = models.TimeField(null=True, help_text="End time of schedule.")
    
        first_date = models.DateField(help_text="Start date of schedule.")
    
        is_repetition = models.BooleanField(
            default=False,
            help_text="Whether the schedule is a repetition.",
        )
    
        last_date = models.DateField(help_text="End date of schedule.", null=True)
    
        rrule = models.ForeignKey(
    
            RRule, help_text="A recurrence rule.", on_delete=models.CASCADE, related_name="schedules"
    
        show = models.ForeignKey(
            Show,
    
            help_text="Show the schedule belongs to.",
    
            on_delete=models.CASCADE,
            related_name="schedules",
    
        start_time = models.TimeField(help_text="Start time of schedule.")
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("first_date", "start_time")
    
            permissions = [
                ("edit__schedule__default_playlist_id", "Can edit default media source"),
            ]
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
            start_time = self.start_time.strftime("%H:%M")
            end_time = self.end_time.strftime("%H:%M")
            recurrence = self.rrule.name
            weekday = self.first_date.weekday()
    
            return f"{self.show.name} - {recurrence} {WEEKDAYS[weekday]} {start_time}-{end_time}"
    
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    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.")
    
        repetition_of = models.ForeignKey(
    
            "self", blank=True, null=True, on_delete=models.CASCADE, related_name="repetitions"
    
        schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, related_name="timeslots")
        start = models.DateTimeField()
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("start", "end")
    
            permissions = [
                ("edit__timeslot__memo", "Can edit memo field"),
    
                ("edit__timeslot__playlist_id", "Can edit media source"),
    
                ("edit__timeslot__repetition_of", "Can edit repetition of field"),
            ]
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
        def __str__(self):
            if self.start.date() == self.end.date():
                time_span = "{0}, {1} - {2}".format(
                    self.start.strftime("%x"),
                    self.start.strftime("%X"),
                    self.end.strftime("%X"),
                )
            else:
                time_span = "{0} - {1}".format(
                    self.start.strftime("%X %x"),
                    self.end.strftime("%X %x"),
                )
    
    
            return f"{str(self.schedule.show)} ({time_span})"
    
            string = (
                str(self.start)
                + str(self.end)
                + str(self.schedule.rrule.id)
                + str(self.schedule.by_weekday)
            )
            return str("".join(s for s in string if s.isdigit()))
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
    
    class Note(models.Model):
    
        cba_id = models.IntegerField(blank=True, null=True, help_text="CBA entry ID.")
        content = models.TextField(help_text="Textual content of the note.")
        contributors = models.ManyToManyField(
    
            Profile, related_name="notes", help_text="Profile IDs that contributed to this episode."
    
        created_at = models.DateTimeField(auto_now_add=True)
        created_by = models.CharField(max_length=150)
    
        image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="notes")
    
        language = models.ManyToManyField(Language, blank=True, related_name="episodes")
    
        playlist = models.TextField(blank=True)
    
        summary = models.TextField(blank=True, help_text="Summary of the Note.")
    
        tags = models.JSONField(blank=True, default=list)
    
        timeslot = models.OneToOneField(TimeSlot, null=True, on_delete=models.SET_NULL, unique=True)
    
        title = models.CharField(
            blank=True, default="", max_length=128, help_text="Title of the note."
        )
    
        topic = models.ManyToManyField(Topic, blank=True, related_name="episodes")
    
        updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
    
        updated_by = models.CharField(blank=True, default="", max_length=150)
    
    Ernesto Rico Schmidt's avatar
    Ernesto Rico Schmidt committed
    
        class Meta:
    
            ordering = ("timeslot",)
    
                ("edit__note__cba_id", "Can edit CBA id field"),
    
                ("edit__note__content", "Can edit content field"),
                ("edit__note__contributors", "Can edit contributor field"),
                ("edit__note__image", "Can edit image field"),
                ("edit__note__languages", "Can edit language field"),
                ("edit__note__links", "Can edit links field"),
    
                ("edit__note__playlist", "Can edit playlist field"),
    
                ("edit__note__summary", "Can edit summary field"),
                ("edit__note__tags", "Can edit tags field"),
                ("edit__note__title", "Can edit title field"),
                ("edit__note__topics", "Can edit topics field"),
    
                # overrides ownership
                ("create_note", "Can create note"),
                ("update_note", "Can update note"),
    
        def __str__(self):
            return self.title
    
    
    
    class NoteLink(Link):
        note = models.ForeignKey(Note, on_delete=models.CASCADE, related_name="links")
    
    class CBA(models.Model):
        username = models.CharField("Username", blank=True, max_length=60)
        user_token = models.CharField("User Token", blank=True, max_length=255)
    
        created_at = models.DateTimeField(auto_now_add=True)
        created_by = models.CharField(max_length=150)
        updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
    
        updated_by = models.CharField(blank=True, default="", max_length=150)
    
        user = models.OneToOneField(User, null=True, on_delete=models.SET_NULL, related_name="cba")
    
                ("create_cba", "Can create user CBA profile"),
                ("update_cba", "Can update user CBA profile"),
    
        def __str__(self):
            return self.user.username
    
    
    
    class Playlist(models.Model):
        class Meta:
            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"),
            ]
    
    class ImageAspectRadioField(models.CharField):
    
        validators = [
            RegexValidator(
                code="invalid_aspect_ratio",
                message="Enter a valid aspect ratio in the format int:int or float:float",
                regex=r"^\d+(\.\d+)?:\d+(\.\d+)?$",
            )
        ]
    
    
    
            super().__init__(*args, **kwargs)
    
    
    class ImageShapeField(models.CharField):
        def __init__(self, *args, **kwargs):
            kwargs["choices"] = [
                ("rect", "rect"),
                ("round", "round"),
            ]
            kwargs["max_length"] = 5
    
            super().__init__(*args, **kwargs)
    
    
    
    def validate_json_value(value: list | dict, schema: dict) -> None:
        """Validates value JSON against the schema. Raises a Django `ValidationError` if invalid."""
    
    
        try:
            jsonschema.validate(instance=value, schema=schema)
        except jsonschema.exceptions.ValidationError as e:
            raise DjangoValidationError(e.args[0])
    
    
    
    def validate_cba_domains(value):
        schema = {
            "type": "array",
            "items": {"type": "string"},
        }
    
    
        validate_json_value(value, schema)
    
    
    
    def validate_line_in_channels(value):
        schema = {
            "type": "object",
            "patternProperties": {
                "^.*$": {"type": "string"},
            },
        }
    
    
        validate_json_value(value, schema)
    
    def validate_fallback_pools(value):
        schema = {
            "type": "object",
            "patternProperties": {
                "^.*$": {"type": "string"},
            },
        }
    
    
        validate_json_value(value, schema)
    
    def validate_fallback_default_pool(value):
    
        if value not in RadioSettings.objects.first().pools.keys():
            raise DjangoValidationError(f"Pool key '{value}' does not exist in pools.")
    
    class RadioSettings(models.Model):
        cba_api_key = models.CharField(blank=True, max_length=64, verbose_name="CBA API key")
        cba_domains = models.JSONField(
    
            blank=True,
            default=list,
            help_text="JSON array of strings",
            validators=[validate_cba_domains],
            verbose_name="CBA domains",
    
        fallback_default_pool = models.CharField(
            blank=True, max_length=32, validators=[validate_fallback_default_pool]
        )
    
        fallback_show = models.ForeignKey(
            Show, blank=True, null=True, on_delete=models.CASCADE, related_name="+"
        )
    
        line_in_channels = models.JSONField(
            blank=True,
            default=dict,
            help_text="JSON key/value pairs",
            validators=[validate_line_in_channels],
        )
    
        micro_show = models.ForeignKey(
            Show, blank=True, null=True, on_delete=models.CASCADE, related_name="+"
        )
    
        note_image_aspect_ratio = ImageAspectRadioField(default="16:9")
        note_image_shape = ImageShapeField(default="rect")
    
        pools = models.JSONField(
            blank=True,
            default=dict,
            help_text="JSON key/value pairs",
            validators=[validate_fallback_pools],
        )
    
        profile_image_aspect_ratio = ImageAspectRadioField(default="1:1")
        profile_image_shape = ImageShapeField(default="round")
    
        show_image_aspect_ratio = ImageAspectRadioField(default="16:9")
        show_image_shape = ImageShapeField(default="rect")
        show_logo_aspect_ratio = ImageAspectRadioField(default="1:1")
        show_logo_shape = ImageShapeField(default="rect")
    
        station_logo = VersatileImageField(
            blank=True,
            height_field="station_logo_height",
            null=True,
            upload_to="images",
            width_field="station_logo_width",
    
        station_logo_height = models.PositiveIntegerField(blank=True, null=True)
        station_logo_width = models.PositiveIntegerField(blank=True, null=True)
    
        station_name = models.CharField(max_length=256, unique=True)
        station_website = models.URLField()
    
        class Meta:
            verbose_name_plural = "Radio Settings"
    
        def __str__(self):
            return self.station_name
    
    
    
    @dataclasses.dataclass()
    class ProgramEntry:
        id: str
        start: datetime.datetime
        end: datetime.datetime
        show: Show
        timeslot: TimeSlot | None
    
    
        def playlist_id(self) -> int | None:
            if self.timeslot and self.timeslot.playlist_id:
                return self.timeslot.playlist_id
            else:
                return self.show.default_playlist_id