Skip to content

Commit aeefa2a

Browse files
authored
Merge pull request #101 from nebulabroadcast/97-scheduler-templates
Scheduling: Apply Weekly Plans via Web Interface
2 parents 37d1020 + 17af1c6 commit aeefa2a

File tree

23 files changed

+672
-215
lines changed

23 files changed

+672
-215
lines changed

backend/api/scheduler/models.py

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,9 @@
22

33
from pydantic import Field
44

5+
from nebula.helpers.create_new_event import EventData
56
from server.models import RequestModel, ResponseModel
67

7-
Serializable = int | str | float | list[str] | bool | None
8-
9-
10-
class EventData(RequestModel):
11-
id: int | None = Field(
12-
None,
13-
title="Event ID",
14-
description="Event ID. None for new events.",
15-
examples=[320],
16-
)
17-
18-
start: int = Field(
19-
...,
20-
title="Start time",
21-
examples=[1620000000],
22-
)
23-
24-
id_asset: int | None = Field(
25-
None,
26-
title="Asset ID",
27-
description="ID of the asset to be used as a primary asset for this event.",
28-
examples=[123],
29-
)
30-
31-
items: list[dict[str, Serializable]] | None = Field(default_factory=lambda: [])
32-
33-
meta: dict[str, Serializable] | None = Field(
34-
default=None,
35-
title="Event metadata",
36-
description="Metadata for the event.",
37-
examples=[
38-
{
39-
"title": "My event",
40-
"subtitle": "My event subtitle",
41-
"genre": "My genre",
42-
}
43-
],
44-
)
45-
46-
# TODO: validate meta object against the channel_config fields
47-
488

499
class SchedulerRequestModel(RequestModel):
5010
id_channel: int = Field(

backend/api/scheduler/scheduler.py

Lines changed: 2 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,11 @@
11
import nebula
2+
from nebula.helpers.create_new_event import create_new_event
23
from nebula.helpers.scheduling import parse_rundown_date
3-
from nebula.settings.models import PlayoutChannelSettings
44

5-
from .models import EventData, SchedulerRequestModel, SchedulerResponseModel
5+
from .models import SchedulerRequestModel, SchedulerResponseModel
66
from .utils import delete_events, get_event_at_time, get_events_in_range
77

88

9-
async def create_new_event(
10-
channel: PlayoutChannelSettings,
11-
event_data: EventData,
12-
user: nebula.User | None = None,
13-
) -> None:
14-
"""Create a new event from the given data."""
15-
16-
username = user.name if user else None
17-
18-
pool = await nebula.db.pool()
19-
async with pool.acquire() as conn, conn.transaction():
20-
new_bin = nebula.Bin(connection=conn, username=username)
21-
new_event = nebula.Event(connection=conn, username=username)
22-
23-
await new_bin.save()
24-
25-
new_event["id_magic"] = new_bin.id
26-
new_event["id_channel"] = channel.id
27-
new_event["start"] = event_data.start
28-
29-
asset_meta = {}
30-
position = 0
31-
if event_data.id_asset:
32-
asset = await nebula.Asset.load(
33-
event_data.id_asset, connection=conn, username=username
34-
)
35-
36-
new_event["id_asset"] = event_data.id_asset
37-
38-
new_item = nebula.Item(connection=conn, username=username)
39-
new_item["id_asset"] = event_data.id_asset
40-
new_item["id_bin"] = new_bin.id
41-
new_item["position"] = position
42-
new_item["mark_in"] = asset["mark_in"]
43-
new_item["mark_out"] = asset["mark_out"]
44-
45-
await new_item.save()
46-
new_bin["duration"] = asset.duration
47-
asset_meta = asset.meta
48-
position += 1
49-
50-
if event_data.items:
51-
for item_data in event_data.items:
52-
if item_data.get("id"):
53-
assert isinstance(item_data["id"], int), "Invalid item ID"
54-
item = await nebula.Item.load(
55-
item_data["id"], connection=conn, username=username
56-
)
57-
else:
58-
item = nebula.Item(connection=conn, username=username)
59-
item.update(item_data)
60-
item["id_bin"] = new_bin.id
61-
item["position"] = position
62-
await item.save()
63-
position += 1
64-
65-
for field in channel.fields:
66-
if (value := asset_meta.get(field.name)) is not None:
67-
new_event[field.name] = value
68-
69-
if event_data.meta is not None:
70-
value = event_data.meta.get(field.name)
71-
if value is not None:
72-
new_event[field.name] = value
73-
74-
try:
75-
await new_event.save()
76-
await new_bin.save()
77-
except Exception as e:
78-
raise nebula.ConflictException() from e
79-
80-
819
async def scheduler(
8210
request: SchedulerRequestModel,
8311
editable: bool = True,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
__all__ = ["ApplyTemplateRequest", "ListTemplatesRequest"]
2+
3+
from .template_request import ApplyTemplateRequest, ListTemplatesRequest
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from typing import Annotated
2+
3+
from pydantic import BaseModel, Field
4+
5+
from server.models import RequestModel, ResponseModel
6+
7+
8+
class TemplateItemModel(BaseModel):
9+
name: Annotated[str, Field(..., title="Template name", examples=["my_template"])]
10+
title: Annotated[str, Field(..., title="Template title", examples=["My Template"])]
11+
12+
13+
class ListTemplatesResponseModel(ResponseModel):
14+
templates: Annotated[
15+
list[TemplateItemModel],
16+
Field(
17+
default_factory=lambda: [],
18+
title="Templates",
19+
),
20+
]
21+
22+
23+
class ApplyTemplateRequestModel(RequestModel):
24+
id_channel: Annotated[
25+
int,
26+
Field(
27+
...,
28+
title="Channel ID",
29+
examples=[1],
30+
),
31+
]
32+
template_name: Annotated[
33+
str,
34+
Field(..., title="Template name", examples=["my_template"]),
35+
]
36+
date: Annotated[
37+
str,
38+
Field(
39+
...,
40+
title="Date",
41+
examples=["2022-12-31"],
42+
),
43+
]
44+
clear: Annotated[
45+
bool,
46+
Field(
47+
title="Clear events",
48+
description="Clear all events before applying the template",
49+
),
50+
] = False
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import datetime
2+
from typing import Any, Literal
3+
4+
from nebula.helpers.create_new_event import EventData
5+
6+
from .utils import get_week_start
7+
8+
DayKey = Literal[
9+
"monday",
10+
"tuesday",
11+
"wednesday",
12+
"thursday",
13+
"friday",
14+
"saturday",
15+
"sunday",
16+
"default",
17+
]
18+
19+
DAY_NAMES: list[DayKey] = [
20+
"monday",
21+
"tuesday",
22+
"wednesday",
23+
"thursday",
24+
"friday",
25+
"saturday",
26+
"sunday",
27+
]
28+
29+
30+
class TemplateImporter:
31+
day_start_hour: int
32+
day_start_minute: int
33+
events: dict[int, EventData]
34+
template: dict[DayKey, list[dict[str, Any]]]
35+
36+
def __init__(
37+
self,
38+
template: dict[DayKey, list[dict[str, Any]]],
39+
day_start_hour: int = 7,
40+
day_start_minute: int = 30,
41+
):
42+
self.day_start_hour = day_start_hour
43+
self.day_start_minute = day_start_minute
44+
self.template = template
45+
self.events = {}
46+
47+
@property
48+
def day_start_offset(self) -> int:
49+
return self.day_start_hour * 3600 + self.day_start_minute * 60
50+
51+
def build_for_week(self, date: str) -> dict[int, Any]:
52+
week_start = get_week_start(date, self.day_start_hour, self.day_start_minute)
53+
54+
for i in range(7):
55+
day_start = week_start + datetime.timedelta(days=i)
56+
day_name: DayKey = DAY_NAMES[day_start.weekday()]
57+
58+
day_start_ts = int(day_start.timestamp())
59+
self._apply_day_template("default", day_start_ts)
60+
61+
if day_name in self.template:
62+
self._apply_day_template(day_name, day_start_ts)
63+
return self.events
64+
65+
def _apply_day_template(self, key: DayKey, day_start_ts: int) -> None:
66+
day_tpl = self.template.get(key, [])
67+
68+
for tpl in day_tpl:
69+
hour, minute = (int(k) for k in tpl["time"].split(":"))
70+
# seconds from midnight
71+
toffset = hour * 3600 + minute * 60
72+
# if the event is before the day start, it is for the next day
73+
if toffset < self.day_start_offset:
74+
toffset += 24 * 3600
75+
# somehow craft the final event timestamp
76+
evt_start = int(day_start_ts + toffset - self.day_start_offset)
77+
78+
meta = {}
79+
for mkey in ["title", "description", "id_asset", "color"]:
80+
if tpl.get(mkey):
81+
meta[mkey] = tpl[mkey]
82+
83+
event_data = EventData(
84+
id=None,
85+
start=evt_start,
86+
items=tpl.get("items", None),
87+
meta=meta,
88+
id_asset=tpl.get("id_asset", None),
89+
)
90+
91+
self.events[evt_start] = event_data
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import nebula
2+
from nebula.helpers.create_new_event import create_new_event
3+
from server.dependencies import CurrentUser, RequestInitiator
4+
from server.request import APIRequest
5+
6+
from .models import (
7+
ApplyTemplateRequestModel,
8+
ListTemplatesResponseModel,
9+
TemplateItemModel,
10+
)
11+
from .template_importer import TemplateImporter
12+
from .utils import list_templates, load_template
13+
14+
15+
class ListTemplatesRequest(APIRequest):
16+
"""Retrieve or update the schedule for a channel
17+
18+
This endpoint handles chanel macro-scheduling,
19+
including the creation, modification, and deletion of playout events.
20+
21+
Schedule is represented as a list of events, typically for one week.
22+
"""
23+
24+
name = "list-scheduling-templates"
25+
title = "List scheduling templates"
26+
response_model = ListTemplatesResponseModel
27+
28+
async def handle(
29+
self,
30+
# user: CurrentUser,
31+
initiator: RequestInitiator,
32+
) -> ListTemplatesResponseModel:
33+
template_names = list_templates()
34+
35+
return ListTemplatesResponseModel(
36+
templates=[
37+
TemplateItemModel(name=name, title=name.capitalize())
38+
for name in template_names
39+
]
40+
)
41+
42+
43+
class ApplyTemplateRequest(APIRequest):
44+
"""Apply a template to a channel"""
45+
46+
name = "apply-scheduling-template"
47+
title = "Apply scheduling template"
48+
49+
async def handle(
50+
self,
51+
user: CurrentUser,
52+
request: ApplyTemplateRequestModel,
53+
initiator: RequestInitiator,
54+
) -> None:
55+
if not (channel := nebula.settings.get_playout_channel(request.id_channel)):
56+
raise nebula.BadRequestException(f"No such channel {request.id_channel}")
57+
58+
template = load_template(request.template_name)
59+
hh, mm = channel.day_start
60+
61+
importer = TemplateImporter(template.get("schedule", {}), hh, mm)
62+
edata = importer.build_for_week(request.date)
63+
64+
if not edata:
65+
nebula.log.warn("No events found in template")
66+
return
67+
68+
first_ts = min(edata.keys())
69+
last_ts = max(edata.keys())
70+
71+
pool = await nebula.db.pool()
72+
async with pool.acquire() as conn, conn.transaction():
73+
if request.clear:
74+
# Clear mode
75+
query = """
76+
DELETE FROM events
77+
WHERE start >= $1 AND start <= $2 AND id_channel = $3
78+
"""
79+
await conn.execute(query, first_ts, last_ts, request.id_channel)
80+
81+
else:
82+
# Merge mode
83+
query = """
84+
SELECT start FROM events
85+
WHERE start >= $1 AND start <= $2 AND id_channel = $3
86+
"""
87+
existing_times = []
88+
async for row in nebula.db.iterate(
89+
query, first_ts, last_ts, request.id_channel
90+
):
91+
existing_times.append(row["start"])
92+
93+
MINIMUM_GAP_SECONDS = 5 * 60
94+
for new_ts in list(edata.keys()):
95+
if any(
96+
abs(new_ts - existing_ts) < MINIMUM_GAP_SECONDS
97+
for existing_ts in existing_times
98+
):
99+
nebula.log.warn(
100+
f"Skipping event at {new_ts}: too close to existing event"
101+
)
102+
edata.pop(new_ts)
103+
104+
for _, event_data in edata.items():
105+
await create_new_event(channel, event_data, user, conn)

0 commit comments

Comments
 (0)