Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • add-playlists
  • ci-trigger-aura-tests
  • feat/249-episodes
  • feat/site-data
  • feature/extended-database-env-configuration
  • fix-aura-sysuser
  • fix-push-latest-with-tag
  • fix/media_id_source
  • kmohrf/fix-configuration-error-api-responses
  • kmohrf/id-filters
  • kmohrf/program-calendar-populate-filter
  • kmohrf/virtual-timeslot-fixes
  • main
  • refactor-playout-endpoint-210
  • rename-playlist-playlistentry
  • 1.0.0-alpha1
  • 1.0.0-alpha2
  • 1.0.0-alpha3
  • 1.0.0-alpha4
  • 1.0.0-alpha5
  • 1.0.0-alpha6
21 results

Target

Select target project
  • aura/steering
  • kmohrf/steering
2 results
Select Git revision
  • add-playlists
  • ci-trigger-aura-tests
  • feat/249-episodes
  • feat/site-data
  • feature/extended-database-env-configuration
  • fix-aura-sysuser
  • fix-push-latest-with-tag
  • fix/media_id_source
  • kmohrf/fix-configuration-error-api-responses
  • kmohrf/id-filters
  • kmohrf/program-calendar-populate-filter
  • kmohrf/virtual-timeslot-fixes
  • main
  • refactor-playout-endpoint-210
  • rename-playlist-playlistentry
  • 1.0.0-alpha1
  • 1.0.0-alpha2
  • 1.0.0-alpha3
  • 1.0.0-alpha4
  • 1.0.0-alpha5
  • 1.0.0-alpha6
21 results
Show changes
Showing
with 1885 additions and 522 deletions
# Generated by Django 4.2.13 on 2024-06-25 20:00
import program.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0102_radiosettings_fallback_pools"),
]
operations = [
migrations.AlterField(
model_name="radiosettings",
name="fallback_default_pool",
field=models.CharField(
blank=True,
max_length=32,
validators=[program.models.validate_fallback_default_pool],
),
),
]
# Generated by Django 4.2.13 on 2024-06-26 22:05
import versatileimagefield.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0103_alter_radiosettings_fallback_default_pool"),
]
operations = [
migrations.AddField(
model_name="radiosettings",
name="station_logo_height",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="radiosettings",
name="station_logo_width",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name="radiosettings",
name="station_logo",
field=versatileimagefield.fields.VersatileImageField(
blank=True,
height_field="station_logo_height",
null=True,
upload_to="images",
width_field="station_logo_width",
),
),
]
# Generated by Django 4.2.13 on 2024-07-01 23:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("program", "0104_radiosettings_station_logo_height_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="host",
options={
"ordering": ("name",),
"permissions": [
("edit__host__biography", "Can edit biography field"),
("edit__host__email", "Can edit email field"),
("edit__host__image", "Can edit image field"),
("edit__host__links", "Can edit links field"),
("edit__host__name", "Can edit name field"),
("edit__host__owners", "Can edit owners field"),
("update_host", "Can update host"),
],
},
),
migrations.AlterModelOptions(
name="note",
options={
"ordering": ("timeslot",),
"permissions": [
("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"),
("create_note", "Can create note"),
("update_note", "Can update note"),
],
},
),
migrations.AlterModelOptions(
name="timeslot",
options={
"ordering": ("start", "end"),
"permissions": [
("edit__timeslot__memo", "Can edit memo field"),
("edit__timeslot__playlist", "Can edit playlist field"),
("edit__timeslot__repetition_of", "Can edit repetition of field"),
],
},
),
]
# Generated by Django 4.2.13 on 2024-07-03 19:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("program", "0105_alter_host_options_alter_note_options_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="schedule",
options={
"ordering": ("first_date", "start_time"),
"permissions": [
("edit__schedule__default_playlist_id", "Can edit default media source")
],
},
),
]
# Generated by Django 4.2.13 on 2024-07-03 19:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("program", "0106_alter_schedule_options"),
]
operations = [
migrations.AlterModelOptions(
name="show",
options={
"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"),
("update_show", "Can update show"),
],
},
),
migrations.AlterModelOptions(
name="timeslot",
options={
"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"),
],
},
),
]
# Generated by Django 4.2.13 on 2024-07-04 16:11
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("program", "0107_alter_show_options_alter_timeslot_options"),
]
operations = [
migrations.RenameField(
model_name="radiosettings",
old_name="fallback_pools",
new_name="pools",
),
]
# Generated by Django 4.2.13 on 2024-07-08 15:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("program", "0108_rename_fallback_pools_radiosettings_pools"),
]
operations = [
migrations.CreateModel(
name="CBA",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("username", models.CharField(blank=True, max_length=60, verbose_name="Username")),
(
"user_token",
models.CharField(blank=True, max_length=255, verbose_name="User Token"),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("created_by", models.CharField(max_length=150)),
("updated_at", models.DateTimeField(auto_now=True, null=True)),
("updated_by", models.CharField(blank=True, default="", max_length=150)),
(
"user",
models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="cba",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"permissions": [
("create_cba", "Can create user CBA profile"),
("update_cba", "Can update user CBA profile"),
],
},
),
migrations.DeleteModel(
name="UserProfile",
),
]
# Generated by Django 4.2.13 on 2024-07-08 20:14
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("program", "0109_cba_delete_userprofile"),
]
operations = [
migrations.RenameModel(
old_name="Host",
new_name="Profile",
),
]
# Generated by Django 4.2.13 on 2024-07-08 20:16
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("program", "0110_rename_host_profile"),
]
operations = [
migrations.AlterModelOptions(
name="profile",
options={
"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"),
("update_profile", "Can update profile"),
],
},
),
migrations.AlterField(
model_name="profile",
name="biography",
field=models.TextField(blank=True, help_text="Biography of the profile."),
),
migrations.AlterField(
model_name="profile",
name="email",
field=models.EmailField(
blank=True, help_text="Email address of the profile.", max_length=254
),
),
migrations.AlterField(
model_name="profile",
name="is_active",
field=models.BooleanField(default=True, help_text="True if the profile is active."),
),
migrations.AlterField(
model_name="profile",
name="name",
field=models.CharField(help_text="Display name of the profile.", max_length=128),
),
migrations.AlterField(
model_name="profile",
name="owners",
field=models.ManyToManyField(
blank=True,
help_text="User ID(s) that own this profile.",
related_name="hosts",
to=settings.AUTH_USER_MODEL,
),
),
]
# Generated by Django 4.2.13 on 2024-07-08 20:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("program", "0111_alter_profile_options_alter_profile_biography_and_more"),
]
operations = [
migrations.CreateModel(
name="ProfileLink",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("url", models.URLField()),
],
options={
"abstract": False,
},
),
migrations.RenameField(
model_name="radiosettings",
old_name="host_image_aspect_ratio",
new_name="profile_image_aspect_ratio",
),
migrations.RenameField(
model_name="radiosettings",
old_name="host_image_shape",
new_name="profile_image_shape",
),
migrations.AlterField(
model_name="profile",
name="image",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="profiles",
to="program.image",
),
),
migrations.AlterField(
model_name="profile",
name="owners",
field=models.ManyToManyField(
blank=True,
help_text="User ID(s) that own this profile.",
related_name="profiles",
to=settings.AUTH_USER_MODEL,
),
),
migrations.DeleteModel(
name="HostLink",
),
migrations.AddField(
model_name="profilelink",
name="profile",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="links",
to="program.profile",
),
),
migrations.AddField(
model_name="profilelink",
name="type",
field=models.ForeignKey(
default=1, on_delete=django.db.models.deletion.CASCADE, to="program.linktype"
),
),
]
# Generated by Django 4.2.13 on 2024-07-09 00:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0112_profilelink_and_more"),
]
operations = [
migrations.AlterField(
model_name="note",
name="contributors",
field=models.ManyToManyField(
help_text="Profile IDs that contributed to this episode.",
related_name="notes",
to="program.profile",
),
),
migrations.AlterField(
model_name="timeslot",
name="playlist_id",
field=models.IntegerField(help_text="Playlist ID of this timeslot.", null=True),
),
]
# Generated by Django 4.2.13 on 2024-07-22 20:04
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("program", "0113_alter_note_contributors_alter_timeslot_playlist_id"),
]
operations = [
migrations.AlterModelOptions(
name="note",
options={
"ordering": ("timeslot",),
"permissions": [
("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 topic field"),
("create_note", "Can create note"),
("update_note", "Can update note"),
],
},
),
]
# Generated by Django 4.2.14 on 2024-07-31 00:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("program", "0114_alter_note_options"),
]
operations = [
migrations.AlterModelOptions(
name="note",
options={
"ordering": ("timeslot",),
"permissions": [
("edit__note__cba_id", "Can edit CBA id field"),
("edit__note__content", "Can edit content field"),
("edit__note__contributors", "Can edit contributors field"),
("edit__note__image", "Can edit image field"),
("edit__note__languages", "Can edit languages 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"),
("create_note", "Can create note"),
("update_note", "Can update note"),
],
},
),
migrations.AlterModelOptions(
name="schedule",
options={
"ordering": ("first_date", "start_time"),
"permissions": [
("edit__schedule__default_playlist_id", "Can edit default media-source")
],
},
),
migrations.AlterModelOptions(
name="show",
options={
"ordering": ("slug",),
"permissions": [
("display__show__internal_note", "Can display internal note field"),
("edit__show__categories", "Can edit categories 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 categories 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 languages 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 topics field"),
("edit__show__type", "Can edit type field"),
("update_show", "Can update show"),
],
},
),
migrations.AlterModelOptions(
name="timeslot",
options={
"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"),
],
},
),
]
# Generated by Django 4.2.14 on 2024-08-01 22:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("program", "0115_alter_note_options_alter_schedule_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="note",
name="image",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="notes",
to="program.image",
),
),
migrations.AlterField(
model_name="notelink",
name="type",
field=models.ForeignKey(
default=1,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="program.linktype",
),
),
migrations.AlterField(
model_name="profile",
name="image",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="profiles",
to="program.image",
),
),
migrations.AlterField(
model_name="profilelink",
name="type",
field=models.ForeignKey(
default=1,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="program.linktype",
),
),
migrations.AlterField(
model_name="radiosettings",
name="fallback_show",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="program.show",
),
),
migrations.AlterField(
model_name="radiosettings",
name="micro_show",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="program.show",
),
),
migrations.AlterField(
model_name="schedule",
name="rrule",
field=models.ForeignKey(
help_text="A recurrence rule.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="schedules",
to="program.rrule",
),
),
migrations.AlterField(
model_name="schedule",
name="show",
field=models.ForeignKey(
help_text="Show the schedule belongs to.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="schedules",
to="program.show",
),
),
migrations.AlterField(
model_name="show",
name="funding_category",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shows",
to="program.fundingcategory",
),
),
migrations.AlterField(
model_name="show",
name="image",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shows",
to="program.image",
),
),
migrations.AlterField(
model_name="show",
name="logo",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="logo_shows",
to="program.image",
),
),
migrations.AlterField(
model_name="show",
name="predecessor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="successors",
to="program.show",
),
),
migrations.AlterField(
model_name="show",
name="type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="shows",
to="program.type",
),
),
migrations.AlterField(
model_name="showlink",
name="type",
field=models.ForeignKey(
default=1,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="program.linktype",
),
),
migrations.AlterField(
model_name="timeslot",
name="repetition_of",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="repetitions",
to="program.timeslot",
),
),
]
......@@ -17,12 +17,18 @@
# 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 collections
import dataclasses
import datetime
import typing
import jsonschema
from rest_framework.exceptions import ValidationError
from versatileimagefield.fields import PPOIField, VersatileImageField
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from django.core.validators import RegexValidator
from django.db import models
from django.db.models import Max, Q
from django.utils import timezone
......@@ -36,9 +42,9 @@ class ScheduleConflictError(ValidationError):
class Type(models.Model):
is_active = models.BooleanField(default=True)
name = models.CharField(max_length=32)
slug = models.SlugField(max_length=32, unique=True)
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.")
class Meta:
ordering = ("name",)
......@@ -48,11 +54,11 @@ class Type(models.Model):
class Category(models.Model):
description = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
name = models.CharField(max_length=32)
slug = models.SlugField(max_length=32, unique=True)
subtitle = models.CharField(blank=True, max_length=32)
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.")
class Meta:
ordering = ("name",)
......@@ -63,9 +69,9 @@ class Category(models.Model):
class Topic(models.Model):
is_active = models.BooleanField(default=True)
name = models.CharField(max_length=32)
slug = models.SlugField(max_length=32, unique=True)
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.")
class Meta:
ordering = ("name",)
......@@ -75,9 +81,9 @@ class Topic(models.Model):
class MusicFocus(models.Model):
is_active = models.BooleanField(default=True)
name = models.CharField(max_length=32)
slug = models.SlugField(max_length=32, unique=True)
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.")
class Meta:
ordering = ("name",)
......@@ -88,9 +94,9 @@ class MusicFocus(models.Model):
class FundingCategory(models.Model):
is_active = models.BooleanField(default=True)
name = models.CharField(max_length=32)
slug = models.SlugField(max_length=32, unique=True)
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.")
class Meta:
ordering = ("name",)
......@@ -101,8 +107,8 @@ class FundingCategory(models.Model):
class Language(models.Model):
is_active = models.BooleanField(default=True)
name = models.CharField(max_length=32)
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:
ordering = ("name",)
......@@ -114,9 +120,11 @@ class Language(models.Model):
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)
requires_express_permission_for_publication = models.BooleanField(default=True)
url = models.URLField(default="", blank=True)
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",)
......@@ -126,9 +134,11 @@ class License(models.Model):
class Image(models.Model):
alt_text = models.TextField(blank=True, default="")
credits = models.TextField(blank=True, default="")
is_use_explicitly_granted_by_author = models.BooleanField(default=False)
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)
image = VersatileImageField(
blank=True,
......@@ -137,6 +147,7 @@ class Image(models.Model):
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"
......@@ -158,25 +169,31 @@ class Image(models.Model):
return self.image.thumbnail[f"{width}x{height}"].url
class Host(models.Model):
biography = models.TextField(blank=True)
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)
image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="hosts")
is_active = models.BooleanField(default=True)
name = models.CharField(max_length=128)
owners = models.ManyToManyField(User, blank=True, related_name="hosts")
email = models.EmailField(blank=True, help_text="Email address of the profile.")
image = models.ForeignKey(Image, null=True, on_delete=models.SET_NULL, 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)
class Meta:
ordering = ("name",)
permissions = [
("edit__host__biography", "Can edit Host biography field"),
("edit__host__name", "Can edit Host name field"),
("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_host", "Can update host"),
("update_profile", "Can update profile"),
]
def __str__(self):
......@@ -184,7 +201,7 @@ class Host(models.Model):
class LinkType(models.Model):
is_active = models.BooleanField(default=True)
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:
......@@ -195,7 +212,7 @@ class LinkType(models.Model):
class Link(models.Model):
type = models.ForeignKey(LinkType, default=1, on_delete=models.CASCADE)
type = models.ForeignKey(LinkType, default=1, null=True, on_delete=models.SET_NULL)
url = models.URLField()
class Meta:
......@@ -205,8 +222,8 @@ class Link(models.Model):
return self.url
class HostLink(Link):
host = models.ForeignKey(Host, on_delete=models.CASCADE, related_name="links")
class ProfileLink(Link):
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="links")
class ShowManager(models.Manager):
......@@ -222,32 +239,34 @@ class Show(models.Model):
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)
email = models.EmailField(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"
FundingCategory, blank=True, null=True, on_delete=models.SET_NULL, related_name="shows"
)
hosts = models.ManyToManyField(Host, blank=True, related_name="shows")
image = models.ForeignKey(Image, null=True, on_delete=models.CASCADE, related_name="shows")
internal_note = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
is_public = models.BooleanField(default=False)
hosts = models.ManyToManyField(Profile, blank=True, related_name="shows")
image = models.ForeignKey(Image, null=True, on_delete=models.SET_NULL, 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"
Image, blank=True, null=True, on_delete=models.SET_NULL, related_name="logo_shows"
)
music_focus = models.ManyToManyField(MusicFocus, blank=True, related_name="shows")
name = models.CharField(max_length=255)
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"
"self", blank=True, null=True, on_delete=models.SET_NULL, 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."
)
short_description = models.TextField()
slug = models.SlugField(blank=True, max_length=255, unique=True)
topic = models.ManyToManyField(Topic, blank=True, related_name="shows")
type = models.ForeignKey(
Type, blank=True, null=True, on_delete=models.CASCADE, related_name="shows"
Type, blank=True, null=True, on_delete=models.SET_NULL, related_name="shows"
)
updated_at = models.DateTimeField(auto_now=True, blank=True, null=True)
updated_by = models.CharField(blank=True, default="", max_length=150)
......@@ -258,26 +277,26 @@ class Show(models.Model):
ordering = ("slug",)
permissions = [
("display__show__internal_note", "Can display internal note field"),
("edit__show__categories", "Can edit category field"),
("edit__show__categories", "Can edit categories field"),
("edit__show__cba_series_id", "Can edit cba series id field"),
("edit__show__default_playlist", "Can edit default playlist 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__funding_categories", "Can edit funding categories 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__languages", "Can edit languages 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__short_description", "Can edit short description field"),
("edit__show__slug", "Can edit slug field"),
("edit__show__topics", "Can edit topic field"),
("edit__show__topics", "Can edit topics field"),
("edit__show__type", "Can edit type field"),
# overrides ownership
("update_show", "Can update show"),
......@@ -398,18 +417,26 @@ class Schedule(models.Model):
)
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"
RRule,
help_text="A recurrence rule.",
null=True,
on_delete=models.SET_NULL,
related_name="schedules",
)
show = models.ForeignKey(
Show,
help_text="Show the schedule belongs to.",
on_delete=models.CASCADE,
null=True,
on_delete=models.SET_NULL,
related_name="schedules",
)
start_time = models.TimeField(help_text="Start time of schedule.")
class Meta:
ordering = ("first_date", "start_time")
permissions = [
("edit__schedule__default_playlist_id", "Can edit default media-source"),
]
def __str__(self):
WEEKDAYS = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]
......@@ -423,17 +450,21 @@ class Schedule(models.Model):
class TimeSlot(models.Model):
end = models.DateTimeField()
memo = models.TextField(blank=True)
playlist_id = models.IntegerField(null=True)
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"
"self", blank=True, null=True, on_delete=models.SET_NULL, related_name="repetitions"
)
schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE, related_name="timeslots")
start = models.DateTimeField()
class Meta:
ordering = ("start", "end")
permissions = [("edit__timeslot__playlist", "Can edit playlist field")]
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"),
]
def __str__(self):
if self.start.date() == self.end.date():
......@@ -462,18 +493,22 @@ class TimeSlot(models.Model):
class Note(models.Model):
cba_id = models.IntegerField(blank=True, null=True)
content = models.TextField()
contributors = models.ManyToManyField(Host, related_name="notes")
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")
image = models.ForeignKey(Image, null=True, on_delete=models.SET_NULL, related_name="notes")
language = models.ManyToManyField(Language, blank=True, related_name="episodes")
playlist = models.TextField(blank=True)
summary = 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)
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)
......@@ -481,11 +516,13 @@ class Note(models.Model):
class Meta:
ordering = ("timeslot",)
permissions = [
("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__contributors", "Can edit contributors field"),
("edit__note__image", "Can edit image field"),
("edit__note__languages", "Can edit language field"),
("edit__note__languages", "Can edit languages 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"),
......@@ -503,14 +540,21 @@ class NoteLink(Link):
note = models.ForeignKey(Note, on_delete=models.CASCADE, related_name="links")
class UserProfile(models.Model):
cba_username = models.CharField("CBA Username", blank=True, max_length=60)
cba_user_token = models.CharField("CBA Token", blank=True, max_length=255)
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="profile")
user = models.OneToOneField(User, null=True, on_delete=models.SET_NULL, related_name="cba")
class Meta:
permissions = [
# overrides ownership
("create_cba", "Can create user CBA profile"),
("update_cba", "Can update user CBA profile"),
]
def __str__(self):
return self.user.username
......@@ -528,12 +572,16 @@ class Playlist(models.Model):
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+)?$",
)
]
def __init__(self, *args, **kwargs):
kwargs["choices"] = [
("1:1", "1:1"),
("16:9", "16:9"),
]
kwargs["max_length"] = 4
kwargs["max_length"] = 11
super().__init__(*args, **kwargs)
......@@ -549,16 +597,22 @@ class ImageShapeField(models.CharField):
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"},
}
try:
jsonschema.validate(value, schema)
except jsonschema.exceptions.ValidationError as e:
raise DjangoValidationError(e.args[0])
validate_json_value(value, schema)
def validate_line_in_channels(value):
......@@ -569,10 +623,23 @@ def validate_line_in_channels(value):
},
}
try:
jsonschema.validate(value, schema)
except jsonschema.exceptions.ValidationError as e:
raise DjangoValidationError(e.args[0])
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):
......@@ -584,12 +651,12 @@ class RadioSettings(models.Model):
validators=[validate_cba_domains],
verbose_name="CBA domains",
)
fallback_default_pool = models.CharField(blank=True, max_length=32)
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="+"
Show, blank=True, null=True, on_delete=models.SET_NULL, related_name="+"
)
host_image_aspect_ratio = ImageAspectRadioField(default="1:1")
host_image_shape = ImageShapeField(default="round")
line_in_channels = models.JSONField(
blank=True,
default=dict,
......@@ -597,17 +664,31 @@ class RadioSettings(models.Model):
validators=[validate_line_in_channels],
)
micro_show = models.ForeignKey(
Show, blank=True, null=True, on_delete=models.CASCADE, related_name="+"
Show, blank=True, null=True, on_delete=models.SET_NULL, 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 = models.ForeignKey(
Image, blank=True, null=True, on_delete=models.CASCADE, related_name="+"
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()
......@@ -616,3 +697,86 @@ class RadioSettings(models.Model):
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
class ApplicationStateManager:
categorized_models: typing.Mapping[str, list[typing.Type[models.Model]]] = {
"classifications": [
Type,
Category,
Topic,
MusicFocus,
FundingCategory,
Language,
License,
LinkType,
RRule,
],
"settings": [RadioSettings],
"auth": [CBA, User],
"media": [Image],
"program": [Note, TimeSlot, Schedule, Show],
}
@property
def _models(self):
result = []
for _models in self.categorized_models.values():
result.extend(_models)
return result
@property
def _model_map(self):
return {model._meta.label: model for model in self._models}
@property
def model_category_choices(self):
return sorted(self.categorized_models.keys())
@property
def model_choices(self):
return sorted(self._model_map.keys())
def purge(
self,
model_category_names: typing.Iterable[str] | None = None,
model_names: typing.Iterable[str] | None = None,
invert_selection: bool = False,
):
model_map = self._model_map
model_category_names = set(model_category_names or [])
model_names = set(model_names or [])
selected_models: set[typing.Type[models.Model]] = set()
for category_name in model_category_names:
selected_models.update(self.categorized_models[category_name])
for model_name in model_names:
selected_models.add(model_map[model_name])
if invert_selection:
selected_models = set(self._models).difference(selected_models)
# Some models may have dependent state and therefore need to be deleted in order.
ordered_selected_models = [model for model in self._models if model in selected_models]
deleted_model_count_map = collections.defaultdict(int)
for model in ordered_selected_models:
for model_path, count in model.objects.all().delete()[1].items():
deleted_model_count_map[model_path] += count
return deleted_model_count_map
application_state_manager = ApplicationStateManager()
......@@ -19,7 +19,9 @@
#
import re
from typing import Literal, NotRequired, TypedDict
from datetime import datetime
from functools import cached_property
from zoneinfo import ZoneInfo
from drf_jsonschema_serializer import JSONSchemaField
from rest_framework import serializers
......@@ -28,13 +30,12 @@ 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
from django.db.models import Q
from django.utils import text, timezone
from program.models import (
CBA,
Category,
FundingCategory,
Host,
HostLink,
Image,
Language,
License,
......@@ -42,6 +43,9 @@ from program.models import (
MusicFocus,
Note,
NoteLink,
Profile,
ProfileLink,
ProgramEntry,
RadioSettings,
RRule,
Schedule,
......@@ -50,9 +54,19 @@ from program.models import (
TimeSlot,
Topic,
Type,
UserProfile,
application_state_manager,
)
from program.utils import delete_links
from program.typing import (
Logo,
MicroProgram,
ProgramFallback,
RadioCBASettings,
RadioImageRequirementsSettings,
RadioPlayoutSettings,
RadioProgramSettings,
RadioStationSettings,
)
from program.utils import update_links
SOLUTION_CHOICES = {
"theirs": "Discard projected timeslot. Keep existing timeslot(s).",
......@@ -86,43 +100,42 @@ class ErrorSerializer(serializers.Serializer):
code = serializers.CharField(allow_null=True)
class ProfileSerializer(serializers.ModelSerializer):
class CBASerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = (
"cba_username",
"cba_user_token",
"created_at",
"created_by",
"updated_at",
"updated_by",
)
model = CBA
read_only_fields = (
"created_at",
"created_by",
"updated_at",
"updated_by",
)
fields = (
"username",
"user_token",
) + read_only_fields
class UserSerializer(serializers.ModelSerializer):
is_privileged = serializers.SerializerMethodField()
permissions = serializers.SerializerMethodField()
# Add profile fields to JSON
profile = ProfileSerializer(required=False)
host_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=Host.objects.all(), source="hosts"
cba = CBASerializer(required=False)
profile_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=Profile.objects.all(), required=False, source="profiles"
)
class Meta:
extra_kwargs = {
"password": {"write_only": True},
}
model = User
read_only_fields = (
"id",
"is_privileged",
"permissions",
"host_ids",
"profile_ids",
)
fields = (
"cba",
"email",
"first_name",
"is_active",
......@@ -130,13 +143,14 @@ class UserSerializer(serializers.ModelSerializer):
"is_superuser",
"last_name",
"password",
"profile",
"username",
) + read_only_fields
@staticmethod
def get_permissions(obj: User) -> list[str]:
return sorted([p for p in obj.get_all_permissions() if p.startswith("program")])
return sorted(
[p for p in obj.get_all_permissions() if p.split(".", 1)[0] in ["auth", "program"]]
)
@staticmethod
def get_is_privileged(obj: User) -> bool:
......@@ -147,21 +161,20 @@ class UserSerializer(serializers.ModelSerializer):
Create and return a new User instance, given the validated data.
"""
profile_data = validated_data.pop("profile") if "profile" in validated_data else None
cba_data = validated_data.pop("cba") if "cba" 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(),
if cba_data:
CBA.objects.create(
username=cba_data.get("username").strip(),
user_token=cba_data.get("user_token").strip(),
created_by=self.context.get("request").user.username,
user=user,
)
profile.save()
return user
......@@ -188,25 +201,46 @@ class UserSerializer(serializers.ModelSerializer):
if "is_superuser" in validated_data:
instance.is_superuser = validated_data.get("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)
if "cba_username" in profile_data:
profile.cba_username = profile_data.get("cba_username")
if "cba_user_token" in profile_data:
profile.cba_user_token = profile_data.get("cba_user_token")
cba_data = validated_data.pop("cba") if "cba" in validated_data else None
profile.updated_by = self.context.get("request").user.username
profile.save()
try:
cba = instance.cba
except ObjectDoesNotExist:
cba = None
if cba_data:
user = self.context.get("request").user
if cba:
# having the update_cba permission overrides being the user
if not (user.has_perm("program.update_cba") or user.id == instance.id):
raise exceptions.PermissionDenied(
detail="You do not have permission to update this user CBA profile."
)
if "username" in cba_data:
cba.username = cba_data.get("username")
if "user_token" in cba_data:
cba.user_token = cba_data.get("user_token")
cba.updated_by = self.context.get("request").user.username
cba.save()
else:
# having the create_cba permission overrides being the user
if not (user.has_perm("program.create_cba") or user.id == instance.id):
raise exceptions.PermissionDenied(
detail="You do not have permission to create this user CBA profile."
)
CBA.objects.create(
created_by=self.context.get("request").user.username,
user=instance,
**cba_data,
)
instance.save()
return instance
......@@ -235,12 +269,12 @@ class LicenseSerializer(serializers.ModelSerializer):
)
class HostLinkSerializer(serializers.ModelSerializer):
class ProfileLinkSerializer(serializers.ModelSerializer):
type_id = serializers.PrimaryKeyRelatedField(queryset=LinkType.objects.all(), source="type")
class Meta:
fields = ("type_id", "url")
model = HostLink
model = ProfileLink
class PPOIField(serializers.CharField):
......@@ -259,17 +293,15 @@ class PPOIField(serializers.CharField):
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"
allow_null=True,
queryset=License.objects.all(),
required=False,
source="license",
help_text="`License` ID of this image.",
)
ppoi = PPOIField(required=False)
ppoi = PPOIField(required=False, help_text="PPOI specifies the crop centerpoint of the image.")
url = serializers.SerializerMethodField()
@staticmethod
......@@ -332,17 +364,26 @@ class ImageRenderSerializer(serializers.Serializer):
height = serializers.IntegerField(required=False, min_value=1)
class HostSerializer(serializers.ModelSerializer):
class ProfileSerializer(serializers.ModelSerializer):
image_id = serializers.PrimaryKeyRelatedField(
allow_null=True, queryset=Image.objects.all(), required=False, source="image"
allow_null=True,
queryset=Image.objects.all(),
required=False,
source="image",
help_text="`Image` id of the profile.",
)
links = ProfileLinkSerializer(
many=True, required=False, help_text="Array of `Link` objects. Can be empty."
)
links = HostLinkSerializer(many=True, required=False)
owner_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=User.objects.all(), source="owners"
many=True,
queryset=User.objects.all(),
source="owners",
help_text="User ID(s) that own this profile.",
)
class Meta:
model = Host
model = Profile
read_only_fields = (
"created_at",
"created_by",
......@@ -362,7 +403,7 @@ class HostSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""
Create and return a new Host instance, given the validated data.
Create and return a new Profile instance, given the validated data.
"""
# optional nested objects
......@@ -374,37 +415,37 @@ class HostSerializer(serializers.ModelSerializer):
# optional foreign key
validated_data["image"] = validated_data.pop("image", None)
host = Host.objects.create(
profile = Profile.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)
ProfileLink.objects.create(host=profile, **link_data)
host.owners.set(owners)
profile.owners.set(owners)
host.save()
profile.save()
return host
return profile
def update(self, instance, validated_data):
"""Update and return an existing Host instance, given the validated data."""
"""Update and return an existing Profile instance, given the validated data."""
user = self.context.get("request").user
user_is_owner = user in instance.owners.all()
user_permissions = set(
permission.split("__")[-1]
for permission in user.get_all_permissions()
if permission.startswith("program.edit__host")
if permission.startswith("program.edit__profile")
)
update_fields = set(validated_data.keys())
# having the update_host permission overrides the ownership
if not (user.has_perm("program.update_host") or (user_is_owner and user_permissions)):
# having the update_profile permission overrides the ownership
if not (user.has_perm("program.update_profile") or (user_is_owner and user_permissions)):
raise exceptions.PermissionDenied(detail="You are not allowed to update this host.")
# without the update_host permission, fields without edit permission are not allowed
if not user.has_perm("program.update_host") and (
# without the update_profile permission, fields without edit permission are not allowed
if not user.has_perm("program.update_profile") and (
not_allowed := update_fields.difference(user_permissions)
):
detail = {field: "You are not allowed to edit this field" for field in not_allowed}
......@@ -431,10 +472,7 @@ class HostSerializer(serializers.ModelSerializer):
# optional nested objects
if "links" in validated_data:
instance = delete_links(instance)
for link_data in validated_data.get("links"):
HostLink.objects.create(host=instance, **link_data)
instance = update_links(instance, validated_data.get("links"))
instance.updated_by = self.context.get("request").user.username
......@@ -483,39 +521,74 @@ class ShowLinkSerializer(serializers.ModelSerializer):
class ShowSerializer(serializers.HyperlinkedModelSerializer):
category_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=Category.objects.all(), source="category"
many=True,
queryset=Category.objects.all(),
source="category",
help_text="Array of `Category` IDs.",
)
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."
)
cba_series_id = serializers.IntegerField(allow_null=True, required=False)
default_playlist_id = serializers.IntegerField(allow_null=True, required=False)
funding_category_id = serializers.PrimaryKeyRelatedField(
queryset=FundingCategory.objects.all(), source="funding_category"
queryset=FundingCategory.objects.all(),
source="funding_category",
help_text="`FundingCategory` ID.",
)
host_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=Host.objects.all(), source="hosts"
many=True,
queryset=Profile.objects.all(),
source="hosts",
help_text="`Profile` IDs that host this show.",
)
image_id = serializers.PrimaryKeyRelatedField(
allow_null=True, queryset=Image.objects.all(), required=False, source="image"
allow_null=True,
queryset=Image.objects.all(),
required=False,
source="image",
help_text="`Image` ID of this show.",
)
language_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=Language.objects.all(), source="language"
many=True,
queryset=Language.objects.all(),
source="language",
help_text="`Language` IDs of this show.",
)
links = HostLinkSerializer(many=True, required=False)
links = ProfileLinkSerializer(many=True, required=False, help_text="Array of `Link` objects.")
logo_id = serializers.PrimaryKeyRelatedField(
allow_null=True, queryset=Image.objects.all(), required=False, source="logo"
allow_null=True,
queryset=Image.objects.all(),
required=False,
source="logo",
help_text="`Image` ID of the logo of this show.",
)
music_focus_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=MusicFocus.objects.all(), source="music_focus"
many=True,
queryset=MusicFocus.objects.all(),
source="music_focus",
help_text="Array of `MusicFocus` IDs.",
)
owner_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=User.objects.all(), source="owners"
many=True,
queryset=User.objects.all(),
source="owners",
help_text="Array of `User` IDs owning this Show.",
)
predecessor_id = serializers.PrimaryKeyRelatedField(
allow_null=True, queryset=Show.objects.all(), required=False, source="predecessor"
allow_null=True,
queryset=Show.objects.all(),
required=False,
source="predecessor",
help_text="`Show` ID that predeceeded this one.",
)
topic_ids = serializers.PrimaryKeyRelatedField(
many=True, queryset=Topic.objects.all(), source="topic"
many=True, queryset=Topic.objects.all(), source="topic", help_text="Array of `Topic` IDs."
)
type_id = serializers.PrimaryKeyRelatedField(
queryset=Type.objects.all(), source="type", help_text="Array of `Type` IDs."
)
type_id = serializers.PrimaryKeyRelatedField(queryset=Type.objects.all(), source="type")
class Meta:
model = Show
......@@ -698,10 +771,7 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer):
# optional nested objects
if "links" in validated_data:
instance = delete_links(instance)
for link_data in validated_data.get("links"):
ShowLink.objects.create(show=instance, **link_data)
instance = update_links(instance, validated_data.get("links"))
instance.updated_by = self.context.get("request").user.username
......@@ -844,12 +914,18 @@ class DryRunTimeSlotSerializer(serializers.Serializer):
class ScheduleCreateUpdateRequestSerializer(serializers.Serializer):
schedule = ScheduleInRequestSerializer()
schedule = ScheduleInRequestSerializer(help_text="`Schedule` object.")
solutions = serializers.DictField(
child=serializers.ChoiceField(SOLUTION_CHOICES), required=False
child=serializers.ChoiceField(SOLUTION_CHOICES),
required=False,
help_text="Array of solution choices.",
)
notes = serializers.DictField(
child=serializers.IntegerField(), required=False, help_text="Array of `Note` objects."
)
playlists = serializers.DictField(
child=serializers.IntegerField(), required=False, help_text="Array of `Playlist` IDs."
)
notes = serializers.DictField(child=serializers.IntegerField(), required=False)
playlists = serializers.DictField(child=serializers.IntegerField(), required=False)
class ScheduleResponseSerializer(serializers.Serializer):
......@@ -874,10 +950,17 @@ class TimeSlotSerializer(serializers.ModelSerializer):
note_id = serializers.SerializerMethodField()
show_id = serializers.SerializerMethodField()
schedule_id = serializers.PrimaryKeyRelatedField(
queryset=Schedule.objects.all(), required=False, source="schedule"
queryset=Schedule.objects.all(),
required=False,
source="schedule",
help_text="`Schedule` ID of this timeslot.",
)
repetition_of_id = serializers.PrimaryKeyRelatedField(
allow_null=True, queryset=TimeSlot.objects.all(), required=False, source="repetition_of"
allow_null=True,
queryset=TimeSlot.objects.all(),
required=False,
source="repetition_of",
help_text="This timeslot is a repetition of `Timeslot` ID.",
)
class Meta:
......@@ -904,6 +987,14 @@ class TimeSlotSerializer(serializers.ModelSerializer):
def get_note_id(obj) -> int:
return obj.note.id if hasattr(obj, "note") else None
@staticmethod
def get_start(obj) -> datetime:
return obj.start.astimezone(tz=ZoneInfo(settings.TIME_ZONE))
@staticmethod
def get_end(obj) -> datetime:
return obj.end.astimezone(tz=ZoneInfo(settings.TIME_ZONE))
def update(self, instance, validated_data):
"""Update and return an existing Show instance, given the validated data."""
......@@ -935,12 +1026,17 @@ tags_json_schema = {"type": "array", "items": {"type": "string"}}
class NoteSerializer(serializers.ModelSerializer):
contributor_ids = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Host.objects.all(),
queryset=Profile.objects.all(),
required=False,
source="contributors",
help_text="`Profile` IDs that contributed to this episode.",
)
image_id = serializers.PrimaryKeyRelatedField(
queryset=Image.objects.all(), required=False, allow_null=True, source="image"
queryset=Image.objects.all(),
required=False,
allow_null=True,
source="image",
help_text="`Image` ID.",
)
language_ids = serializers.PrimaryKeyRelatedField(
allow_null=True,
......@@ -948,15 +1044,24 @@ class NoteSerializer(serializers.ModelSerializer):
queryset=Language.objects.all(),
required=False,
source="language",
help_text="Array of `Language` IDs.",
)
links = NoteLinkSerializer(many=True, required=False)
playlist_id = serializers.IntegerField(required=False)
tags = JSONSchemaField(tags_json_schema, required=False)
links = NoteLinkSerializer(many=True, required=False, help_text="Array of `Link` objects.")
playlist_id = serializers.IntegerField(required=False, help_text="Array of `Playlist` IDs.")
tags = JSONSchemaField(tags_json_schema, required=False, help_text="Tags of the Note.")
timeslot_id = serializers.PrimaryKeyRelatedField(
queryset=TimeSlot.objects.all(), required=False, source="timeslot"
queryset=TimeSlot.objects.all(),
required=False,
source="timeslot",
help_text="`Timeslot` ID.",
)
topic_ids = serializers.PrimaryKeyRelatedField(
allow_null=True, many=True, queryset=Topic.objects.all(), required=False, source="topic"
allow_null=True,
many=True,
queryset=Topic.objects.all(),
required=False,
source="topic",
help_text="Array of `Topic`IDs.",
)
class Meta:
......@@ -984,52 +1089,6 @@ class NoteSerializer(serializers.ModelSerializer):
"topic_ids",
) + 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", [])
show = validated_data["timeslot"].schedule.show
user = self.context.get("request").user
user_is_owner = user in show.owners.all()
# Having the create_note permission overrides the ownership
if not (
user.has_perm("program.create_note")
or (user.has_perm("program.add_note") and user_is_owner)
):
raise exceptions.PermissionDenied(detail="You are not allowed to create this note.")
# we derive `contributors`, `language` and `topic` from the Show's values if not set
contributors = validated_data.pop("contributors", show.hosts.values_list("id", flat=True))
language = validated_data.pop("language", show.language.values_list("id", flat=True))
topic = validated_data.pop("topic", show.topic.values_list("id", flat=True))
# optional foreign key
validated_data["image"] = validated_data.pop("image", None)
try:
note = Note.objects.create(
created_by=self.context.get("request").user.username,
**validated_data,
)
except IntegrityError:
raise exceptions.ValidationError(
code="duplicate", detail="note for this timeslot already exists."
)
else:
note.contributors.set(contributors)
note.language.set(language)
note.topic.set(topic)
# optional nested objects
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."""
......@@ -1077,10 +1136,7 @@ class NoteSerializer(serializers.ModelSerializer):
# optional nested objects
if "links" in validated_data:
instance = delete_links(instance)
for link_data in validated_data.get("links"):
NoteLink.objects.create(note=instance, **link_data)
instance = update_links(instance, validated_data.get("links"))
instance.updated_by = self.context.get("request").user.username
......@@ -1089,66 +1145,11 @@ class NoteSerializer(serializers.ModelSerializer):
return instance
class RadioCBASettings(TypedDict):
api_key: NotRequired[str]
domains: list[str]
class ProgrammeFallback(TypedDict):
default_pool: Literal["fallback"] | None
show_id: int | None
class MicroProgramme(TypedDict):
show_id: int | None
class RadioProgrammeSettings(TypedDict):
fallback: ProgrammeFallback
micro: MicroProgramme
class PlayoutPools(TypedDict):
fallback: str | None
class RadioPlayoutSettings(TypedDict):
line_in_channels: dict[str, str]
pools: PlayoutPools
class RadioStationSettings(TypedDict):
name: str
logo_id: int | None
website: str
class ImageFrame(TypedDict):
aspect_ratio: tuple[int, int]
shape: Literal["rect", "round"]
class ImageRequirements(TypedDict):
frame: ImageFrame
# done this way, because the keys have dots (".")
RadioImageRequirementsSettings = TypedDict(
"RadioImageRequirementsSettings",
{
"host.image": ImageRequirements,
"note.image": ImageRequirements,
"show.image": ImageRequirements,
"show.logo": ImageRequirements,
},
)
class RadioSettingsSerializer(serializers.ModelSerializer):
cba = serializers.SerializerMethodField()
image_requirements = serializers.SerializerMethodField()
playout = serializers.SerializerMethodField()
programme = serializers.SerializerMethodField()
program = serializers.SerializerMethodField()
station = serializers.SerializerMethodField()
class Meta:
......@@ -1157,86 +1158,288 @@ class RadioSettingsSerializer(serializers.ModelSerializer):
"cba",
"image_requirements",
"playout",
"programme",
"program",
"station",
)
model = RadioSettings
def get_cba(self, obj) -> RadioCBASettings:
if self.context.get("request").user.is_authenticated:
return {
"api_key": obj.cba_api_key,
"domains": obj.cba_domains,
}
return RadioCBASettings(
api_key=obj.cba_api_key,
domains=obj.cba_domains,
)
else:
return {"domains": obj.cba_domains}
return RadioCBASettings(domains=obj.cba_domains)
@staticmethod
def get_image_requirements(obj) -> RadioImageRequirementsSettings:
def get_aspect_ration(field) -> tuple[int, int]:
"""return the tuple of ints representing the aspect ratio of the image."""
def get_aspect_ratio(field) -> tuple[int, int] | tuple[float, float]:
"""return the tuple of ints or floats representing the aspect ratio of the image."""
values = field.split(":")
return int(values[0]), int(values[1])
try:
return int(values[0]), int(values[1])
except ValueError:
return float(values[0]), float(values[1])
aspect_ratios = {
"host.image": get_aspect_ration(obj.host_image_aspect_ratio),
"note.image": get_aspect_ration(obj.note_image_aspect_ratio),
"show.image": get_aspect_ration(obj.show_image_aspect_ratio),
"show.logo": get_aspect_ration(obj.show_logo_aspect_ratio),
"note.image": get_aspect_ratio(obj.note_image_aspect_ratio),
"profile.image": get_aspect_ratio(obj.profile_image_aspect_ratio),
"show.image": get_aspect_ratio(obj.show_image_aspect_ratio),
"show.logo": get_aspect_ratio(obj.show_logo_aspect_ratio),
}
return {
"host.image": {
"note.image": {
"frame": {
"aspect_ratio": aspect_ratios["host.image"],
"shape": obj.host_image_shape,
"aspect_ratio": aspect_ratios["note.image"],
"shape": obj.profile_image_shape,
}
},
"note.image": {
"profile.image": {
"frame": {
"aspect_ratio": aspect_ratios["note.image"],
"shape": obj.host_image_shape,
"aspect_ratio": aspect_ratios["profile.image"],
"shape": obj.profile_image_shape,
}
},
"show.image": {
"frame": {
"aspect_ratio": aspect_ratios["show.image"],
"shape": obj.host_image_shape,
"shape": obj.profile_image_shape,
}
},
"show.logo": {
"frame": {
"aspect_ratio": aspect_ratios["show.logo"],
"shape": obj.host_image_shape,
"shape": obj.profile_image_shape,
}
},
}
@staticmethod
def get_programme(obj) -> RadioProgrammeSettings:
return {
"micro": {"show_id": obj.micro_show.id if obj.micro_show else None},
"fallback": {
"show_id": obj.fallback_show.id if obj.fallback_show else None,
"default_pool": "fallback",
},
}
def get_program(obj) -> RadioProgramSettings:
return RadioProgramSettings(
micro=MicroProgram(show_id=obj.micro_show.id if obj.micro_show else None),
fallback=ProgramFallback(
show_id=obj.fallback_show.id if obj.fallback_show else None,
default_pool="fallback" if obj.fallback_default_pool else "",
),
)
@staticmethod
def get_playout(obj) -> RadioPlayoutSettings:
return {
"line_in_channels": obj.line_in_channels,
"pools": {
"fallback": obj.fallback_default_pool,
},
}
return RadioPlayoutSettings(
line_in_channels=obj.line_in_channels,
pools=obj.pools,
)
@staticmethod
def get_station(obj) -> RadioStationSettings:
return {
"name": obj.station_name,
"logo_id": obj.station_logo.id if obj.station_logo else None,
"website": obj.station_website,
}
logo = (
Logo(
url=f"{settings.SITE_URL}{obj.station_logo.url}",
height=obj.station_logo.height,
width=obj.station_logo.width,
)
if obj.station_logo
else None
)
return RadioStationSettings(
name=obj.station_name,
logo=logo,
website=obj.station_website,
)
class BasicProgramEntrySerializer(serializers.Serializer):
id = serializers.UUIDField()
start = serializers.DateTimeField()
end = serializers.DateTimeField()
timeslot_id = serializers.IntegerField(allow_null=True, source="timeslot.id")
playlist_id = serializers.IntegerField(allow_null=True)
show_id = serializers.IntegerField(source="show.id")
class PlayoutProgramEntrySerializer(BasicProgramEntrySerializer):
class PlayoutShowSerializer(serializers.ModelSerializer):
class Meta:
model = Show
fields = ["id", "name", "default_playlist_id"]
class PlayoutScheduleSerializer(serializers.ModelSerializer):
class Meta:
model = Schedule
fields = ["id", "default_playlist_id"]
class PlayoutEpisodeSerializer(serializers.ModelSerializer):
class Meta:
model = Note
fields = ["id", "title"]
timeslot = TimeSlotSerializer(allow_null=True)
show = PlayoutShowSerializer()
episode = PlayoutEpisodeSerializer(allow_null=True, source="timeslot.note")
schedule = PlayoutScheduleSerializer(allow_null=True, source="timeslot.schedule")
class CalendarSchemaSerializer(serializers.Serializer):
class Wrapper:
def __init__(self, program: list[ProgramEntry]):
self.program = program
@cached_property
def shows(self):
show_ids = set(entry.show.id for entry in self.program)
return Show.objects.distinct().filter(id__in=show_ids)
@cached_property
def timeslots(self):
timeslot_ids = set(entry.timeslot.id for entry in self.program if entry.timeslot)
return TimeSlot.objects.distinct().filter(id__in=timeslot_ids)
@cached_property
def episodes(self):
return Note.objects.distinct().filter(timeslot__in=self.timeslots)
@cached_property
def profiles(self):
return Profile.objects.distinct().filter(
Q(shows__in=self.shows) | Q(notes__in=self.episodes)
)
@property
def categories(self):
return Category.objects.distinct().filter(shows__in=self.shows)
@property
def funding_categories(self):
return FundingCategory.objects.distinct().filter(shows__in=self.shows)
@property
def types(self):
return Type.objects.distinct().filter(shows__in=self.shows)
@property
def topics(self):
return Topic.objects.distinct().filter(
Q(shows__in=self.shows) | Q(episodes__in=self.episodes)
)
@property
def languages(self):
return Language.objects.distinct().filter(
Q(shows__in=self.shows) | Q(episodes__in=self.episodes)
)
@property
def music_focuses(self):
return MusicFocus.objects.distinct().filter(shows__in=self.shows)
@cached_property
def images(self):
return Image.objects.distinct().filter(
Q(logo_shows__in=self.shows)
| Q(shows__in=self.shows)
| Q(profiles__in=self.profiles)
| Q(notes__in=self.episodes)
)
@property
def licenses(self):
return License.objects.distinct().filter(images__in=self.images)
@property
def link_types(self):
return LinkType.objects.all()
class CalendarTimeslotSerializer(TimeSlotSerializer):
class Meta(TimeSlotSerializer.Meta):
fields = [f for f in TimeSlotSerializer.Meta.fields if f != "memo"]
class CalendarEpisodeSerializer(NoteSerializer):
class Meta(NoteSerializer.Meta):
fields = [
field
for field in NoteSerializer.Meta.fields
if field not in ["created_at", "created_by", "updated_at", "updated_by"]
]
class CalendarProfileSerializer(ProfileSerializer):
class Meta(ProfileSerializer.Meta):
fields = [
field
for field in ProfileSerializer.Meta.fields
if field
not in [
"created_at",
"created_by",
"owner_ids",
"updated_at",
"updated_by",
]
]
class CalendarShowSerializer(ShowSerializer):
class Meta(ShowSerializer.Meta):
fields = [
field
for field in ShowSerializer.Meta.fields
if field
not in [
"created_at",
"created_by",
"internal_note",
"owner_ids",
"updated_at",
"updated_by",
]
]
shows = CalendarShowSerializer(many=True)
timeslots = CalendarTimeslotSerializer(many=True)
profiles = CalendarProfileSerializer(many=True)
categories = CategorySerializer(many=True)
funding_categories = FundingCategorySerializer(many=True)
types = TypeSerializer(many=True)
images = ImageSerializer(many=True)
topics = TopicSerializer(many=True)
languages = LanguageSerializer(many=True)
music_focuses = MusicFocusSerializer(many=True)
program = BasicProgramEntrySerializer(many=True)
episodes = CalendarEpisodeSerializer(many=True)
licenses = LicenseSerializer(many=True)
link_types = LinkTypeSerializer(many=True)
class ApplicationStatePurgeSerializer(serializers.Serializer):
@staticmethod
def _render_model_category_definitions():
yield "<dl>"
for category_name, models in application_state_manager.categorized_models.items():
model_names = ", ".join(sorted(model._meta.label for model in models))
yield f"<dt>{category_name}</dt>"
yield f"<dd>{model_names}</dd>"
yield "</dl>"
models = serializers.MultipleChoiceField(
choices=application_state_manager.model_choices, default=set()
)
model_categories = serializers.MultipleChoiceField(
choices=application_state_manager.model_category_choices,
default=set(),
help_text=(
"Selects multiple models by their categorization. "
"Models included in the categories are: "
f"{''.join(_render_model_category_definitions())}"
),
)
invert_selection = serializers.BooleanField(
default=False,
help_text=(
"Inverts the model selection that is selected through other filters. "
"Selects all models if set to true and no other filters have been set."
),
)
......@@ -18,9 +18,10 @@
#
import copy
import hashlib
import uuid
from collections.abc import Iterator
from datetime import datetime, time, timedelta
from itertools import pairwise
from typing import Literal, TypedDict
from dateutil.relativedelta import relativedelta
from dateutil.rrule import rrule
......@@ -31,8 +32,10 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q, QuerySet
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from program.exceptions import ConfigurationError
from program.models import (
Note,
ProgramEntry,
RadioSettings,
RRule,
Schedule,
......@@ -41,90 +44,10 @@ from program.models import (
TimeSlot,
)
from program.serializers import ScheduleSerializer, TimeSlotSerializer
from program.typing import Conflicts, ScheduleCreateUpdateData, ScheduleData
from program.utils import parse_date, parse_datetime, parse_time
class ScheduleData(TypedDict):
add_business_days_only: bool | None
add_days_no: int | None
by_weekday: int | None
default_playlist_id: int | None
dryrun: bool | None
end_time: str
first_date: str
id: int | None
is_repetition: bool | None
last_date: str | None
rrule_id: int
show_id: int | None
start_time: str
class Collision(TypedDict):
end: str
timeslot_id: int
memo: str
note_id: int | None
playlist_id: int | None
schedule_id: int
show_id: int
show_name: str
start: str
class ProjectedEntry(TypedDict):
collisions: list[Collision]
end: str
error: str | None
hash: str
solution_choices: set[str]
start: str
class Conflicts(TypedDict):
notes: dict
playlists: dict
projected: list[ProjectedEntry]
solutions: dict[str, str]
class ScheduleCreateUpdateData(TypedDict):
notes: dict
playlists: dict
schedule: ScheduleData
solutions: dict[str, str]
class ScheduleEntry(TypedDict):
end: str
is_virtual: bool
show_id: int
start: str
title: str
class TimeslotEntry(TypedDict):
end: str
id: int
is_virtual: Literal[False]
playlist_id: int | None
repetition_of_id: int | None
schedule_default_playlist_id: int | None
schedule_id: int
show_default_playlist_id: int | None
show_id: int
start: str
title: str
class VirtualTimeslotEntry(TypedDict):
end: str
is_virtual: Literal[True]
show_id: int
start: str
title: str
def create_timeslot(start: str, end: str, schedule: Schedule) -> TimeSlot:
"""Creates and returns an unsaved timeslot with the given `start`, `end` and `schedule`."""
......@@ -779,93 +702,96 @@ def generate_conflicts(timeslots: list[TimeSlot]) -> Conflicts:
return conflicts
def make_schedule_entry(*, timeslot_entry: TimeslotEntry) -> ScheduleEntry:
"""returns a schedule entry for the given timeslot entry."""
return {
"end": timeslot_entry["end"],
"show_id": timeslot_entry["show_id"],
"is_virtual": timeslot_entry["is_virtual"],
"start": timeslot_entry["start"],
"title": timeslot_entry["title"],
}
def make_timeslot_entry(*, timeslot: TimeSlot) -> TimeslotEntry:
"""returns a timeslot entry for the given timeslot."""
schedule = timeslot.schedule
show = timeslot.schedule.show
return {
"end": timeslot.end.strftime("%Y-%m-%dT%H:%M:%S %z"),
"id": timeslot.id,
"is_virtual": False,
"playlist_id": timeslot.playlist_id,
# 'timeslot.repetition_of` is a foreign key that can be null
"repetition_of_id": timeslot.repetition_of.id if timeslot.repetition_of else None,
"schedule_default_playlist_id": schedule.default_playlist_id,
"schedule_id": schedule.id,
"show_default_playlist_id": show.default_playlist_id,
"show_id": show.id,
"start": timeslot.start.strftime("%Y-%m-%dT%H:%M:%S %z"),
"title": f"{show.name} {_('REP')}" if schedule.is_repetition else show.name,
}
def make_virtual_timeslot_entry(*, gap_start: datetime, gap_end: datetime) -> VirtualTimeslotEntry:
"""returns a virtual timeslot entry to fill the gap in between `gap_start` and `gap_end`."""
return {
"end": gap_end.strftime("%Y-%m-%dT%H:%M:%S %z"),
"is_virtual": True,
"show_id": RadioSettings.objects.first().fallback_show.id,
"start": gap_start.strftime("%Y-%m-%dT%H:%M:%S %z"),
"title": RadioSettings.objects.first().fallback_default_pool,
}
def get_timerange_timeslot_entries(
timerange_start: datetime, timerange_end: datetime, include_virtual: bool = False
) -> list[TimeslotEntry | VirtualTimeslotEntry]:
"""Gets list of timeslot entries between the given `timerange_start` and `timerange_end`.
Include virtual timeslots if requested."""
timeslots = TimeSlot.objects.filter(
# start before `timerange_start` and end after `timerange_start`
Q(start__lt=timerange_start, end__gt=timerange_start)
# start after/at `timerange_start`, end before/at `timerange_end`
| Q(start__gte=timerange_start, end__lte=timerange_end)
# start before `timerange_end`, end after/at `timerange_end`
| Q(start__lt=timerange_end, end__gte=timerange_end)
).select_related("schedule")
if not include_virtual:
return [make_timeslot_entry(timeslot=timeslot) for timeslot in timeslots]
timeslot_entries = []
# gap before the first timeslot
first_timeslot = timeslots.first()
if first_timeslot.start > timerange_start:
timeslot_entries.append(
make_virtual_timeslot_entry(gap_start=timerange_start, gap_end=first_timeslot.start)
def get_fallback_show(raise_exceptions: bool = False):
radio_settings: RadioSettings | None = RadioSettings.objects.first()
fallback_show = radio_settings.fallback_show if radio_settings is not None else None
if raise_exceptions and fallback_show is None:
raise ConfigurationError(
"Radio settings must define a fallback show if include_virtual is True.",
code="no-fallback-show-defined",
)
return fallback_show
def uuid_from_string(data: str):
hash = hashlib.md5()
hash.update(data.encode())
return uuid.UUID(hex=hash.hexdigest(), version=4)
def generate_program_entries(
queryset: QuerySet[TimeSlot],
*,
start: datetime | None,
end: datetime | None,
include_virtual: bool,
cut_at_range_boundaries: bool,
) -> Iterator[ProgramEntry]:
"""Gets list of timerange entries between the given `timerange_start` and `timerange_end`.
Include virtual timerange entries if requested."""
def create_entry(
starts_at: datetime, ends_at: datetime, show: Show, timeslot: TimeSlot | None = None
):
entry_id = uuid_from_string(f"{starts_at.isoformat()}...{ends_at.isoformat()}")
if cut_at_range_boundaries:
starts_at = max(starts_at, start)
ends_at = min(ends_at, end)
return ProgramEntry(
id=entry_id,
start=starts_at,
end=ends_at,
timeslot=timeslot,
show=show,
)
for index, (current, upcoming) in enumerate(pairwise(timeslots)):
timeslot_entries.append(make_timeslot_entry(timeslot=current))
def create_timeslot_entry(timeslot: TimeSlot):
return create_entry(
timeslot.start,
timeslot.end,
timeslot.schedule.show,
timeslot,
)
# gap between the timeslots
if current.end != upcoming.start:
timeslot_entries.append(
make_virtual_timeslot_entry(gap_start=current.end, gap_end=upcoming.start)
)
if start is None:
start = timezone.now()
if end is None:
end = start + timedelta(days=1)
# gap after the last timeslot
last_timeslot = timeslots.last()
if last_timeslot.end < timerange_end:
timeslot_entries.append(
make_virtual_timeslot_entry(gap_start=last_timeslot.end, gap_end=timerange_end)
)
queryset = queryset.order_by("start")
# Find all timeslots that
# * have started before the specified start value but end after it
# * or end after the specified end value but start before it
timeslots = queryset.filter(end__gt=start, start__lt=end)
return timeslot_entries
if not include_virtual:
yield from (create_timeslot_entry(timeslot) for timeslot in timeslots)
return
# Program entries that are not based on scheduled timeslots are generated using the fallback
# show. We can only create these program entries if a fallback show has been specified.
fallback_show = get_fallback_show(raise_exceptions=True)
# Shift the range start/end to the closest scheduled timeslots around the specified range.
# This ensures that we generate stable ids for entries.
# We first check if the timeslots in our queryset might already start/end before/after the
# specified range, because we potentially include them according to the filter above.
first_ts_before_start = timeslots.first()
if not first_ts_before_start or first_ts_before_start.start > start:
first_ts_before_start = queryset.filter(end__lte=start).last()
first_ts_after_end = timeslots.last()
if not first_ts_after_end or first_ts_after_end.end < end:
first_ts_after_end = queryset.filter(start__gte=end).first()
range_start = first_ts_before_start.end if first_ts_before_start else start
range_end = first_ts_after_end.start if first_ts_after_end else end
entry_start = range_start
timeslot: TimeSlot
for timeslot in timeslots:
if timeslot.start > entry_start:
yield create_entry(entry_start, timeslot.start, fallback_show)
yield create_timeslot_entry(timeslot)
entry_start = timeslot.end
if entry_start < range_end:
yield create_entry(entry_start, range_end, fallback_show)
......@@ -7,15 +7,17 @@ from django.contrib.auth.models import Permission, User
from django.db.models import QuerySet
from django.utils.timezone import now
from program.models import (
CBA,
Category,
FundingCategory,
Host,
Image,
Language,
License,
LinkType,
MusicFocus,
Note,
Profile,
RadioSettings,
RRule,
Schedule,
Show,
......@@ -53,11 +55,11 @@ class UserWithPermissionsFactory(DjangoModelFactory):
self.user_permissions.add(permission)
class HostFactory(DjangoModelFactory):
class ProfileFactory(DjangoModelFactory):
class Meta:
model = Host
model = Profile
name = Sequence(lambda n: "host %d" % n)
name = Sequence(lambda n: "profile %d" % n)
class ImageFactory(DjangoModelFactory):
......@@ -152,3 +154,16 @@ class TopicFactory(DjangoModelFactory):
class OwnerFactory(DjangoModelFactory):
class Meta:
model = User
class RadioSettingsFactory(DjangoModelFactory):
class Meta:
model = RadioSettings
class CBAFactory(DjangoModelFactory):
class Meta:
model = CBA
username = Sequence(lambda n: "username_%d" % n)
user_token = Sequence(lambda n: "user_token_%d" % n)
from datetime import datetime, timedelta
from itertools import pairwise
import pytest
from conftest import create_daily_schedule
pytestmark = pytest.mark.django_db
def url(include_virtual=False, start=None, end=None):
if include_virtual and start and end:
return f"/api/v1/program/basic/?include_virtual=true&start={start}&end={end}"
elif start and end:
return f"/api/v1/program/basic/?start={start}&end={end}"
elif include_virtual:
return "/api/v1/program/basic/?include_virtual=true"
else:
return "/api/v1/program/basic/"
def assert_entry(entry, show) -> None:
"""asserts the playout entry corresponds to the given show."""
assert entry["end"]
assert entry["id"]
assert "playlistId" in entry
assert entry["start"]
assert entry["timeslotId"]
assert entry["showId"] == show.id
def assert_virtual_entry(entry, fallback_show) -> None:
"""asserts the playout entry is virtual and corresponds to given fallback show."""
assert entry["end"]
assert entry["id"]
assert "playlistId" in entry
assert entry["start"]
assert not entry["timeslotId"]
assert entry["showId"] == fallback_show.id
def test_basic(admin_api_client, api_client, daily_rrule, show):
create_daily_schedule(admin_api_client, daily_rrule, show)
response = api_client.get(url())
assert response.status_code == 200
assert len(response.json()) == 2
for entry in response.json():
assert_entry(entry, show)
def test_basic_one_week(admin_api_client, api_client, daily_rrule, show):
create_daily_schedule(admin_api_client, daily_rrule, show)
now = datetime.now()
in_one_week = now + timedelta(days=7)
response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat()))
assert response.status_code == 200
assert len(response.json()) == 6 or 7 # I’m not sure why, but this changes around midnight.
for entry in response.json():
assert_entry(entry, show)
def test_basic_include_virtual(
admin_api_client,
api_client,
daily_rrule,
show,
fallback_show,
radio_settings,
):
create_daily_schedule(admin_api_client, daily_rrule, show)
response = api_client.get(url(include_virtual=True))
assert response.status_code == 200
assert len(response.json()) == 3
entry1, virtual_entry, entry2 = response.json()
assert_entry(entry1, show)
assert_virtual_entry(virtual_entry, fallback_show)
assert_entry(entry2, show)
assert entry1["end"] == virtual_entry["start"]
assert virtual_entry["end"] == entry2["start"]
def test_basic_one_week_include_virtual(
admin_api_client,
api_client,
daily_rrule,
show,
fallback_show,
radio_settings,
):
create_daily_schedule(admin_api_client, daily_rrule, show)
now = datetime.now()
in_one_week = now + timedelta(days=7)
response = api_client.get(
url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat())
)
assert response.status_code == 200
assert len(response.json()) == 13 or 15 # I’m not sure why, but this changes around midnight.
entries = response.json()
for entry in entries[0::2]:
assert_entry(entry, show)
for virtual_entry in entries[1::2]:
assert_virtual_entry(virtual_entry, fallback_show)
for entry1, entry2 in pairwise(entries):
assert entry1["end"] == entry2["start"]
from datetime import datetime, timedelta
import pytest
from conftest import create_daily_schedule
pytestmark = pytest.mark.django_db
def url(include_virtual=False, start=None, end=None):
if include_virtual and start and end:
return f"/api/v1/program/calendar/?include_virtual=true&start={start}&end={end}"
elif start and end:
return f"/api/v1/program/calendar/?start={start}&end={end}"
elif include_virtual:
return "/api/v1/program/calendar/?include_virtual=true"
else:
return "/api/v1/program/calendar/"
def assert_episodes(episodes, one_week=False) -> None:
"""asserts the episodes are valid."""
assert len(episodes) == 2 if not one_week else 7
for episode in episodes:
assert episode["id"]
assert episode["timeslotId"]
def assert_program(program, show, fallback_show=None, one_week=False) -> None:
"""asserts the program are valid and correspond to the given show and fallback show."""
assert len(program) == 2 if fallback_show is None and not one_week else (7 if one_week else 3)
for entry in program:
assert entry["end"]
assert entry["id"]
assert entry["start"]
if fallback_show is None:
for entry in program:
assert entry["showId"] == show.id
assert entry["timeslotId"]
else:
for entry in program:
if entry["timeslotId"]:
assert entry["showId"] == show.id
assert entry["timeslotId"]
else:
assert entry["showId"] == fallback_show.id
def assert_shows(shows, show, fallback_show=None) -> None:
"""asserts the shows are valid correspond to the given show and fallback show."""
assert len(shows) == 1 if fallback_show is None else 2
if fallback_show is None:
assert shows[0]["id"] == show.id
assert shows[0]["name"] == show.name
else:
assert shows[0]["id"] == show.id
assert shows[0]["name"] == show.name
assert shows[1]["id"] == fallback_show.id
assert shows[1]["name"] == fallback_show.name
def assert_timeslots(timeslots, show, one_week=False) -> None:
"""asserts the timeslots are valid and correspond to the given show."""
assert len(timeslots) == 2 if not one_week else 7
for timeslot in timeslots:
assert timeslot["id"]
assert timeslot["noteId"]
assert timeslot["end"]
assert timeslot["start"]
assert timeslot["showId"] == show.id
def test_calendar(admin_api_client, api_client, daily_rrule, show):
create_daily_schedule(admin_api_client, daily_rrule, show)
response = api_client.get(url())
assert response.status_code == 200
for key, value in response.json().items():
match key:
case "episodes":
assert_episodes(value)
case "program":
assert_program(value, show)
case "shows":
assert_shows(value, show)
case "timeslots":
assert_timeslots(value, show)
def test_calendar_one_week(admin_api_client, api_client, daily_rrule, show):
create_daily_schedule(admin_api_client, daily_rrule, show)
now = datetime.now()
in_one_week = now + timedelta(days=7)
response = api_client.get(url(start=now.isoformat(), end=in_one_week.isoformat()))
assert response.status_code == 200
for key, value in response.json().items():
match key:
case "episodes":
assert_episodes(value, one_week=True)
case "program":
assert_program(value, show, one_week=True)
case "shows":
assert_shows(value, show)
case "timeslots":
assert_timeslots(value, show, one_week=True)
def test_calendar_include_virtual(
admin_api_client,
api_client,
daily_rrule,
show,
fallback_show,
radio_settings,
):
create_daily_schedule(admin_api_client, daily_rrule, show)
response = api_client.get(url(include_virtual=True))
assert response.status_code == 200
for key, value in response.json().items():
match key:
case "episodes":
assert_episodes(value)
case "program":
assert_program(value, show, fallback_show)
case "shows":
assert_shows(value, show, fallback_show)
case "timeslots":
assert_timeslots(value, show)
def test_calendar_one_week_include_virtual(
admin_api_client,
api_client,
daily_rrule,
show,
fallback_show,
radio_settings,
):
create_daily_schedule(admin_api_client, daily_rrule, show)
now = datetime.now()
in_one_week = now + timedelta(days=7)
response = api_client.get(
url(include_virtual=True, start=now.isoformat(), end=in_one_week.isoformat())
)
assert response.status_code == 200
for key, value in response.json().items():
match key:
case "episodes":
assert_episodes(value, one_week=True)
case "program":
assert_program(value, show=show, fallback_show=fallback_show, one_week=True)
case "shows":
assert_shows(value, show=show, fallback_show=fallback_show)
case "timeslots":
assert_timeslots(value, show=show, one_week=True)