feat(main): initial commit
This commit is contained in:
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
3
core/admin.py
Normal file
3
core/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
core/apps.py
Normal file
5
core/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CoreConfig(AppConfig):
|
||||
name = "core"
|
||||
0
core/management/__init__.py
Normal file
0
core/management/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
0
core/management/commands/__init__.py
Normal file
209
core/management/commands/seed.py
Normal file
209
core/management/commands/seed.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import random
|
||||
from datetime import time, timedelta
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from core.models import (
|
||||
AppUser, Library, Genre, ContentRating, MediaSource,
|
||||
Series, MediaItem, Channel, ScheduleTemplate, ScheduleBlock, Airing
|
||||
)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Seeds the database with mock PYTV data including users, libraries, media, and channels.'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
self.stdout.write("Flushing existing data...")
|
||||
# Since we cascade everything, deleting users usually drops almost everything
|
||||
AppUser.objects.filter(username__startswith='mock_').delete()
|
||||
Genre.objects.all().delete()
|
||||
ContentRating.objects.all().delete()
|
||||
|
||||
self.stdout.write("Seeding Users...")
|
||||
admin_user = AppUser.objects.create_superuser('mock_admin', 'admin@pytv.local', 'admin')
|
||||
viewer_user = AppUser.objects.create_user('mock_viewer', 'viewer@pytv.local', 'password')
|
||||
|
||||
self.stdout.write("Seeding Genres & Ratings...")
|
||||
g_action = Genre.objects.create(name="Action")
|
||||
g_comedy = Genre.objects.create(name="Comedy")
|
||||
g_drama = Genre.objects.create(name="Drama")
|
||||
g_scifi = Genre.objects.create(name="Sci-Fi")
|
||||
g_promo = Genre.objects.create(name="Promo/Bumper")
|
||||
|
||||
r_pg = ContentRating.objects.create(system_name="TV Parental Guidelines", code="TV-PG", min_age=8)
|
||||
r_14 = ContentRating.objects.create(system_name="TV Parental Guidelines", code="TV-14", min_age=14)
|
||||
r_ma = ContentRating.objects.create(system_name="TV Parental Guidelines", code="TV-MA", min_age=17)
|
||||
|
||||
self.stdout.write("Seeding Library & Media Sources...")
|
||||
lib = Library.objects.create(
|
||||
owner_user=admin_user,
|
||||
name="Main Broadcasting Library",
|
||||
visibility="public",
|
||||
description="The core streaming library for PYTV mock."
|
||||
)
|
||||
|
||||
source_movies = MediaSource.objects.create(
|
||||
library=lib,
|
||||
name="Mocked Local Movies",
|
||||
source_type="local_directory",
|
||||
uri="/mock/movies"
|
||||
)
|
||||
|
||||
source_tv = MediaSource.objects.create(
|
||||
library=lib,
|
||||
name="Mocked Local TV Shows",
|
||||
source_type="local_directory",
|
||||
uri="/mock/tv"
|
||||
)
|
||||
|
||||
source_bumpers = MediaSource.objects.create(
|
||||
library=lib,
|
||||
name="Network Bumpers",
|
||||
source_type="local_directory",
|
||||
uri="/mock/bumpers"
|
||||
)
|
||||
|
||||
self.stdout.write("Seeding Series & Media Items...")
|
||||
|
||||
# Movies
|
||||
m1 = MediaItem.objects.create(
|
||||
media_source=source_movies,
|
||||
title="Space Rangers 3000",
|
||||
item_kind="movie",
|
||||
release_year=1999,
|
||||
runtime_seconds=5400, # 1.5 hours
|
||||
file_path="/mock/movies/space_rangers.mp4",
|
||||
content_rating=r_pg
|
||||
)
|
||||
m1.genres.add(g_action, g_scifi)
|
||||
|
||||
m2 = MediaItem.objects.create(
|
||||
media_source=source_movies,
|
||||
title="The Laughing Policeman",
|
||||
item_kind="movie",
|
||||
release_year=2005,
|
||||
runtime_seconds=6300, # 1.75 hours
|
||||
file_path="/mock/movies/laughing_policeman.mp4",
|
||||
content_rating=r_14
|
||||
)
|
||||
m2.genres.add(g_comedy, g_action)
|
||||
|
||||
# Series
|
||||
s1 = Series.objects.create(title="Neon City Nights", description="Cyberpunk detective drama.", release_year=2024)
|
||||
|
||||
for ep in range(1, 6):
|
||||
ep_item = MediaItem.objects.create(
|
||||
media_source=source_tv,
|
||||
series=s1,
|
||||
title=f"Episode {ep}",
|
||||
item_kind="episode",
|
||||
season_number=1,
|
||||
episode_number=ep,
|
||||
runtime_seconds=2700, # 45 mins
|
||||
file_path=f"/mock/tv/neon_city_nights/s01e0{ep}.mkv",
|
||||
content_rating=r_ma
|
||||
)
|
||||
ep_item.genres.add(g_drama, g_scifi)
|
||||
|
||||
# Bumpers
|
||||
for b in range(1, 4):
|
||||
bump = MediaItem.objects.create(
|
||||
media_source=source_bumpers,
|
||||
title=f"Station Bumper {b}",
|
||||
item_kind="bumper",
|
||||
runtime_seconds=15,
|
||||
file_path=f"/mock/bumpers/ident_{b}.mp4"
|
||||
)
|
||||
bump.genres.add(g_promo)
|
||||
|
||||
self.stdout.write("Seeding Channels and Scheduling Blocks...")
|
||||
ch1 = Channel.objects.create(
|
||||
owner_user=admin_user,
|
||||
library=lib,
|
||||
name="PYTV One",
|
||||
slug="pytv-1",
|
||||
channel_number=1,
|
||||
description="The flagship generic network.",
|
||||
scheduling_mode="template_driven",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
template = ScheduleTemplate.objects.create(
|
||||
channel=ch1,
|
||||
name="Standard Weekday",
|
||||
timezone_name="UTC",
|
||||
priority=10
|
||||
)
|
||||
|
||||
# Prime time block
|
||||
b_prime = ScheduleBlock.objects.create(
|
||||
schedule_template=template,
|
||||
name="Prime Time Drama",
|
||||
block_type="programming",
|
||||
start_local_time=time(20, 0),
|
||||
end_local_time=time(23, 0),
|
||||
day_of_week_mask=127, # Everyday
|
||||
default_genre=g_drama,
|
||||
rotation_strategy="sequential",
|
||||
pad_strategy="fill_with_interstitials"
|
||||
)
|
||||
|
||||
ch2 = Channel.objects.create(
|
||||
owner_user=admin_user,
|
||||
library=lib,
|
||||
name="Tears of Steel Channel",
|
||||
slug="tears-of-steel",
|
||||
channel_number=2,
|
||||
description="All Sci-Fi all the time.",
|
||||
scheduling_mode="template_driven",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
template2 = ScheduleTemplate.objects.create(
|
||||
channel=ch2,
|
||||
name="Sci-Fi Everyday",
|
||||
timezone_name="UTC",
|
||||
priority=10
|
||||
)
|
||||
|
||||
ScheduleBlock.objects.create(
|
||||
schedule_template=template2,
|
||||
name="Sci-Fi Block",
|
||||
block_type="programming",
|
||||
start_local_time=time(0, 0),
|
||||
end_local_time=time(23, 59, 59),
|
||||
day_of_week_mask=127,
|
||||
default_genre=g_scifi,
|
||||
rotation_strategy="random",
|
||||
pad_strategy="none"
|
||||
)
|
||||
|
||||
ch3 = Channel.objects.create(
|
||||
owner_user=admin_user,
|
||||
library=lib,
|
||||
name="Sintel Classics",
|
||||
slug="sintel-classics",
|
||||
channel_number=3,
|
||||
description="Classic movies and animation.",
|
||||
scheduling_mode="template_driven",
|
||||
visibility="public"
|
||||
)
|
||||
|
||||
template3 = ScheduleTemplate.objects.create(
|
||||
channel=ch3,
|
||||
name="Comedy and Action",
|
||||
timezone_name="UTC",
|
||||
priority=10
|
||||
)
|
||||
|
||||
ScheduleBlock.objects.create(
|
||||
schedule_template=template3,
|
||||
name="Action Comedy",
|
||||
block_type="programming",
|
||||
start_local_time=time(0, 0),
|
||||
end_local_time=time(23, 59, 59),
|
||||
day_of_week_mask=127,
|
||||
default_genre=g_action,
|
||||
rotation_strategy="random",
|
||||
pad_strategy="none"
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully seeded the PYTV database with mock data."))
|
||||
60
core/management/commands/state.py
Normal file
60
core/management/commands/state.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from core.models import AppUser, Library, Channel, MediaItem, Airing, ScheduleTemplate
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Displays a beautifully formatted terminal dashboard of the current backend state."
|
||||
|
||||
def get_color(self, text, code):
|
||||
"""Helper to wrap string in bash color codes"""
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# 1. Gather Aggregate Metrics
|
||||
total_users = AppUser.objects.count()
|
||||
total_libraries = Library.objects.count()
|
||||
total_channels = Channel.objects.count()
|
||||
total_media = MediaItem.objects.count()
|
||||
total_templates = ScheduleTemplate.objects.count()
|
||||
|
||||
# Gather near-term Airing metrics
|
||||
now = timezone.now()
|
||||
tomorrow = now + timedelta(days=1)
|
||||
airings_today = Airing.objects.filter(starts_at__gte=now, starts_at__lt=tomorrow).count()
|
||||
airings_total = Airing.objects.count()
|
||||
|
||||
self.stdout.write(self.get_color("\n=== PYTV Backend State Dashboard ===", "1;34"))
|
||||
self.stdout.write(f"\n{self.get_color('Core Metrics:', '1;36')}")
|
||||
self.stdout.write(f" Users: {self.get_color(str(total_users), '1;32')}")
|
||||
self.stdout.write(f" Libraries: {self.get_color(str(total_libraries), '1;32')}")
|
||||
self.stdout.write(f" Media Items: {self.get_color(str(total_media), '1;32')}")
|
||||
self.stdout.write(f" Channels: {self.get_color(str(total_channels), '1;32')}")
|
||||
|
||||
self.stdout.write(f"\n{self.get_color('Scheduling Engine:', '1;36')}")
|
||||
self.stdout.write(f" Active Templates: {self.get_color(str(total_templates), '1;33')}")
|
||||
self.stdout.write(f" Airings (Next 24h): {self.get_color(str(airings_today), '1;33')}")
|
||||
self.stdout.write(f" Airings (Total): {self.get_color(str(airings_total), '1;33')}")
|
||||
|
||||
# 2. Build Tree View of Channels
|
||||
self.stdout.write(f"\n{self.get_color('Channel Tree:', '1;36')}")
|
||||
|
||||
channels = Channel.objects.prefetch_related('scheduletemplate_set').all()
|
||||
if not channels:
|
||||
self.stdout.write(" (No channels configured)")
|
||||
|
||||
for c in channels:
|
||||
status_color = "1;32" if c.is_active else "1;31"
|
||||
status_text = "ACTIVE" if c.is_active else "INACTIVE"
|
||||
self.stdout.write(f"\n 📺 [{c.channel_number or '-'}] {c.name} ({self.get_color(status_text, status_color)})")
|
||||
|
||||
# Show templates
|
||||
templates = c.scheduletemplate_set.filter(is_active=True).order_by('-priority')
|
||||
if not templates:
|
||||
self.stdout.write(" ⚠️ No active schedule templates.")
|
||||
else:
|
||||
for t in templates:
|
||||
blocks_count = t.scheduleblock_set.count()
|
||||
self.stdout.write(f" 📄 Template: {t.name} (Priority {t.priority}) -> {blocks_count} Blocks")
|
||||
|
||||
self.stdout.write("\n")
|
||||
1293
core/migrations/0001_initial.py
Normal file
1293
core/migrations/0001_initial.py
Normal file
File diff suppressed because it is too large
Load Diff
0
core/migrations/__init__.py
Normal file
0
core/migrations/__init__.py
Normal file
490
core/models.py
Normal file
490
core/models.py
Normal file
@@ -0,0 +1,490 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# users and access control
|
||||
# ------------------------------------------------------------
|
||||
|
||||
class AppUser(AbstractUser):
|
||||
# Using Django's built-in AbstractUser which covers username, email, password_hash (password),
|
||||
# last_login, created_at (date_joined), is_admin (is_staff/is_superuser).
|
||||
pass
|
||||
|
||||
class Library(models.Model):
|
||||
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
class Visibility(models.TextChoices):
|
||||
PRIVATE = 'private', 'Private'
|
||||
SHARED = 'shared', 'Shared'
|
||||
PUBLIC = 'public', 'Public'
|
||||
|
||||
visibility = models.CharField(
|
||||
max_length=16,
|
||||
choices=Visibility.choices,
|
||||
default=Visibility.PRIVATE
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['owner_user', 'name'], name='unique_library_name_per_user')
|
||||
]
|
||||
|
||||
|
||||
class LibraryMember(models.Model):
|
||||
library = models.ForeignKey(Library, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||
|
||||
class Role(models.TextChoices):
|
||||
VIEWER = 'viewer', 'Viewer'
|
||||
EDITOR = 'editor', 'Editor'
|
||||
MANAGER = 'manager', 'Manager'
|
||||
|
||||
role = models.CharField(max_length=16, choices=Role.choices)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['library', 'user'], name='unique_library_member')
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# media metadata
|
||||
# ------------------------------------------------------------
|
||||
|
||||
class Genre(models.Model):
|
||||
parent_genre = models.ForeignKey('self', on_delete=models.SET_NULL, blank=True, null=True)
|
||||
name = models.CharField(max_length=128, unique=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
|
||||
class ContentRating(models.Model):
|
||||
system_name = models.CharField(max_length=64)
|
||||
code = models.CharField(max_length=32)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
min_age = models.IntegerField(blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['system_name', 'code'], name='unique_content_rating')
|
||||
]
|
||||
|
||||
|
||||
class MediaSource(models.Model):
|
||||
library = models.ForeignKey(Library, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
class SourceType(models.TextChoices):
|
||||
LOCAL_DIRECTORY = 'local_directory', 'Local Directory'
|
||||
NETWORK_SHARE = 'network_share', 'Network Share'
|
||||
MANUAL_IMPORT = 'manual_import', 'Manual Import'
|
||||
PLAYLIST = 'playlist', 'Playlist'
|
||||
STREAM = 'stream', 'Stream'
|
||||
API_FEED = 'api_feed', 'API Feed'
|
||||
|
||||
source_type = models.CharField(max_length=32, choices=SourceType.choices)
|
||||
uri = models.TextField()
|
||||
recursive_scan = models.BooleanField(default=True)
|
||||
include_commercials = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
scan_interval_minutes = models.IntegerField(blank=True, null=True)
|
||||
last_scanned_at = models.DateTimeField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['library', 'name'], name='unique_media_source_name_per_library')
|
||||
]
|
||||
|
||||
|
||||
class MediaSourceRule(models.Model):
|
||||
media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE)
|
||||
|
||||
class RuleType(models.TextChoices):
|
||||
INCLUDE_GLOB = 'include_glob', 'Include Glob'
|
||||
EXCLUDE_GLOB = 'exclude_glob', 'Exclude Glob'
|
||||
INCLUDE_REGEX = 'include_regex', 'Include Regex'
|
||||
EXCLUDE_REGEX = 'exclude_regex', 'Exclude Regex'
|
||||
|
||||
rule_type = models.CharField(max_length=24, choices=RuleType.choices)
|
||||
rule_value = models.TextField()
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
|
||||
class Series(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
release_year = models.IntegerField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class MediaItem(models.Model):
|
||||
media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE)
|
||||
series = models.ForeignKey(Series, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
title = models.CharField(max_length=255)
|
||||
sort_title = models.CharField(max_length=255, blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
class ItemKind(models.TextChoices):
|
||||
MOVIE = 'movie', 'Movie'
|
||||
EPISODE = 'episode', 'Episode'
|
||||
SPECIAL = 'special', 'Special'
|
||||
MUSIC_VIDEO = 'music_video', 'Music Video'
|
||||
BUMPER = 'bumper', 'Bumper'
|
||||
INTERSTITIAL = 'interstitial', 'Interstitial'
|
||||
COMMERCIAL = 'commercial', 'Commercial'
|
||||
|
||||
item_kind = models.CharField(max_length=24, choices=ItemKind.choices, db_index=True)
|
||||
season_number = models.IntegerField(blank=True, null=True)
|
||||
episode_number = models.IntegerField(blank=True, null=True)
|
||||
release_year = models.IntegerField(blank=True, null=True)
|
||||
runtime_seconds = models.PositiveIntegerField()
|
||||
file_path = models.TextField()
|
||||
file_hash = models.CharField(max_length=128, blank=True, null=True)
|
||||
thumbnail_path = models.TextField(blank=True, null=True)
|
||||
language_code = models.CharField(max_length=16, blank=True, null=True)
|
||||
content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
date_added_at = models.DateTimeField(auto_now_add=True)
|
||||
metadata_json = models.JSONField(default=dict)
|
||||
|
||||
genres = models.ManyToManyField(Genre, related_name="media_items", blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['media_source', 'file_path'], name='unique_media_item_path_per_source')
|
||||
]
|
||||
|
||||
|
||||
class MediaCollection(models.Model):
|
||||
library = models.ForeignKey(Library, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
class CollectionType(models.TextChoices):
|
||||
MANUAL = 'manual', 'Manual'
|
||||
SMART = 'smart', 'Smart'
|
||||
|
||||
collection_type = models.CharField(max_length=24, choices=CollectionType.choices)
|
||||
definition_json = models.JSONField(default=dict)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
media_items = models.ManyToManyField(MediaItem, through='MediaCollectionItem')
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['library', 'name'], name='unique_media_collection_name_per_library')
|
||||
]
|
||||
|
||||
|
||||
class MediaCollectionItem(models.Model):
|
||||
media_collection = models.ForeignKey(MediaCollection, on_delete=models.CASCADE)
|
||||
media_item = models.ForeignKey(MediaItem, on_delete=models.CASCADE)
|
||||
sort_order = models.IntegerField(default=0)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# channels and programming policy
|
||||
# ------------------------------------------------------------
|
||||
|
||||
class Channel(models.Model):
|
||||
owner_user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||
library = models.ForeignKey(Library, on_delete=models.CASCADE)
|
||||
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=64, unique=True)
|
||||
channel_number = models.IntegerField(blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
timezone_name = models.CharField(max_length=64, default='UTC')
|
||||
|
||||
class Visibility(models.TextChoices):
|
||||
PRIVATE = 'private', 'Private'
|
||||
SHARED = 'shared', 'Shared'
|
||||
PUBLIC = 'public', 'Public'
|
||||
|
||||
visibility = models.CharField(max_length=16, choices=Visibility.choices, default=Visibility.PRIVATE)
|
||||
|
||||
class SchedulingMode(models.TextChoices):
|
||||
TEMPLATE_DRIVEN = 'template_driven', 'Template Driven'
|
||||
ALGORITHMIC = 'algorithmic', 'Algorithmic'
|
||||
MIXED = 'mixed', 'Mixed'
|
||||
|
||||
scheduling_mode = models.CharField(max_length=24, choices=SchedulingMode.choices, default=SchedulingMode.TEMPLATE_DRIVEN)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['library', 'name'], name='unique_channel_name_per_library'),
|
||||
models.UniqueConstraint(fields=['library', 'channel_number'], name='unique_channel_number_per_library')
|
||||
]
|
||||
|
||||
|
||||
class ChannelMember(models.Model):
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||
|
||||
class Role(models.TextChoices):
|
||||
VIEWER = 'viewer', 'Viewer'
|
||||
EDITOR = 'editor', 'Editor'
|
||||
MANAGER = 'manager', 'Manager'
|
||||
|
||||
role = models.CharField(max_length=16, choices=Role.choices)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['channel', 'user'], name='unique_channel_member')
|
||||
]
|
||||
|
||||
|
||||
class ChannelSourceRule(models.Model):
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
media_source = models.ForeignKey(MediaSource, on_delete=models.CASCADE, blank=True, null=True)
|
||||
media_collection = models.ForeignKey(MediaCollection, on_delete=models.CASCADE, blank=True, null=True)
|
||||
|
||||
class RuleMode(models.TextChoices):
|
||||
ALLOW = 'allow', 'Allow'
|
||||
PREFER = 'prefer', 'Prefer'
|
||||
AVOID = 'avoid', 'Avoid'
|
||||
BLOCK = 'block', 'Block'
|
||||
|
||||
rule_mode = models.CharField(max_length=24, choices=RuleMode.choices)
|
||||
weight = models.DecimalField(max_digits=10, decimal_places=4, default=1.0)
|
||||
max_items_per_day = models.IntegerField(blank=True, null=True)
|
||||
max_runs_per_day = models.IntegerField(blank=True, null=True)
|
||||
min_repeat_gap_hours = models.IntegerField(blank=True, null=True)
|
||||
active_from = models.DateTimeField(blank=True, null=True)
|
||||
active_to = models.DateTimeField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=(
|
||||
models.Q(media_source__isnull=False, media_collection__isnull=True) |
|
||||
models.Q(media_source__isnull=True, media_collection__isnull=False)
|
||||
),
|
||||
name='exactly_one_source_target_channel_source_rule'
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class CommercialPolicy(models.Model):
|
||||
name = models.CharField(max_length=255, unique=True)
|
||||
|
||||
class Mode(models.TextChoices):
|
||||
NONE = 'none', 'None'
|
||||
REPLACE_BREAKS = 'replace_breaks', 'Replace Breaks'
|
||||
FILL_TO_TARGET = 'fill_to_target', 'Fill to Target'
|
||||
PROBABILISTIC = 'probabilistic', 'Probabilistic'
|
||||
|
||||
mode = models.CharField(max_length=24, choices=Mode.choices)
|
||||
target_break_seconds = models.IntegerField(blank=True, null=True)
|
||||
max_break_seconds = models.IntegerField(blank=True, null=True)
|
||||
allow_same_ad_back_to_back = models.BooleanField(default=False)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
|
||||
|
||||
class ChannelBranding(models.Model):
|
||||
channel = models.OneToOneField(Channel, on_delete=models.CASCADE)
|
||||
commercial_policy = models.ForeignKey(CommercialPolicy, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
logo_path = models.TextField(blank=True, null=True)
|
||||
bumper_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True, related_name='branded_channels_bumpers')
|
||||
fallback_fill_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True, related_name='branded_channels_fallback')
|
||||
station_id_audio_path = models.TextField(blank=True, null=True)
|
||||
config_json = models.JSONField(default=dict)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# schedule templates: recurring editorial structure
|
||||
# ------------------------------------------------------------
|
||||
|
||||
class ScheduleTemplate(models.Model):
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
timezone_name = models.CharField(max_length=64)
|
||||
valid_from_date = models.DateField(blank=True, null=True)
|
||||
valid_to_date = models.DateField(blank=True, null=True)
|
||||
priority = models.IntegerField(default=0)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['channel', 'name'], name='unique_schedule_template_name_per_channel')
|
||||
]
|
||||
|
||||
|
||||
class ScheduleBlock(models.Model):
|
||||
schedule_template = models.ForeignKey(ScheduleTemplate, on_delete=models.CASCADE)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
class BlockType(models.TextChoices):
|
||||
PROGRAMMING = 'programming', 'Programming'
|
||||
COMMERCIAL = 'commercial', 'Commercial'
|
||||
FILLER = 'filler', 'Filler'
|
||||
OFF_AIR = 'off_air', 'Off Air'
|
||||
SPECIAL_EVENT = 'special_event', 'Special Event'
|
||||
|
||||
block_type = models.CharField(max_length=24, choices=BlockType.choices)
|
||||
start_local_time = models.TimeField()
|
||||
end_local_time = models.TimeField()
|
||||
day_of_week_mask = models.SmallIntegerField() # 1 to 127
|
||||
spills_past_midnight = models.BooleanField(default=False)
|
||||
default_genre = models.ForeignKey(Genre, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
min_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+')
|
||||
max_content_rating = models.ForeignKey(ContentRating, on_delete=models.SET_NULL, blank=True, null=True, related_name='+')
|
||||
preferred_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
preferred_source = models.ForeignKey(MediaSource, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
|
||||
class RotationStrategy(models.TextChoices):
|
||||
SHUFFLE = 'shuffle', 'Shuffle'
|
||||
SEQUENTIAL = 'sequential', 'Sequential'
|
||||
LEAST_RECENT = 'least_recent', 'Least Recent'
|
||||
WEIGHTED_RANDOM = 'weighted_random', 'Weighted Random'
|
||||
|
||||
rotation_strategy = models.CharField(max_length=24, choices=RotationStrategy.choices, default=RotationStrategy.SHUFFLE)
|
||||
|
||||
class PadStrategy(models.TextChoices):
|
||||
HARD_STOP = 'hard_stop', 'Hard Stop'
|
||||
TRUNCATE = 'truncate', 'Truncate'
|
||||
FILL_WITH_INTERSTITIALS = 'fill_with_interstitials', 'Fill With Interstitials'
|
||||
ALLOW_OVERRUN = 'allow_overrun', 'Allow Overrun'
|
||||
|
||||
pad_strategy = models.CharField(max_length=24, choices=PadStrategy.choices, default=PadStrategy.FILL_WITH_INTERSTITIALS)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class BlockSlot(models.Model):
|
||||
schedule_block = models.ForeignKey(ScheduleBlock, on_delete=models.CASCADE)
|
||||
slot_order = models.IntegerField()
|
||||
|
||||
class SlotKind(models.TextChoices):
|
||||
FIXED_ITEM = 'fixed_item', 'Fixed Item'
|
||||
DYNAMIC_PICK = 'dynamic_pick', 'Dynamic Pick'
|
||||
COMMERCIAL_BREAK = 'commercial_break', 'Commercial Break'
|
||||
BUMPER = 'bumper', 'Bumper'
|
||||
STATION_ID = 'station_id', 'Station ID'
|
||||
|
||||
slot_kind = models.CharField(max_length=24, choices=SlotKind.choices)
|
||||
media_item = models.ForeignKey(MediaItem, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
media_collection = models.ForeignKey(MediaCollection, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
expected_duration_seconds = models.IntegerField(blank=True, null=True)
|
||||
max_duration_seconds = models.IntegerField(blank=True, null=True)
|
||||
is_mandatory = models.BooleanField(default=True)
|
||||
selection_rule_json = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['schedule_block', 'slot_order'], name='unique_slot_order_per_block')
|
||||
]
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# concrete schedule: actual planned / generated airtimes
|
||||
# ------------------------------------------------------------
|
||||
|
||||
class Airing(models.Model):
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
schedule_template = models.ForeignKey(ScheduleTemplate, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
schedule_block = models.ForeignKey(ScheduleBlock, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
media_item = models.ForeignKey(MediaItem, on_delete=models.RESTRICT)
|
||||
starts_at = models.DateTimeField(db_index=True)
|
||||
ends_at = models.DateTimeField(db_index=True)
|
||||
|
||||
class SlotKind(models.TextChoices):
|
||||
PROGRAM = 'program', 'Program'
|
||||
COMMERCIAL = 'commercial', 'Commercial'
|
||||
BUMPER = 'bumper', 'Bumper'
|
||||
INTERSTITIAL = 'interstitial', 'Interstitial'
|
||||
STATION_ID = 'station_id', 'Station ID'
|
||||
|
||||
slot_kind = models.CharField(max_length=24, choices=SlotKind.choices)
|
||||
|
||||
class Status(models.TextChoices):
|
||||
SCHEDULED = 'scheduled', 'Scheduled'
|
||||
PLAYING = 'playing', 'Playing'
|
||||
PLAYED = 'played', 'Played'
|
||||
SKIPPED = 'skipped', 'Skipped'
|
||||
INTERRUPTED = 'interrupted', 'Interrupted'
|
||||
CANCELLED = 'cancelled', 'Cancelled'
|
||||
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.SCHEDULED, db_index=True)
|
||||
|
||||
class SourceReason(models.TextChoices):
|
||||
TEMPLATE = 'template', 'Template'
|
||||
AUTOFILL = 'autofill', 'Autofill'
|
||||
MANUAL = 'manual', 'Manual'
|
||||
RECOVERY = 'recovery', 'Recovery'
|
||||
|
||||
source_reason = models.CharField(max_length=24, choices=SourceReason.choices)
|
||||
generation_batch_uuid = models.UUIDField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class AiringEvent(models.Model):
|
||||
airing = models.ForeignKey(Airing, on_delete=models.CASCADE)
|
||||
|
||||
class EventType(models.TextChoices):
|
||||
SCHEDULED = 'scheduled', 'Scheduled'
|
||||
STARTED = 'started', 'Started'
|
||||
ENDED = 'ended', 'Ended'
|
||||
SKIPPED = 'skipped', 'Skipped'
|
||||
INTERRUPTED = 'interrupted', 'Interrupted'
|
||||
RESUMED = 'resumed', 'Resumed'
|
||||
CANCELLED = 'cancelled', 'Cancelled'
|
||||
|
||||
event_type = models.CharField(max_length=24, choices=EventType.choices)
|
||||
event_at = models.DateTimeField(auto_now_add=True)
|
||||
details_json = models.JSONField(default=dict)
|
||||
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# viewer state and history
|
||||
# ------------------------------------------------------------
|
||||
|
||||
class UserChannelState(models.Model):
|
||||
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||
channel = models.ForeignKey(Channel, on_delete=models.CASCADE)
|
||||
is_favorite = models.BooleanField(default=False)
|
||||
last_tuned_at = models.DateTimeField(blank=True, null=True)
|
||||
last_known_airing = models.ForeignKey(Airing, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'channel'], name='unique_user_channel_state')
|
||||
]
|
||||
|
||||
|
||||
class WatchSession(models.Model):
|
||||
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||
channel = models.ForeignKey(Channel, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
airing = models.ForeignKey(Airing, on_delete=models.SET_NULL, blank=True, null=True)
|
||||
started_at = models.DateTimeField(db_index=True)
|
||||
ended_at = models.DateTimeField(blank=True, null=True)
|
||||
position_seconds = models.PositiveIntegerField(default=0)
|
||||
client_id = models.CharField(max_length=128, blank=True, null=True)
|
||||
session_metadata = models.JSONField(default=dict)
|
||||
|
||||
|
||||
class MediaResumePoint(models.Model):
|
||||
user = models.ForeignKey(AppUser, on_delete=models.CASCADE)
|
||||
media_item = models.ForeignKey(MediaItem, on_delete=models.CASCADE)
|
||||
resume_seconds = models.PositiveIntegerField()
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'media_item'], name='unique_media_resume_point')
|
||||
]
|
||||
0
core/services/__init__.py
Normal file
0
core/services/__init__.py
Normal file
108
core/services/scheduler.py
Normal file
108
core/services/scheduler.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from datetime import datetime, timedelta, date, time, timezone
|
||||
from core.models import Channel, ScheduleTemplate, ScheduleBlock, Airing, MediaItem
|
||||
import random
|
||||
|
||||
class ScheduleGenerator:
|
||||
"""
|
||||
A service that reads the latest ScheduleTemplate and Blocks for a given channel
|
||||
and generates concrete Airings logic based on available matching MediaItems.
|
||||
"""
|
||||
|
||||
def __init__(self, channel: Channel):
|
||||
self.channel = channel
|
||||
|
||||
def generate_for_date(self, target_date: date) -> int:
|
||||
"""
|
||||
Idempotent generation of airings for a specific date on this channel.
|
||||
Returns the number of new Airings created.
|
||||
"""
|
||||
# 1. Get the highest priority active template valid on this date
|
||||
template = ScheduleTemplate.objects.filter(
|
||||
channel=self.channel,
|
||||
is_active=True
|
||||
).filter(
|
||||
# Start date is null or <= target_date
|
||||
valid_from_date__isnull=True
|
||||
).order_by('-priority').first()
|
||||
|
||||
# In a real app we'd construct complex Q objects for the valid dates,
|
||||
# but for PYTV mock we will just grab the highest priority active template.
|
||||
if not template:
|
||||
template = ScheduleTemplate.objects.filter(channel=self.channel, is_active=True).order_by('-priority').first()
|
||||
if not template:
|
||||
return 0
|
||||
|
||||
# 2. Extract day of week mask
|
||||
# Python weekday: 0=Monday, 6=Sunday
|
||||
# Our mask: bit 0 = Monday, bit 6 = Sunday
|
||||
target_weekday_bit = 1 << target_date.weekday()
|
||||
|
||||
blocks = template.scheduleblock_set.all()
|
||||
airings_created = 0
|
||||
|
||||
for block in blocks:
|
||||
# Check if block runs on this day
|
||||
if not (block.day_of_week_mask & target_weekday_bit):
|
||||
continue
|
||||
|
||||
# Naive time combining mapping local time to UTC timeline without specific tz logic for simplicity now
|
||||
start_dt = datetime.combine(target_date, block.start_local_time, tzinfo=timezone.utc)
|
||||
end_dt = datetime.combine(target_date, block.end_local_time, tzinfo=timezone.utc)
|
||||
|
||||
# If the block wraps past midnight (e.g. 23:00 to 02:00)
|
||||
if end_dt <= start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
|
||||
# Clear existing airings in this window to allow idempotency
|
||||
Airing.objects.filter(
|
||||
channel=self.channel,
|
||||
starts_at__gte=start_dt,
|
||||
starts_at__lt=end_dt
|
||||
).delete()
|
||||
|
||||
# 3. Pull matching Media Items
|
||||
# Simplistic matching: pull items from library matching the block's genre
|
||||
items_query = MediaItem.objects.filter(media_source__library=self.channel.library)
|
||||
if block.default_genre:
|
||||
items_query = items_query.filter(genres=block.default_genre)
|
||||
|
||||
available_items = list(items_query.exclude(item_kind="bumper"))
|
||||
if not available_items:
|
||||
continue
|
||||
|
||||
# Shuffle randomly for basic scheduling variety
|
||||
random.shuffle(available_items)
|
||||
|
||||
# 4. Fill the block
|
||||
current_cursor = start_dt
|
||||
item_index = 0
|
||||
|
||||
while current_cursor < end_dt and item_index < len(available_items):
|
||||
item = available_items[item_index]
|
||||
duration = timedelta(seconds=item.runtime_seconds or 3600)
|
||||
|
||||
# Check if this item fits
|
||||
if current_cursor + duration > end_dt:
|
||||
# Item doesn't strictly fit, but we'll squeeze it in and break if needed
|
||||
# Real systems pad this out or trim the slot.
|
||||
pass
|
||||
|
||||
import uuid
|
||||
Airing.objects.create(
|
||||
channel=self.channel,
|
||||
schedule_template=template,
|
||||
schedule_block=block,
|
||||
media_item=item,
|
||||
starts_at=current_cursor,
|
||||
ends_at=current_cursor + duration,
|
||||
slot_kind="program",
|
||||
status="scheduled",
|
||||
source_reason="template",
|
||||
generation_batch_uuid=uuid.uuid4()
|
||||
)
|
||||
|
||||
current_cursor += duration
|
||||
item_index += 1
|
||||
airings_created += 1
|
||||
|
||||
return airings_created
|
||||
127
core/tests.py
Normal file
127
core/tests.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from django.test import TestCase
|
||||
from django.db import IntegrityError, transaction
|
||||
from core.models import (
|
||||
AppUser, Library, LibraryMember, Genre, ContentRating, MediaSource,
|
||||
Series, MediaItem, Channel, ScheduleTemplate, ScheduleBlock, Airing
|
||||
)
|
||||
from datetime import time
|
||||
from django.utils import timezone
|
||||
from uuid import uuid4
|
||||
|
||||
class PytvCoreTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user1 = AppUser.objects.create_user(username="testuser1", email="test1@example.com", password="password123")
|
||||
self.user2 = AppUser.objects.create_user(username="testuser2", email="test2@example.com", password="password123")
|
||||
|
||||
def test_user_and_library_creation(self):
|
||||
# Create library
|
||||
lib = Library.objects.create(owner_user=self.user1, name="My Movies")
|
||||
self.assertEqual(lib.visibility, Library.Visibility.PRIVATE)
|
||||
|
||||
# Test unique constraint: Cannot have two libraries with the same name for the same user
|
||||
with transaction.atomic():
|
||||
with self.assertRaises(IntegrityError):
|
||||
Library.objects.create(owner_user=self.user1, name="My Movies")
|
||||
|
||||
# Different user can have the same library name
|
||||
lib2 = Library.objects.create(owner_user=self.user2, name="My Movies")
|
||||
self.assertEqual(lib2.name, "My Movies")
|
||||
|
||||
def test_media_source_and_item(self):
|
||||
lib = Library.objects.create(owner_user=self.user1, name="My Movies")
|
||||
source = MediaSource.objects.create(
|
||||
library=lib,
|
||||
name="Local NAS",
|
||||
source_type=MediaSource.SourceType.LOCAL_DIRECTORY,
|
||||
uri="/mnt/nas/movies"
|
||||
)
|
||||
|
||||
item = MediaItem.objects.create(
|
||||
media_source=source,
|
||||
title="Cool Movie",
|
||||
item_kind=MediaItem.ItemKind.MOVIE,
|
||||
runtime_seconds=7200,
|
||||
file_path="/mnt/nas/movies/cool_movie.mp4"
|
||||
)
|
||||
|
||||
self.assertEqual(item.runtime_seconds, 7200)
|
||||
|
||||
# Test unique constraint (same media source, same path)
|
||||
with transaction.atomic():
|
||||
with self.assertRaises(IntegrityError):
|
||||
MediaItem.objects.create(
|
||||
media_source=source,
|
||||
title="Another Movie",
|
||||
item_kind=MediaItem.ItemKind.MOVIE,
|
||||
runtime_seconds=5000,
|
||||
file_path="/mnt/nas/movies/cool_movie.mp4"
|
||||
)
|
||||
|
||||
# Test M2M Genre works beautifully
|
||||
genre1 = Genre.objects.create(name="Action")
|
||||
genre2 = Genre.objects.create(name="Sci-Fi")
|
||||
item.genres.add(genre1, genre2)
|
||||
self.assertEqual(item.genres.count(), 2)
|
||||
|
||||
def test_channel_and_scheduling(self):
|
||||
lib = Library.objects.create(owner_user=self.user1, name="TV Network")
|
||||
channel = Channel.objects.create(
|
||||
owner_user=self.user1,
|
||||
library=lib,
|
||||
name="Channel 1",
|
||||
slug="ch-1",
|
||||
channel_number=1
|
||||
)
|
||||
|
||||
template = ScheduleTemplate.objects.create(
|
||||
channel=channel,
|
||||
name="Weekday Prime Time",
|
||||
timezone_name="America/New_York"
|
||||
)
|
||||
|
||||
block = ScheduleBlock.objects.create(
|
||||
schedule_template=template,
|
||||
name="8PM Movie",
|
||||
block_type=ScheduleBlock.BlockType.PROGRAMMING,
|
||||
start_local_time=time(20, 0),
|
||||
end_local_time=time(22, 0),
|
||||
day_of_week_mask=62 # Mon-Fri bitmask
|
||||
)
|
||||
|
||||
self.assertEqual(block.pad_strategy, ScheduleBlock.PadStrategy.FILL_WITH_INTERSTITIALS)
|
||||
|
||||
def test_airing_constraints(self):
|
||||
lib = Library.objects.create(owner_user=self.user1, name="TV Network")
|
||||
source = MediaSource.objects.create(
|
||||
library=lib,
|
||||
name="Local NAS",
|
||||
source_type=MediaSource.SourceType.LOCAL_DIRECTORY,
|
||||
uri="/mnt/nas/tv"
|
||||
)
|
||||
item = MediaItem.objects.create(
|
||||
media_source=source,
|
||||
title="Promo",
|
||||
item_kind=MediaItem.ItemKind.BUMPER,
|
||||
runtime_seconds=15,
|
||||
file_path="/mnt/nas/tv/promo.mp4"
|
||||
)
|
||||
channel = Channel.objects.create(
|
||||
owner_user=self.user1,
|
||||
library=lib,
|
||||
name="Promo Channel",
|
||||
slug="promo-ch",
|
||||
channel_number=99
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
airing = Airing.objects.create(
|
||||
channel=channel,
|
||||
media_item=item,
|
||||
starts_at=now,
|
||||
ends_at=now + timezone.timedelta(seconds=15),
|
||||
slot_kind=Airing.SlotKind.BUMPER,
|
||||
source_reason=Airing.SourceReason.MANUAL,
|
||||
generation_batch_uuid=uuid4()
|
||||
)
|
||||
|
||||
self.assertEqual(airing.status, Airing.Status.SCHEDULED)
|
||||
3
core/views.py
Normal file
3
core/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user