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

Target

Select target project
  • aura/steering
  • kmohrf/steering
2 results
Show changes
import pytest
from django.core.management import call_command
pytestmark = pytest.mark.django_db
# adapted from https://adamj.eu/tech/2024/06/23/django-test-pending-migrations/ for pytest
def test_no_pending_migrations(capsys):
try:
call_command("makemigrations", "--dry-run", "--check")
except SystemExit: # pragma: no cover
out, _ = capsys.readouterr()
raise AssertionError("Pending migrations:\n" + out) from None
import pytest
from program.models import Note, TimeSlot
from conftest import assert_data
from program.models import LinkType, TimeSlot
from program.tests.factories import NoteFactory
pytestmark = pytest.mark.django_db
......@@ -13,8 +14,17 @@ def url(note=None):
return "/api/v1/notes/"
def note_data(timeslot: TimeSlot) -> dict[str, str | int]:
return {"content": "CONTENT", "title": "TITLE", "timeslot_id": timeslot.id}
def note_data(timeslot: TimeSlot, link_type: LinkType = None) -> dict[str, str | int]:
data = {
"content": "CONTENT",
"title": "TITLE",
"timeslot_id": timeslot.id,
}
if link_type:
data["links"] = [{"type_id": link_type.id, "url": "https://aura.radio"}]
return data
def test_read_notes_as_unauthenticated_user(api_client):
......@@ -27,39 +37,6 @@ def test_read_notes_as_unauthenticated_user(api_client):
assert len(response.data) == NOTES
@pytest.mark.django_db(transaction=True)
def test_create_note_for_owned_show_fails_if_it_exists(
user_with_note_perms, api_client_note_perms, owned_show_once_timeslot_perms
):
data = note_data(timeslot=owned_show_once_timeslot_perms)
response = api_client_note_perms.post(url(), data=data)
assert response.status_code == 400
def test_create_note_for_owned_show(
user_with_note_perms, api_client_note_perms, owned_show_once_timeslot_perms
):
Note.objects.get(pk=owned_show_once_timeslot_perms.note.id).delete()
data = note_data(timeslot=owned_show_once_timeslot_perms)
response = api_client_note_perms.post(url(), data=data)
assert response.status_code == 201
def test_create_note_not_found_for_not_owned_show(
user_with_note_perms, api_client_note_perms, show_once_timeslot
):
data = note_data(timeslot=show_once_timeslot)
response = api_client_note_perms.patch(url(note=show_once_timeslot.note), data=data)
assert response.status_code == 404
def test_update_note_for_owned_show(
user_with_note_perms, api_client_note_perms, owned_show_once_timeslot_perms
):
......@@ -71,6 +48,8 @@ def test_update_note_for_owned_show(
assert response.status_code == 200
assert_data(response, update)
def test_update_note_not_found_for_not_owned_show(
user_with_note_perms, api_client_note_perms, once_timeslot
......@@ -82,17 +61,18 @@ def test_update_note_not_found_for_not_owned_show(
assert response.status_code == 404
def test_delete_note_for_owned_show(
user_with_note_perms, api_client_note_perms, owned_show_once_timeslot_perms
def test_update_note_links(
user_with_note_perms,
api_client_note_perms,
owned_show_once_timeslot_perms,
link_type,
):
response = api_client_note_perms.delete(url(note=owned_show_once_timeslot_perms.note))
assert response.status_code == 204
update = note_data(timeslot=owned_show_once_timeslot_perms, link_type=link_type)
response = api_client_note_perms.patch(
url(note=owned_show_once_timeslot_perms.note), data=update, format="json"
)
def test_delete_note_not_found_for_not_owned_show(
user_with_note_perms, api_client_note_perms, once_timeslot
):
response = api_client_note_perms.delete(url(note=once_timeslot.note))
assert response.status_code == 200
assert response.status_code == 404
assert_data(response, update)
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/playout/?include_virtual=true&start={start}&end={end}"
elif start and end:
return f"/api/v1/program/playout/?start={start}&end={end}"
elif include_virtual:
return "/api/v1/program/playout/?include_virtual=true"
else:
return "/api/v1/program/playout/"
def assert_entry(entry, show) -> None:
"""asserts the playout entry corresponds to the given show."""
assert entry["episode"]
assert entry["schedule"]
assert entry["show"]
assert entry["timeslot"]
assert entry["timeslotId"]
assert entry["showId"] == entry["show"]["id"] == show.id
assert entry["show"]["name"] == show.name
def assert_virtual_entry(entry, fallback_show) -> None:
"""asserts the playout entry is virtual and corresponds to given fallback show."""
assert entry["show"]
assert not entry["episode"]
assert not entry["schedule"]
assert not entry["timeslot"]
assert not entry["timeslotId"]
assert entry["showId"] == entry["show"]["id"] == fallback_show.id
assert entry["show"]["name"] == fallback_show.name
def test_playout(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_playout_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_playout_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(entry1, show)
assert entry1["end"] == virtual_entry["start"]
assert virtual_entry["end"] == entry2["start"]
def test_playout_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"]
import pytest
from conftest import assert_data
from program.tests.factories import HostFactory
from program.models import LinkType
from program.tests.factories import ProfileFactory
pytestmark = pytest.mark.django_db
def url(host=None) -> str:
return f"/api/v1/hosts/{host.id}/" if host else "/api/v1/hosts/"
def url(profile=None) -> str:
return f"/api/v1/profiles/{profile.id}/" if profile else "/api/v1/profiles/"
def host_data(image=None) -> dict[str, str | int]:
def profile_data(image=None, link_type: LinkType = None) -> dict[str, str | int]:
data = {
"biography": "BIOGRAPHY",
"email": "host@aura.radio",
......@@ -20,11 +21,14 @@ def host_data(image=None) -> dict[str, str | int]:
if image:
data["image_id"] = image.id
if link_type:
data["links"] = [{"type_id": link_type.id, "url": "https://aura.radio"}]
return data
def test_create_host(admin_api_client):
data = host_data()
def test_create_profile(admin_api_client):
data = profile_data()
response = admin_api_client.post(url(), data=data)
......@@ -33,55 +37,65 @@ def test_create_host(admin_api_client):
assert_data(response, data)
def test_create_host_forbidden_for_common_user(common_api_client1):
data = host_data()
def test_create_profile_forbidden_for_common_user(common_api_client1):
data = profile_data()
response = common_api_client1.post(url(), data=data)
assert response.status_code == 403
def test_delete_host(admin_api_client, host):
response = admin_api_client.delete(url(host))
def test_delete_profile(admin_api_client, profile):
response = admin_api_client.delete(url(profile))
assert response.status_code == 204
def test_delete_host_forbidden_for_common_user(common_api_client1, host):
response = common_api_client1.delete(url(host))
def test_delete_profile_forbidden_for_common_user(common_api_client1, profile):
response = common_api_client1.delete(url(profile))
assert response.status_code == 403
def test_list_hosts(admin_api_client):
def test_list_profile(admin_api_client):
HOSTS = 3
HostFactory.create_batch(size=HOSTS)
ProfileFactory.create_batch(size=HOSTS)
response = admin_api_client.get(url())
assert len(response.data) == HOSTS
def test_retrieve_host(api_client, host):
response = api_client.get(url(host))
def test_retrieve_profile(api_client, profile):
response = api_client.get(url(profile))
assert response.status_code == 200
def test_update_host(admin_api_client, host, image):
update = host_data(image)
def test_update_profile(admin_api_client, profile, image):
update = profile_data(image)
update["is_active"] = False
response = admin_api_client.put(url(host), data=update)
response = admin_api_client.put(url(profile), data=update)
assert response.status_code == 200
assert_data(response, update)
def test_update_host_profile(admin_api_client, profile, link_type):
update = profile_data(link_type=link_type)
response = admin_api_client.patch(url(profile), data=update, format="json")
assert response.status_code == 200
assert_data(response, update)
def test_update_host_forbidden_for_common_user(common_api_client1, host):
update = host_data()
def test_update_profile_forbidden_for_common_user(common_api_client1, profile):
update = profile_data()
response = common_api_client1.put(url(host), data=update)
response = common_api_client1.put(url(profile), data=update)
assert response.status_code == 403
import pytest
pytestmark = pytest.mark.django_db
def url(model_categories=None):
base_url = "/api/v1/debug/application-state/"
if model_categories:
return f"{base_url}?modelCategories={model_categories}"
return base_url
def assert_value(response, key: str, value: int) -> None:
assert response.data[key] == value
def test_reset_auth(admin_api_client, common_user1, cba):
response = admin_api_client.delete(url(model_categories="auth"))
assert response.status_code == 200
assert_value(response, "auth.User", 2)
assert_value(response, "program.CBA", 1)
def test_reset_classifications(
admin_api_client,
category,
daily_rrule,
funding_category,
language,
link_type,
music_focus,
public_domain_license,
topic,
type_,
):
response = admin_api_client.delete(url(model_categories="classifications"))
assert response.status_code == 200
assert_value(response, "program.Category", 1)
assert_value(response, "program.FundingCategory", 1)
assert_value(response, "program.Language", 1)
assert_value(response, "program.License", 1)
assert_value(response, "program.LinkType", 1)
assert_value(response, "program.MusicFocus", 1)
assert_value(response, "program.RRule", 1)
assert_value(response, "program.Topic", 1)
assert_value(response, "program.Type", 1)
def test_reset_media(admin_api_client, image):
response = admin_api_client.delete(url(model_categories="media"))
assert response.status_code == 200
assert_value(response, "program.Image", 1)
def test_reset_program(admin_api_client, show, once_schedule, show_once_timeslot):
response = admin_api_client.delete(url(model_categories="program"))
assert response.status_code == 200
assert_value(response, "program.Note", 1)
assert_value(response, "program.Schedule", 1)
assert_value(response, "program.Show", 1)
assert_value(response, "program.TimeSlot", 1)
def test_reset_settings(admin_api_client, radio_settings):
response = admin_api_client.delete(url(model_categories="settings"))
assert response.status_code == 200
assert_value(response, "program.RadioSettings", 1)
......@@ -240,7 +240,7 @@ def test_patch_set_is_repetition_true(admin_api_client, once_schedule):
update = {"is_repetition": "true"}
response = admin_api_client.patch(url(schedule=once_schedule), data=update)
print(response.request.items())
assert response.status_code == 200
assert response.data["is_repetition"] is True
......
......@@ -38,7 +38,7 @@ def test_create_show(
admin_api_client,
category,
funding_category,
host,
profile,
language,
link_type,
music_focus,
......@@ -47,7 +47,7 @@ def test_create_show(
type_,
):
data = show_data(
category, funding_category, host, language, link_type, music_focus, owner, topic, type_
category, funding_category, profile, language, link_type, music_focus, owner, topic, type_
)
response = admin_api_client.post(url(), data=data, format="json")
......@@ -61,7 +61,7 @@ def test_create_show_forbidden_for_common_user(
common_api_client1,
category,
funding_category,
host,
profile,
language,
link_type,
music_focus,
......@@ -70,7 +70,7 @@ def test_create_show_forbidden_for_common_user(
type_,
):
data = show_data(
category, funding_category, host, language, link_type, music_focus, owner, topic, type_
category, funding_category, profile, language, link_type, music_focus, owner, topic, type_
)
response = common_api_client1.post(url(), data=data, format="json")
......@@ -112,7 +112,7 @@ def test_update_show(
admin_api_client,
category,
funding_category,
host,
profile,
language,
link_type,
music_focus,
......@@ -122,7 +122,7 @@ def test_update_show(
show,
):
update = show_data(
category, funding_category, host, language, link_type, music_focus, owner, topic, type_
category, funding_category, profile, language, link_type, music_focus, owner, topic, type_
)
response = admin_api_client.patch(url(show), data=update, format="json")
......@@ -136,7 +136,7 @@ def test_update_show_links(
admin_api_client,
category,
funding_category,
host,
profile,
language,
link_type,
music_focus,
......@@ -146,7 +146,7 @@ def test_update_show_links(
show,
):
update = show_data(
category, funding_category, host, language, link_type, music_focus, owner, topic, type_
category, funding_category, profile, language, link_type, music_focus, owner, topic, type_
)
response = admin_api_client.patch(url(show), data=update, format="json")
......@@ -160,7 +160,7 @@ def test_update_show_forbidden_for_common_user(
common_api_client1,
category,
funding_category,
host,
profile,
language,
link_type,
music_focus,
......@@ -170,7 +170,7 @@ def test_update_show_forbidden_for_common_user(
show,
):
update = show_data(
category, funding_category, host, language, link_type, music_focus, owner, topic, type_
category, funding_category, profile, language, link_type, music_focus, owner, topic, type_
)
response = common_api_client1.patch(url(show), data=update, format="json")
......
......@@ -10,7 +10,7 @@ def url(user=None) -> str:
return f"/api/v1/users/{user.id}/" if user else "/api/v1/users/"
def user_data(is_superuser=False, add_profile=False) -> dict[str, str | dict[str, str]]:
def user_data(is_superuser=False, add_cba=False) -> dict[str, str | dict[str, str]]:
data = {
"password": "password",
"username": "user",
......@@ -19,10 +19,10 @@ def user_data(is_superuser=False, add_profile=False) -> dict[str, str | dict[str
if is_superuser:
data["is_superuser"] = is_superuser
if add_profile:
data["profile"] = {
"cba_username": "CBA USERNAME",
"cba_user_token": "CBA USER TOKEN",
if add_cba:
data["cba"] = {
"username": "CBA USERNAME",
"user_token": "CBA USER TOKEN",
}
return data
......@@ -31,7 +31,7 @@ def user_data(is_superuser=False, add_profile=False) -> dict[str, str | dict[str
def test_create_user(admin_api_client):
data = user_data()
response = admin_api_client.post(url(), data=data)
response = admin_api_client.post(url(), data=data, format="json")
assert response.status_code == 201
......@@ -41,7 +41,7 @@ def test_create_user(admin_api_client):
def test_create_superuser(admin_api_client):
data = user_data(is_superuser=True)
response = admin_api_client.post(url(), data=data)
response = admin_api_client.post(url(), data=data, format="json")
assert response.status_code == 201
......
from typing import Literal, NotRequired, TypedDict
class NestedTimeslot(TypedDict):
end: str
id: int | None
is_virtual: bool
memo: str
playlist_id: int | None
repetition_of_id: int | None
start: str
class NestedShow(TypedDict):
default_playlist_id: int | None
id: int
name: str
class NestedSchedule(TypedDict):
id: int | None
default_playlist_id: int | None
class NestedEpisode(TypedDict):
id: int | None
title: str
class DayScheduleEntry(TypedDict):
episode: NestedEpisode
timeslot: NestedTimeslot
show: NestedShow
class TimerangeEntry(TypedDict):
episode: NestedEpisode
schedule: NestedSchedule | None
show: NestedShow
timeslot: NestedTimeslot
class Thumbnail(TypedDict):
width: float
height: float
url: str
class RadioCBASettings(TypedDict):
api_key: NotRequired[str]
domains: list[str]
class ProgramFallback(TypedDict):
default_pool: str
show_id: int | None
class MicroProgram(TypedDict):
show_id: int | None
class RadioProgramSettings(TypedDict):
fallback: ProgramFallback
micro: MicroProgram
class PlayoutPools(TypedDict):
fallback: str | None
class RadioPlayoutSettings(TypedDict):
line_in_channels: dict[str, str]
pools: PlayoutPools
class Logo(TypedDict):
url: str
height: int
width: int
class RadioStationSettings(TypedDict):
name: str
logo: Logo | None
website: str
class ImageFrame(TypedDict):
aspect_ratio: tuple[int, int] | tuple[float, float]
shape: Literal["rect", "round"]
class ImageRequirements(TypedDict):
frame: ImageFrame
# done this way, because the keys have dots (".")
RadioImageRequirementsSettings = TypedDict(
"RadioImageRequirementsSettings",
{
"note.image": ImageRequirements,
"profile.image": ImageRequirements,
"show.image": ImageRequirements,
"show.logo": ImageRequirements,
},
)
class Link(TypedDict):
type_id: int
url: str
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]
......@@ -27,9 +27,12 @@ import requests
from django.conf import settings
from django.utils import timezone
from program.typing import Link
if typing.TYPE_CHECKING:
from program.models import Host, Note, Show
from program.models import Note, NoteLink, Profile, ProfileLink, Show, ShowLink
else:
from program.models import Note, NoteLink, Profile, ProfileLink, Show, ShowLink
def parse_datetime(date_string: str | None) -> datetime | None:
......@@ -134,11 +137,26 @@ def get_values(
return int_if_digit(values[0])
def delete_links(instance: Union["Host", "Note", "Show"]) -> Union["Host", "Note", "Show"]:
"""Delete the links associated with the instance."""
def update_links(
instance: Union["Profile", "Note", "Show"], links: list[Link]
) -> Union["Profile", "Note", "Show"]:
"""Update the links associated with the instance"""
# delete the links associated with the instance
if instance.links.count() > 0:
for link in instance.links.all():
link.delete(keep_parents=True)
if isinstance(instance, Profile):
for link_data in links:
ProfileLink.objects.create(profile=instance, **link_data)
if isinstance(instance, Note):
for link_data in links:
NoteLink.objects.create(note=instance, **link_data)
if isinstance(instance, Show):
for link_data in links:
ShowLink.objects.create(show=instance, **link_data)
return instance
......@@ -19,11 +19,12 @@
#
import logging
from datetime import date, datetime, time, timedelta
from datetime import date, datetime
from textwrap import dedent
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import (
OpenApiExample,
OpenApiParameter,
OpenApiResponse,
OpenApiTypes,
......@@ -32,8 +33,9 @@ from drf_spectacular.utils import (
)
from rest_framework import decorators
from rest_framework import filters as drf_filters
from rest_framework import mixins, permissions, status, viewsets
from rest_framework import mixins, permissions, status, views, viewsets
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.request import Request
from rest_framework.response import Response
from django.conf import settings
......@@ -46,13 +48,13 @@ from program import filters
from program.models import (
Category,
FundingCategory,
Host,
Image,
Language,
License,
LinkType,
MusicFocus,
Note,
Profile,
RadioSettings,
RRule,
Schedule,
......@@ -61,12 +63,15 @@ from program.models import (
TimeSlot,
Topic,
Type,
application_state_manager,
)
from program.serializers import (
ApplicationStatePurgeSerializer,
BasicProgramEntrySerializer,
CalendarSchemaSerializer,
CategorySerializer,
ErrorSerializer,
FundingCategorySerializer,
HostSerializer,
ImageRenderSerializer,
ImageSerializer,
LanguageSerializer,
......@@ -74,6 +79,8 @@ from program.serializers import (
LinkTypeSerializer,
MusicFocusSerializer,
NoteSerializer,
PlayoutProgramEntrySerializer,
ProfileSerializer,
RadioSettingsSerializer,
RRuleSerializer,
ScheduleConflictResponseSerializer,
......@@ -87,100 +94,440 @@ from program.serializers import (
TypeSerializer,
UserSerializer,
)
from program.services import (
get_timerange_timeslot_entries,
make_schedule_entry,
resolve_conflicts,
)
from program.utils import get_values, parse_date
from program.services import resolve_conflicts
from program.utils import get_values
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List schedule for a specific date.",
description=(
"Returns a list of the schedule for a specific date."
"Expects parameters `year` (int), `month` (int), and `day` (int) as url components."
"e.g. /program/2024/01/31/"
),
),
)
class APIDayScheduleViewSet(
class AbstractAPIProgramViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
filterset_class = filters.VirtualTimeslotFilterSet
queryset = TimeSlot.objects.all()
serializer_class = TimeSlotSerializer
def list(self, request, year=None, month=None, day=None):
# datetime.combine returns a timezone naive datetime object
if year is None and month is None and day is None:
start = timezone.make_aware(datetime.combine(timezone.now(), time(0, 0)))
else:
start = timezone.make_aware(datetime.combine(date(year, month, day), time(0, 0)))
end = start + timedelta(hours=24)
include_virtual = request.GET.get("include_virtual") == "true"
schedule = [
make_schedule_entry(timeslot_entry=timeslot_entry)
for timeslot_entry in get_timerange_timeslot_entries(start, end, include_virtual)
]
return JsonResponse(schedule, safe=False)
@extend_schema_view(
list=extend_schema(
examples=[
OpenApiExample(
"Example entry",
value={
"end": "2024-07-15T15:30:00-04:00",
"id": "2024-07-15T19:07:38.604349+00:00...2024-07-15T19:30:00+00:00",
"playlistId": None,
"showId": 1,
"start": "2024-07-15T15:07:38.604349-04:00",
"timeslotId": None,
},
),
OpenApiExample(
"Example virtual entry",
value={
"end": "2024-07-15T18:00:00-04:00",
"id": "2024-07-15T19:30:00+00:00...2024-07-15T22:00:00+00:00",
"playlistId": None,
"showId": 3,
"start": "2024-07-15T15:30:00-04:00",
"timeslotId": 141,
},
),
],
summary=(
"List program for a specific date range. "
"Only returns the most basic data for clients that fetch other data themselves."
),
),
)
class APIProgramBasicViewSet(AbstractAPIProgramViewSet):
serializer_class = BasicProgramEntrySerializer
@extend_schema_view(
list=extend_schema(
summary="List scheduled playout.",
description=(
"Returns a list of the scheduled playout. "
"The schedule will include virtual timeslots to fill unscheduled gaps if requested."
examples=[
OpenApiExample(
"Example entry",
value={
"end": "2024-07-31T12:15:00-04:00",
"episode": {"id": 6, "title": ""},
"id": "44b26957-fa84-4704-89dd-308e26b00556",
"playlistId": None,
"schedule": {"defaultPlaylistId": None, "id": 1},
"show": {"defaultPlaylistId": None, "id": 1, "name": "EINS"},
"showId": 1,
"start": "2024-07-31T11:00:00-04:00",
"timeslot": {
"end": "2024-07-31T12:15:00-04:00",
"id": 6,
"memo": "",
"noteId": 6,
"playlistId": None,
"repetitionOfId": None,
"scheduleId": 1,
"showId": 1,
"start": "2024-07-31T11:00:00-04:00",
},
"timeslotId": 6,
},
),
OpenApiExample(
"Example virtual entry",
value={
"end": "2024-08-01T11:00:00-04:00",
"episode": None,
"id": "5e8a3075-b5d6-40c8-97d1-5ee11d8a090d",
"playlistId": None,
"schedule": None,
"show": {"defaultPlaylistId": None, "id": 2, "name": "Musikpool"},
"showId": 2,
"start": "2024-07-31T12:15:00-04:00",
"timeslot": None,
"timeslotId": None,
},
),
],
summary=(
"List program for a specific date range. "
"Returns an extended program dataset for use in the AURA engine. "
"Not recommended for other tools."
),
),
)
class APIPlayoutViewSet(
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
queryset = TimeSlot.objects.all()
serializer_class = TimeSlotSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = filters.PlayoutFilterSet
class APIProgramPlayoutViewSet(AbstractAPIProgramViewSet):
serializer_class = PlayoutProgramEntrySerializer
def list(self, request, *args, **kwargs):
"""
Return a JSON representation of the scheduled playout.
Called by
- engine (playout) to retrieve timeslots within a given timerange
- internal calendar to retrieve all timeslots for a week
"""
# datetime.now and datetime.combine return timezone naive datetime objects
if request.GET.get("start") is None:
schedule_start = timezone.make_aware(datetime.combine(datetime.now(), time(0, 0)))
else:
schedule_start = timezone.make_aware(
datetime.combine(parse_date(request.GET.get("start")), time(0, 0))
)
class APIProgramCalendarViewSet(AbstractAPIProgramViewSet):
serializer_class = CalendarSchemaSerializer
if request.GET.get("end") is None:
schedule_end = schedule_start + timedelta(days=7)
else:
schedule_end = timezone.make_aware(
datetime.combine(
parse_date(request.GET.get("end")) + timedelta(days=1), time(0, 0)
)
@extend_schema(
examples=[
OpenApiExample(
"Example entry with virtual timeslots",
value={
"shows": [
{
"categoryIds": [5],
"cbaSeriesId": None,
"defaultPlaylistId": None,
"description": "",
"email": "ernesto@helsinki.at",
"fundingCategoryId": 1,
"hostIds": [1],
"id": 4,
"imageId": None,
"isActive": True,
"isPublic": True,
"languageIds": [1, 3],
"links": [{"typeId": 1, "url": "https://helsinki.at"}],
"logoId": None,
"musicFocusIds": [4],
"name": "DREI",
"predecessorId": None,
"shortDescription": "chores",
"slug": "drei",
"topicIds": [5],
"typeId": 2,
},
{
"categoryIds": [],
"cbaSeriesId": None,
"defaultPlaylistId": None,
"description": "",
"email": "",
"fundingCategoryId": 1,
"hostIds": [1],
"id": 2,
"imageId": None,
"isActive": True,
"isPublic": True,
"languageIds": [],
"links": [],
"logoId": None,
"musicFocusIds": [],
"name": "EINS",
"predecessorId": None,
"shortDescription": "shallow work",
"slug": "eins",
"topicIds": [],
"typeId": 5,
},
{
"categoryIds": [],
"cbaSeriesId": None,
"defaultPlaylistId": None,
"description": "",
"email": "",
"fundingCategoryId": 1,
"hostIds": [],
"id": 1,
"imageId": None,
"isActive": True,
"isPublic": True,
"languageIds": [],
"links": [],
"logoId": None,
"musicFocusIds": [],
"name": "Musikpool",
"predecessorId": None,
"shortDescription": "Musik aus der Dose",
"slug": "musikpool",
"topicIds": [],
"typeId": 3,
},
{
"categoryIds": [],
"cbaSeriesId": None,
"defaultPlaylistId": None,
"description": "",
"email": "",
"fundingCategoryId": 1,
"hostIds": [1],
"id": 3,
"imageId": None,
"isActive": True,
"isPublic": True,
"languageIds": [],
"links": [],
"logoId": None,
"musicFocusIds": [],
"name": "ZWEI",
"predecessorId": None,
"shortDescription": "deep work",
"slug": "zwei",
"topicIds": [],
"typeId": 5,
},
],
"timeslots": [
{
"playlistId": None,
"repetitionOfId": None,
"end": "2024-07-22T18:00:00-04:00",
"id": 146,
"noteId": 146,
"scheduleId": 2,
"showId": 3,
"start": "2024-07-22T15:30:00-04:00",
},
{
"playlistId": None,
"repetitionOfId": None,
"end": "2024-07-22T23:00:00-04:00",
"id": 267,
"noteId": 267,
"scheduleId": 3,
"showId": 4,
"start": "2024-07-22T20:30:00-04:00",
},
{
"playlistId": None,
"repetitionOfId": None,
"end": "2024-07-23T12:30:00-04:00",
"id": 16,
"noteId": 16,
"scheduleId": 1,
"showId": 2,
"start": "2024-07-23T10:00:00-04:00",
},
],
"profiles": [
{
"biography": "",
"email": "",
"id": 1,
"imageId": None,
"isActive": True,
"links": [],
"name": "Ernesto",
}
],
"categories": [
{
"description": "",
"id": 5,
"isActive": True,
"name": "Mehr-/Fremdsprachig",
"slug": "mehr-fremdsprachig",
"subtitle": "",
}
],
"fundingCategories": [
{"id": 1, "isActive": True, "name": "Standard", "slug": "standard"}
],
"types": [
{
"id": 5,
"isActive": True,
"name": "Experimentell",
"slug": "experimentell",
},
{
"id": 2,
"isActive": True,
"name": "Musiksendung",
"slug": "musiksendung",
},
{
"id": 3,
"isActive": True,
"name": "Unmoderiertes Musikprogramm",
"slug": "unmoderiertes-musikprogramm",
},
],
"images": [],
"topics": [
{
"id": 5,
"isActive": True,
"name": "Wissenschaft/Philosophie",
"slug": "wissenschaft-philosophie",
}
],
"languages": [
{"id": 1, "isActive": True, "name": "Deutsch"},
{"id": 3, "isActive": True, "name": "Spanisch"},
],
"musicFocuses": [
{"id": 4, "isActive": True, "name": "Rock/Indie", "slug": "rock-indie"}
],
"program": [
{
"id": "2024-07-22T15:26:44.738502+00:00...2024-07-22T19:30:00+00:00",
"start": "2024-07-22T11:26:44.738502-04:00",
"end": "2024-07-22T15:30:00-04:00",
"timeslotId": None,
"playlistId": None,
"showId": 1,
},
{
"id": "2024-07-22T19:30:00+00:00...2024-07-22T22:00:00+00:00",
"start": "2024-07-22T15:30:00-04:00",
"end": "2024-07-22T18:00:00-04:00",
"timeslotId": 146,
"playlistId": None,
"showId": 3,
},
{
"id": "2024-07-22T22:00:00+00:00...2024-07-23T00:30:00+00:00",
"start": "2024-07-22T18:00:00-04:00",
"end": "2024-07-22T20:30:00-04:00",
"timeslotId": None,
"playlistId": None,
"showId": 1,
},
{
"id": "2024-07-23T00:30:00+00:00...2024-07-23T03:00:00+00:00",
"start": "2024-07-22T20:30:00-04:00",
"end": "2024-07-22T23:00:00-04:00",
"timeslotId": 267,
"playlistId": None,
"showId": 4,
},
{
"id": "2024-07-23T03:00:00+00:00...2024-07-23T14:00:00+00:00",
"start": "2024-07-22T23:00:00-04:00",
"end": "2024-07-23T10:00:00-04:00",
"timeslotId": None,
"playlistId": None,
"showId": 1,
},
{
"id": "2024-07-23T14:00:00+00:00...2024-07-23T16:30:00+00:00",
"start": "2024-07-23T10:00:00-04:00",
"end": "2024-07-23T12:30:00-04:00",
"timeslotId": 16,
"playlistId": None,
"showId": 2,
},
{
"id": "2024-07-23T16:30:00+00:00...2024-07-23T15:26:44.738502+00:00",
"start": "2024-07-23T12:30:00-04:00",
"end": "2024-07-23T11:26:44.738502-04:00",
"timeslotId": None,
"playlistId": None,
"showId": 1,
},
],
"episodes": [
{
"cbaId": None,
"content": "",
"contributorIds": [],
"id": 146,
"imageId": None,
"languageIds": [],
"links": [],
"summary": "",
"tags": [],
"timeslotId": 146,
"title": "",
"topicIds": [],
},
{
"cbaId": None,
"content": "",
"contributorIds": [],
"id": 267,
"imageId": None,
"languageIds": [],
"links": [],
"summary": "",
"tags": [],
"timeslotId": 267,
"title": "",
"topicIds": [],
},
{
"cbaId": None,
"content": "",
"contributorIds": [],
"id": 16,
"imageId": None,
"languageIds": [],
"links": [],
"summary": "",
"tags": [],
"timeslotId": 16,
"title": "",
"topicIds": [],
},
],
"licenses": [],
"linkTypes": [
{"id": 2, "isActive": True, "name": "CBA"},
{"id": 11, "isActive": True, "name": "Facebook"},
{"id": 3, "isActive": True, "name": "Freie Radios Online"},
{"id": 4, "isActive": True, "name": "Funkwhale"},
{"id": 10, "isActive": True, "name": "Instagram"},
{"id": 7, "isActive": True, "name": "Internet Archive (archive.org)"},
{"id": 13, "isActive": True, "name": "Mastodon"},
{"id": 5, "isActive": True, "name": "Mixcloud"},
{"id": 6, "isActive": True, "name": "SoundCloud"},
{"id": 9, "isActive": True, "name": "Spotify"},
{"id": 12, "isActive": True, "name": "Twitter"},
{"id": 1, "isActive": True, "name": "Website"},
{"id": 8, "isActive": True, "name": "YouTube"},
],
},
)
include_virtual = request.GET.get("include_virtual") == "true"
playout = get_timerange_timeslot_entries(schedule_start, schedule_end, include_virtual)
return JsonResponse(playout, safe=False)
],
summary=(
"List program for a specific date range. "
"Returns all relevant data for the specified time frame."
),
# FIXME:
# This doesn’t work because the list wrapping behaviour is forced in drf-spectacular.
# This can potentially be fixed with an OpenApiSerializerExtension.
# see: https://drf-spectacular.readthedocs.io/en/latest/customization.html#declare-serializer-magic-with-openapiserializerextension # noqa: E501
responses=serializer_class(many=False),
)
def list(self, request, *args, **kwargs):
program = list(self.filter_queryset(self.get_queryset()))
serializer = self.get_serializer(instance=CalendarSchemaSerializer.Wrapper(program))
return Response(serializer.data)
@extend_schema_view(
......@@ -424,10 +771,74 @@ class APIRRuleViewSet(viewsets.ModelViewSet):
@extend_schema_view(
create=extend_schema(
examples=[
OpenApiExample(
"Request to create a new schedule",
request_only=True,
value={
"schedule": {
"endTime": "09:00:00",
"startTime": "08:00:00",
"rruleId": 1,
"showId": 10,
"firstDate": "2024-08-12",
}
},
),
OpenApiExample(
"Request to simulate the creation of a new schedule",
request_only=True,
value={
"schedule": {
"endTime": "09:00:00",
"dryrun": True,
"startTime": "08:00:00",
"rruleId": 1,
"showId": 10,
"firstDate": "2024-08-12",
}
},
),
OpenApiExample(
"Request to create a new schedule and solve a collision",
request_only=True,
value={
"schedule": {
"endTime": "09:00:00",
"dryrun": True,
"startTime": "08:00:00",
"rruleId": 1,
"showId": 10,
"firstDate": "2024-08-12",
},
"solutions": {"2024081211000004002024081212000004001": "ours"},
},
),
],
summary="Create a new schedule.",
request=ScheduleCreateUpdateRequestSerializer,
responses={
status.HTTP_201_CREATED: OpenApiResponse(
examples=[
OpenApiExample(
"Successful creation if a new schedule",
response_only=True,
value={
"addBusinessDaysOnly": False,
"addDaysNo": None,
"byWeekday": None,
"defaultPlaylistId": None,
"endTime": "09:00:00",
"firstDate": "2024-08-12",
"id": 11,
"isRepetition": False,
"lastDate": None,
"rruleId": 1,
"showId": 10,
"startTime": "08:00:00",
},
)
],
response=ScheduleResponseSerializer,
description=(
"Signals the successful creation of the schedule and of the projected "
......@@ -435,6 +846,29 @@ class APIRRuleViewSet(viewsets.ModelViewSet):
),
),
status.HTTP_202_ACCEPTED: OpenApiResponse(
examples=[
OpenApiExample(
"Simulated creation of a new schedule",
response_only=True,
value={
"create": [
{
"end": "2024-08-12T09:00:00-04:00",
"id": None,
"memo": "",
"noteId": None,
"playlistId": None,
"repetitionOfId": None,
"scheduleId": None,
"showId": 10,
"start": "2024-08-12T08:00:00-04:00",
}
],
"delete": [],
"update": [],
},
)
],
response=ScheduleDryRunResponseSerializer,
description=(
"Returns the list of timeslots that would be created, updated and deleted if "
......@@ -470,6 +904,59 @@ class APIRRuleViewSet(viewsets.ModelViewSet):
),
),
status.HTTP_409_CONFLICT: OpenApiResponse(
examples=[
OpenApiExample(
"Creation of a new schedule would create in a collision",
response_only=True,
value={
"notes": {},
"playlists": {},
"projected": [
{
"collisions": [
{
"end": "2024-08-12 17:00:00+00:00",
"memo": "",
"noteId": 694,
"playlistId": None,
"scheduleId": 7,
"showId": 6,
"showName": "EINS",
"start": "2024-08-12 14:00:00+00:00",
"timeslotId": 694,
}
],
"end": "2024-08-12 12:00:00-04:00",
"error": None,
"hash": "2024081211000004002024081212000004001",
"solutionChoices": [
"ours-end",
"theirs",
"ours-start",
"ours",
"ours-both",
],
"start": "2024-08-12 11:00:00-04:00",
}
],
"schedule": {
"addBusinessDaysOnly": False,
"addDaysNo": None,
"byWeekday": None,
"defaultPlaylistId": None,
"endTime": "12:00:00",
"firstDate": "2024-08-12",
"id": None,
"isRepetition": False,
"lastDate": None,
"rruleId": 1,
"showId": 10,
"startTime": "11:00:00",
},
"solutions": {"2024081211000004002024081212000004001": ""},
},
)
],
response=ScheduleConflictResponseSerializer,
description=dedent(
"""
......@@ -492,17 +979,150 @@ class APIRRuleViewSet(viewsets.ModelViewSet):
),
},
),
retrieve=extend_schema(summary="Retrieve a single schedule."),
retrieve=extend_schema(
examples=[
OpenApiExample(
"Example single schedule",
value={
"addBusinessDaysOnly": False,
"addDaysNo": None,
"byWeekday": 0,
"defaultPlaylistId": None,
"endTime": "18:30:00",
"firstDate": "2024-01-08",
"id": 8,
"isRepetition": False,
"lastDate": "2024-12-20",
"rruleId": 3,
"showId": 8,
"startTime": "15:30:00",
},
)
],
summary="Retrieve a single schedule.",
),
update=extend_schema(
examples=[
OpenApiExample(
"Request to update an existing schedule",
request_only=True,
value={
"schedule": {
"rruleId": 2,
"endTime": "09:00:00",
"firstDate": "2024-08-02",
"startTime": "10:00:00",
}
},
)
],
summary="Update an existing schedule.",
request=ScheduleCreateUpdateRequestSerializer,
),
partial_update=extend_schema(
examples=[
OpenApiExample(
"Request to update defaultPlaylistId",
request_only=True,
value={
"defaultPlaylistId": 75,
},
),
OpenApiExample(
"Request to update isRepetition",
request_only=True,
value={
"isRepetition": True,
},
),
OpenApiExample(
"Request to update lastDate",
request_only=True,
value={
"lastDate": "2024-09-30",
},
),
OpenApiExample(
"Response to update defaultPlaylistId",
response_only=True,
value={
"addBusinessDaysOnly": False,
"addDaysNo": None,
"byWeekday": None,
"defaultPlaylistId": 75,
"endTime": "12:30:00",
"firstDate": "2024-08-12",
"id": 1,
"isRepetition": False,
"lastDate": None,
"rruleId": 1,
"showId": 1,
"startTime": "10:00:00",
},
),
OpenApiExample(
"Response to update isRepetition",
response_only=True,
value={
"addBusinessDaysOnly": False,
"addDaysNo": None,
"byWeekday": None,
"defaultPlaylistId": None,
"endTime": "12:30:00",
"firstDate": "2024-08-12",
"id": 1,
"isRepetition": True,
"lastDate": None,
"rruleId": 1,
"showId": 1,
"startTime": "10:00:00",
},
),
OpenApiExample(
"Response to update lastDate",
response_only=True,
value={
"addBusinessDaysOnly": False,
"addDaysNo": None,
"byWeekday": None,
"defaultPlaylistId": 75,
"endTime": "12:30:00",
"firstDate": "2024-08-12",
"id": 1,
"isRepetition": False,
"lastDate": "2024-09-30",
"rruleId": 1,
"showId": 1,
"startTime": "10:00:00",
},
),
],
summary="Partially update an existing schedule.",
request=ScheduleCreateUpdateRequestSerializer,
),
destroy=extend_schema(summary="Delete an existing schedule."),
list=extend_schema(summary="List all schedules."),
list=extend_schema(
examples=[
OpenApiExample(
"Example list of schedules",
value={
"addBusinessDaysOnly": False,
"addDaysNo": None,
"byWeekday": 0,
"defaultPlaylistId": None,
"endTime": "18:30:00",
"firstDate": "2024-01-08",
"id": 8,
"isRepetition": False,
"lastDate": "2024-12-20",
"rruleId": 3,
"showId": 8,
"startTime": "15:30:00",
},
)
],
summary="List all schedules.",
),
)
class APIScheduleViewSet(viewsets.ModelViewSet):
filterset_class = filters.ScheduleFilterSet
......@@ -580,32 +1200,81 @@ class APIScheduleViewSet(viewsets.ModelViewSet):
them including notes.
"""
if not request.user.is_superuser:
required_schedule_fields = {"end_time", "first_date", "rrule_id", "start_time"}
if not self.request.user.has_perm("program.change_schedule"):
return Response(status=status.HTTP_401_UNAUTHORIZED)
if request.method == "PATCH":
# only these fields can be updated without generating conflicts
allowed = {"default_playlist_id", "is_repetition", "last_date"}
if "schedule" not in self.request.data:
return Response(status=status.HTTP_400_BAD_REQUEST)
if set(request.data.keys()).issubset(allowed):
schedule = self.get_object()
schedule_fields = set(self.request.data["schedule"].keys())
if default_playlist_id := request.data.get("default_playlist_id"):
if default_playlist_id == "":
# "clear" the default_playlist_id if the field has no value
schedule.default_playlist_id = None
else:
if missing_fields := required_schedule_fields.difference(schedule_fields):
data = {
"schedule": {
field: "This field is required in a PUT request" for field in missing_fields
}
}
return Response(data, status=status.HTTP_400_BAD_REQUEST)
schedule = self.get_object()
try:
resolution = resolve_conflicts(request.data, schedule.pk, schedule.show.pk)
except ScheduleConflictError as exc:
return Response(exc.conflicts, status.HTTP_409_CONFLICT)
return Response(resolution)
def partial_update(self, request, *args, **kwargs):
"""
Partial update a schedule without generating timeslots, testing or resolving collisions.
"""
if not self.request.user.has_perm("program.change_schedule"):
return Response(status=status.HTTP_401_UNAUTHORIZED)
# only these fields can be updated without generating conflicts
allowed = {"default_playlist_id", "is_repetition", "last_date"}
update_fields = set(request.data.keys())
if update_fields.issubset(allowed):
schedule = self.get_object()
request_data = self.request.data
if "default_playlist_id" in request_data:
default_playlist_id = request_data.get("default_playlist_id")
if default_playlist_id == "" or default_playlist_id is None:
schedule.default_playlist_id = None
else:
try:
schedule.default_playlist_id = int(default_playlist_id)
except ValueError as e:
data = {"last_date": e.args[0]}
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if is_repetition := request.data.get("is_repetition"):
if is_repetition == "true" or is_repetition == "1":
schedule.is_repetition = True
if is_repetition == "false" or is_repetition == "0":
schedule.is_repetition = False
if is_repetition := request_data.get("is_repetition"):
if is_repetition == "true" or is_repetition == "1":
schedule.is_repetition = True
elif is_repetition == "false" or is_repetition == "0":
schedule.is_repetition = False
if last_date := request.data.get("last_date"):
last_date = date.fromisoformat(last_date)
if "last_date" in request_data:
last_date = request_data.get("last_date")
if last_date == "":
schedule.last_date = None
else:
try:
last_date = date.fromisoformat(last_date)
except ValueError as e:
data = {"last_date": e.args[0]}
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if schedule.last_date is None or schedule.last_date > last_date:
schedule.last_date = last_date
......@@ -615,43 +1284,19 @@ class APIScheduleViewSet(viewsets.ModelViewSet):
TimeSlot.objects.filter(schedule=schedule, start__gt=last_end).delete()
else:
return Response(status=status.HTTP_400_BAD_REQUEST)
data = {"last_date": "This field cannot be updated to this date"}
schedule.save()
serializer = ScheduleSerializer(schedule)
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return Response(serializer.data)
else:
return Response(status=status.HTTP_400_BAD_REQUEST)
# Only allow updating when with the `schedule` JSON object
if "schedule" not in request.data:
return Response(status=status.HTTP_400_BAD_REQUEST)
schedule = self.get_object()
# FIXME: this is redundant now and should be removed
# If default playlist id or repetition are given, just update
if default_playlist_id := request.data.get("schedule").get("default_playlist_id"):
schedule.default_playlist_id = int(default_playlist_id)
schedule.save()
serializer = ScheduleSerializer(schedule)
return Response(serializer.data)
if is_repetition := request.data.get("schedule").get("is_repetition"):
schedule.is_repetition = bool(is_repetition)
schedule.save()
serializer = ScheduleSerializer(schedule)
return Response(serializer.data)
else:
bad_fields = update_fields.difference(allowed)
data = {field: "This field cannot be updated with PATCH" for field in bad_fields}
try:
resolution = resolve_conflicts(request.data, schedule.pk, schedule.show.pk)
except ScheduleConflictError as exc:
return Response(exc.conflicts, status.HTTP_409_CONFLICT)
return Response(resolution)
return Response(data, status=status.HTTP_400_BAD_REQUEST)
# TODO: Create is currently not implemented because timeslots are supposed to be inserted
......@@ -705,17 +1350,20 @@ class APITimeSlotViewSet(
@extend_schema_view(
create=extend_schema(summary="Create a new note."),
retrieve=extend_schema(summary="Retrieve a single note."),
update=extend_schema(summary="Update an existing note."),
list=extend_schema(summary="List all notes."),
partial_update=extend_schema(
summary="Partially update an existing note.",
description="Only admins can partially update existing notes.",
),
destroy=extend_schema(summary="Delete an existing note."),
list=extend_schema(summary="List all notes."),
retrieve=extend_schema(summary="Retrieve a single note."),
update=extend_schema(summary="Update an existing note."),
)
class APINoteViewSet(viewsets.ModelViewSet):
class APINoteViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
filterset_class = filters.NoteFilterSet
pagination_class = LimitOffsetPagination
serializer_class = NoteSerializer
......@@ -815,22 +1463,22 @@ class APILanguageViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
@extend_schema_view(
create=extend_schema(summary="Create a new host."),
retrieve=extend_schema(summary="Retrieve a single host."),
update=extend_schema(summary="Update an existing host."),
partial_update=extend_schema(summary="Partially update an existing host."),
destroy=extend_schema(summary="Delete an existing host."),
list=extend_schema(summary="List all hosts."),
create=extend_schema(summary="Create a new profile."),
retrieve=extend_schema(summary="Retrieve a single profile."),
update=extend_schema(summary="Update an existing profile."),
partial_update=extend_schema(summary="Partially update an existing profile."),
destroy=extend_schema(summary="Delete an existing profile."),
list=extend_schema(summary="List all profiles."),
)
class APIHostViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
queryset = Host.objects.all().order_by("-is_active", "name")
serializer_class = HostSerializer
class APIProfileViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
queryset = Profile.objects.all().order_by("-is_active", "name")
serializer_class = ProfileSerializer
pagination_class = LimitOffsetPagination
filter_backends = [drf_filters.SearchFilter]
search_fields = ["name", "email"]
def create(self, request, *args, **kwargs):
serializer = HostSerializer(
serializer = ProfileSerializer(
# FIXME: the method get_serializer_context should be used but it does seem to get lost
context={"request": request}, # the serializer needs the request in the context
data=request.data,
......@@ -841,13 +1489,13 @@ class APIHostViewSet(ActiveFilterMixin, viewsets.ModelViewSet):
def update(self, request, *args, **kwargs):
partial = kwargs.get("partial", False)
host = self.get_object()
profile = self.get_object()
serializer = HostSerializer(
serializer = ProfileSerializer(
# FIXME: the method get_serializer_context should be used but it does seem to get lost
context={"request": request}, # the serializer needs the request in the context
data=request.data,
instance=host,
instance=profile,
partial=partial,
)
......@@ -898,8 +1546,82 @@ class APILicenseViewSet(viewsets.ModelViewSet):
@extend_schema_view(
list=extend_schema(summary="List all settings."),
list=extend_schema(
# TODO: move this into the serializers
examples=[
OpenApiExample(
"Example Radio Settings",
value={
"id": 1,
"cba": {"domains": ["cba.media"]},
"imageRequirements": {
"note.image": {"frame": {"aspectRatio": [16, 9], "shape": "round"}},
"profile.image": {"frame": {"aspectRatio": [1, 1], "shape": "round"}},
"show.image": {"frame": {"aspectRatio": [16, 9], "shape": "round"}},
"show.logo": {"frame": {"aspectRatio": [1, 1], "shape": "round"}},
},
"playout": {
"lineInChannels": {"0": "live", "1": "preprod"},
"pools": {"fallback": "Station Fallback Pool"},
},
"program": {
"micro": {"showId": None},
"fallback": {"showId": None, "defaultPool": "fallback"},
},
"station": {
"name": "Radio AURA",
"logo": None,
"website": "https://aura.radio",
},
},
)
],
summary="List all settings.",
),
)
class APIRadioSettingsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
queryset = RadioSettings.objects.all()
serializer_class = RadioSettingsSerializer
class TestOperationPermission(permissions.BasePermission):
def has_permission(self, request, view):
return settings.OPERATION_MODE == "tests"
def has_object_permission(self, request, view, obj):
return self.has_permission(request, view)
class TestOperationViewMixin:
authentication_classes = []
permission_classes = [TestOperationPermission]
class APIApplicationStateView(TestOperationViewMixin, views.APIView):
@extend_schema(
description=(
"Purges any data of the selected models and "
"returns the deleted count for each of them."
),
parameters=[ApplicationStatePurgeSerializer],
responses={200: dict[str, int]},
examples=[
OpenApiExample(
"Example entry",
value={
"program.Show": 6,
"program.TimeSlot": 38,
},
),
],
tags=["debug"],
)
def delete(self, request: Request, *args, **kwargs):
params = ApplicationStatePurgeSerializer(data=request.query_params)
params.is_valid(raise_exception=True)
deleted = application_state_manager.purge(
model_category_names=set(params.validated_data["model_categories"]),
model_names=set(params.validated_data["models"]),
invert_selection=params.validated_data["invert_selection"],
)
return Response(status=status.HTTP_200_OK, data=deleted)
......@@ -19,19 +19,20 @@ Django = "^4.2.2"
django-auth-ldap = "^4.1.0"
django-cors-headers = "^4.0.0"
django-extensions = "^3.2.1"
django-filter = "^23.2"
django-filter = "^24.2"
django-json-widget = "^2.0.1"
django-oidc-provider = "^0.8.0"
djangorestframework = "^3.14.0"
djangorestframework-camel-case = "^1.4.2"
django-versatileimagefield = "^3.0"
drf-jsonschema-serializer = "^2.0.0"
dj-database-url = "^2.2.0"
drf-jsonschema-serializer = "^3.0.0"
drf-spectacular = "^0.27.1"
gunicorn = "^21.2.0"
gunicorn = "^23.0.0"
jsonschema = "^4.22.0"
Pillow = "^10.1.0"
psycopg2-binary = "^2.9.3"
pydot = "^2.0.0"
pydot = "^3.0.1"
python-dateutil = "^2.8.2"
python-ldap = "^3.4.3"
pytz = "^2024.1"
......@@ -48,12 +49,12 @@ yq = "^3.4.1"
[tool.poetry.group.test.dependencies]
coverage = "^7.4.3"
pytest = "^8.0.2"
pytest-cov = "^4.1.0"
pytest-cov = "^5.0.0"
pytest-django = "^4.5.2"
pytest-factoryboy = "^2.5.1"
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "steering.settings"
DJANGO_SETTINGS_MODULE = "steering.test_settings"
django_debug_mode = true
filterwarnings = [
"ignore::DeprecationWarning",
......
import json
import os
import sys
from pathlib import Path
from typing import Literal, cast
import dj_database_url
import ldap
from corsheaders.defaults import default_headers
from django_auth_ldap.config import LDAPSearch, PosixGroupType
......@@ -23,22 +27,16 @@ SITE_ID = 1
# Must be set if DEBUG is False
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", default="127.0.0.1,localhost").split(",")
CORS_ALLOWED_ORIGINS = ["http://localhost:8080", "http://localhost:8040"]
CORS_ALLOWED_ORIGINS = os.getenv(
"CORS_ALLOWED_ORIGINS",
default="http://localhost:8080,http://localhost:8040",
).split(",")
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = list(default_headers) + [
"content-disposition",
]
DATABASES = {
"default": {
"ENGINE": f"django.db.backends.{os.getenv('DATABASE_ENGINE', default='sqlite3')}",
"NAME": os.getenv("POSTGRES_DB", default=BASE_DIR / "db.sqlite3"),
"USER": os.getenv("POSTGRES_USER", default="steering"),
"PASSWORD": os.getenv("POSTGRES_PASSWORD", default="aura"),
"HOST": os.getenv("POSTGRES_HOST", default="steering-postgres"),
"PORT": os.getenv("POSTGRES_PORT", default="5432"),
},
}
DATABASES = {"default": dj_database_url.config(default=f"sqlite:///{BASE_DIR}/db.sqlite3")}
TIME_ZONE = os.getenv("TZ", default="Europe/Vienna")
LANGUAGE_CODE = os.getenv("LANGUAGE_CODE", default="en-us")
......@@ -212,33 +210,53 @@ AURA_PROTO = os.getenv("AURA_PROTO", default="http")
AURA_HOST = os.getenv("AURA_HOST", default="localhost")
# SITE_URL is used by django-oidc-provider and openid-configuration will break if not set correctly
SITE_URL = f"{AURA_PROTO}://{AURA_HOST}:{PORT}" if PORT else f"{AURA_PROTO}://{AURA_HOST}"
SITE_URL = os.getenv(
"SITE_URL",
default=f"{AURA_PROTO}://{AURA_HOST}:{PORT}" if PORT else f"{AURA_PROTO}://{AURA_HOST}",
)
if AURA_PROTO == "https":
CSRF_TRUSTED_ORIGINS = [f"{AURA_PROTO}://{AURA_HOST}"]
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"django.server": {
"()": "django.utils.log.ServerFormatter",
"format": "[{server_time}] {message}",
"style": "{",
}
},
"handlers": {
"file": {
"class": "logging.FileHandler",
"filename": os.path.abspath(os.path.join(BASE_DIR, "logs", "steering.log")),
"formatter": "django.server",
OPERATION_MODE_TYPE = Literal["default", "tests"]
OPERATION_MODE: OPERATION_MODE_TYPE
OPERATION_MODE = cast(OPERATION_MODE_TYPE, os.getenv("OPERATION_MODE", default="default"))
# TODO: find a better way. This is limited to settings that can be JSON-encoded.
if "LOGGING_JSON" in os.environ:
LOGGING = json.loads(os.getenv("LOGGING_JSON"))
else:
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"django.server": {
"()": "django.utils.log.ServerFormatter",
"format": "[{server_time}] {message}",
"style": "{",
}
},
},
"loggers": {
"django": {
"handlers": ["file"],
"level": os.getenv("STEERING_LOG_LEVEL", "INFO"),
"propagate": True,
"handlers": {
"file": {
"class": "logging.FileHandler",
"filename": os.path.abspath(os.path.join(BASE_DIR, "logs", "steering.log")),
"formatter": "django.server",
},
},
},
}
"loggers": {
"django": {
"handlers": ["file"],
"level": os.getenv("STEERING_LOG_LEVEL", "INFO"),
"propagate": True,
},
},
}
# ATTENTION:
# Don’t add any configuration settings after this, so that administrators can override them.
try:
sys.path.insert(0, "/etc/steering")
from steering_settings import * # noqa: F401,F403
except ImportError:
if os.path.exists("/etc/steering/steering_settings.py"):
raise
from steering.settings import * # noqa
OPERATION_MODE = "tests"
......@@ -26,17 +26,19 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from program.views import (
APIApplicationStateView,
APICategoryViewSet,
APIDayScheduleViewSet,
APIFundingCategoryViewSet,
APIHostViewSet,
APIImageViewSet,
APILanguageViewSet,
APILicenseViewSet,
APILinkTypeViewSet,
APIMusicFocusViewSet,
APINoteViewSet,
APIPlayoutViewSet,
APIProfileViewSet,
APIProgramBasicViewSet,
APIProgramCalendarViewSet,
APIProgramPlayoutViewSet,
APIRadioSettingsViewSet,
APIRRuleViewSet,
APIScheduleViewSet,
......@@ -51,7 +53,7 @@ admin.autodiscover()
router = routers.DefaultRouter()
router.register(r"users", APIUserViewSet, basename="user")
router.register(r"hosts", APIHostViewSet, basename="host")
router.register(r"profiles", APIProfileViewSet, basename="profile")
router.register(r"shows", APIShowViewSet, basename="show")
router.register(r"schedules", APIScheduleViewSet, basename="schedule")
router.register(r"timeslots", APITimeSlotViewSet, basename="timeslot")
......@@ -67,17 +69,13 @@ router.register(r"link-types", APILinkTypeViewSet, basename="link-type")
router.register(r"rrules", APIRRuleViewSet, basename="rrule")
router.register(r"images", APIImageViewSet, basename="image")
router.register(r"settings", APIRadioSettingsViewSet, basename="settings")
router.register(r"playout", APIPlayoutViewSet, basename="playout")
router.register(r"program/week", APIPlayoutViewSet, basename="program/week")
router.register(r"program/basic", APIProgramBasicViewSet, basename="program-basic")
router.register(r"program/playout", APIProgramPlayoutViewSet, basename="program-playout")
router.register(r"program/calendar", APIProgramCalendarViewSet, basename="program-calendar")
urlpatterns = [
path("openid/", include("oidc_provider.urls", namespace="oidc_provider")),
path("api/v1/", include(router.urls)),
path(
"api/v1/program/<int:year>/<int:month>/<int:day>/",
APIDayScheduleViewSet.as_view({"get": "list"}),
name="program",
),
path("api/v1/schema/", SpectacularAPIView.as_view(), name="schema"),
path(
"api/v1/schema/swagger-ui/",
......@@ -86,3 +84,10 @@ urlpatterns = [
),
path("admin/", admin.site.urls),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.DEBUG or settings.OPERATION_MODE == "tests":
# This endpoint is only usable with OPERATION_MODE == "tests"
# but we include it in DEBUG mode so that developers can inspect the schema.
urlpatterns.append(
path("api/v1/debug/application-state/", APIApplicationStateView.as_view()),
)