feat(main): initial commit
This commit is contained in:
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')
|
||||
]
|
||||
Reference in New Issue
Block a user