forked from rc/aircox
cfr #121 Co-authored-by: Christophe Siraut <d@tobald.eu.org> Co-authored-by: bkfox <thomas bkfox net> Co-authored-by: Thomas Kairos <thomas@bkfox.net> Reviewed-on: rc/aircox#131 Co-authored-by: Chris Tactic <ctactic@noreply.git.radiocampus.be> Co-committed-by: Chris Tactic <ctactic@noreply.git.radiocampus.be>
This commit is contained in:
@ -1,28 +1,30 @@
|
||||
from . import signals
|
||||
from .article import Article
|
||||
from .diffusion import Diffusion, DiffusionQuerySet
|
||||
from .episode import Episode
|
||||
from .episode import Episode, EpisodeSound
|
||||
from .log import Log, LogQuerySet
|
||||
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
|
||||
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
|
||||
from .schedule import Schedule
|
||||
from .sound import Sound, SoundQuerySet, Track
|
||||
from .sound import Sound, SoundQuerySet
|
||||
from .station import Port, Station, StationQuerySet
|
||||
from .track import Track
|
||||
from .user_settings import UserSettings
|
||||
|
||||
__all__ = (
|
||||
"signals",
|
||||
"Article",
|
||||
"Episode",
|
||||
"Category",
|
||||
"Comment",
|
||||
"Diffusion",
|
||||
"DiffusionQuerySet",
|
||||
"Episode",
|
||||
"EpisodeSound",
|
||||
"Log",
|
||||
"LogQuerySet",
|
||||
"Category",
|
||||
"PageQuerySet",
|
||||
"Page",
|
||||
"StaticPage",
|
||||
"Comment",
|
||||
"NavItem",
|
||||
"Program",
|
||||
"ProgramQuerySet",
|
||||
|
@ -1,13 +1,14 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .page import Page
|
||||
from .page import ChildPage
|
||||
from .program import ProgramChildQuerySet
|
||||
|
||||
__all__ = ("Article",)
|
||||
|
||||
|
||||
class Article(Page):
|
||||
class Article(ChildPage):
|
||||
detail_url_name = "article-detail"
|
||||
template_prefix = "article"
|
||||
|
||||
objects = ProgramChildQuerySet.as_manager()
|
||||
|
||||
|
@ -17,6 +17,10 @@ __all__ = ("Diffusion", "DiffusionQuerySet")
|
||||
|
||||
|
||||
class DiffusionQuerySet(RerunQuerySet):
|
||||
def editor(self, user):
|
||||
episodes = Episode.objects.editor(user)
|
||||
return self.filter(episode__in=episodes)
|
||||
|
||||
def episode(self, episode=None, id=None):
|
||||
"""Diffusions for this episode."""
|
||||
return self.filter(episode=episode) if id is None else self.filter(episode__id=id)
|
||||
@ -89,6 +93,8 @@ class Diffusion(Rerun):
|
||||
- stop: the diffusion has been manually stopped
|
||||
"""
|
||||
|
||||
list_url_name = "timetable-list"
|
||||
|
||||
objects = DiffusionQuerySet.as_manager()
|
||||
|
||||
TYPE_ON_AIR = 0x00
|
||||
@ -127,8 +133,6 @@ class Diffusion(Rerun):
|
||||
# help_text = _('use this input port'),
|
||||
# )
|
||||
|
||||
item_template_name = "aircox/widgets/diffusion_item.html"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Diffusion")
|
||||
verbose_name_plural = _("Diffusions")
|
||||
@ -192,34 +196,15 @@ class Diffusion(Rerun):
|
||||
now = tz.now()
|
||||
return self.type == self.TYPE_ON_AIR and self.start <= now and self.end >= now
|
||||
|
||||
@property
|
||||
def is_today(self):
|
||||
"""True if diffusion is currently today."""
|
||||
return self.start.date() == datetime.date.today()
|
||||
|
||||
@property
|
||||
def is_live(self):
|
||||
"""True if Diffusion is live (False if there are sounds files)."""
|
||||
return self.type == self.TYPE_ON_AIR and not self.episode.sound_set.archive().count()
|
||||
|
||||
def get_playlist(self, **types):
|
||||
"""Returns sounds as a playlist (list of *local* archive file path).
|
||||
|
||||
The given arguments are passed to ``get_sounds``.
|
||||
"""
|
||||
from .sound import Sound
|
||||
|
||||
return list(
|
||||
self.get_sounds(**types).filter(path__isnull=False, type=Sound.TYPE_ARCHIVE).values_list("path", flat=True)
|
||||
)
|
||||
|
||||
def get_sounds(self, **types):
|
||||
"""Return a queryset of sounds related to this diffusion, ordered by
|
||||
type then path.
|
||||
|
||||
**types: filter on the given sound types name, as `archive=True`
|
||||
"""
|
||||
from .sound import Sound
|
||||
|
||||
sounds = (self.initial or self).sound_set.order_by("type", "path")
|
||||
_in = [getattr(Sound.Type, name) for name, value in types.items() if value]
|
||||
|
||||
return sounds.filter(type__in=_in)
|
||||
return self.type == self.TYPE_ON_AIR and not self.episode.episodesound_set.all().broadcast()
|
||||
|
||||
def is_date_in_range(self, date=None):
|
||||
"""Return true if the given date is in the diffusion's start-end
|
||||
|
@ -1,55 +1,65 @@
|
||||
import os
|
||||
|
||||
from django.conf import settings as d_settings
|
||||
from django.db import models
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from easy_thumbnails.files import get_thumbnailer
|
||||
|
||||
from aircox.conf import settings
|
||||
|
||||
from .page import Page
|
||||
from .page import ChildPage
|
||||
from .program import ProgramChildQuerySet
|
||||
from .sound import Sound
|
||||
|
||||
__all__ = ("Episode",)
|
||||
|
||||
|
||||
class Episode(Page):
|
||||
objects = ProgramChildQuerySet.as_manager()
|
||||
class EpisodeQuerySet(ProgramChildQuerySet):
|
||||
def with_podcasts(self):
|
||||
return self.filter(episodesound__sound__is_public=True).distinct()
|
||||
|
||||
|
||||
class Episode(ChildPage):
|
||||
objects = EpisodeQuerySet.as_manager()
|
||||
detail_url_name = "episode-detail"
|
||||
item_template_name = "aircox/widgets/episode_item.html"
|
||||
list_url_name = "episode-list"
|
||||
edit_url_name = "episode-edit"
|
||||
template_prefix = "episode"
|
||||
|
||||
@property
|
||||
def program(self):
|
||||
return getattr(self.parent, "program", None)
|
||||
|
||||
@cached_property
|
||||
def podcasts(self):
|
||||
"""Return serialized data about podcasts."""
|
||||
from ..serializers import PodcastSerializer
|
||||
|
||||
podcasts = [PodcastSerializer(s).data for s in self.sound_set.public().order_by("type")]
|
||||
if self.cover:
|
||||
options = {"size": (128, 128), "crop": "scale"}
|
||||
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
|
||||
else:
|
||||
cover = None
|
||||
|
||||
for index, podcast in enumerate(podcasts):
|
||||
podcasts[index]["cover"] = cover
|
||||
podcasts[index]["page_url"] = self.get_absolute_url()
|
||||
podcasts[index]["page_title"] = self.title
|
||||
return podcasts
|
||||
return self.parent_subclass
|
||||
|
||||
@program.setter
|
||||
def program(self, value):
|
||||
self.parent = value
|
||||
|
||||
@cached_property
|
||||
def podcasts(self):
|
||||
"""Return serialized data about podcasts."""
|
||||
query = self.episodesound_set.all().public().order_by("-broadcast", "position")
|
||||
return self._to_podcasts(query)
|
||||
|
||||
@cached_property
|
||||
def sounds(self):
|
||||
"""Return serialized data about all related sounds."""
|
||||
query = self.episodesound_set.all().order_by("-broadcast", "position")
|
||||
return self._to_podcasts(query)
|
||||
|
||||
def _to_podcasts(self, query):
|
||||
from ..serializers import EpisodeSoundSerializer as serializer_class
|
||||
|
||||
query = query.select_related("sound")
|
||||
podcasts = [serializer_class(s).data for s in query]
|
||||
for index, podcast in enumerate(podcasts):
|
||||
podcasts[index]["page_url"] = self.get_absolute_url()
|
||||
podcasts[index]["page_title"] = self.title
|
||||
return podcasts
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Episode")
|
||||
verbose_name_plural = _("Episodes")
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.is_published:
|
||||
return self.program.get_absolute_url()
|
||||
return super().get_absolute_url()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.parent is None:
|
||||
raise ValueError("missing parent program")
|
||||
@ -74,3 +84,54 @@ class Episode(Page):
|
||||
else title
|
||||
)
|
||||
return super().get_init_kwargs_from(page, title=title, program=page, **kwargs)
|
||||
|
||||
|
||||
class EpisodeSoundQuerySet(models.QuerySet):
|
||||
def episode(self, episode):
|
||||
if isinstance(episode, int):
|
||||
return self.filter(episode_id=episode)
|
||||
return self.filter(episode=episode)
|
||||
|
||||
def available(self):
|
||||
return self.filter(sound__is_removed=False)
|
||||
|
||||
def public(self):
|
||||
return self.filter(sound__is_public=True)
|
||||
|
||||
def broadcast(self):
|
||||
return self.available().filter(broadcast=True)
|
||||
|
||||
def playlist(self, order="position"):
|
||||
# TODO: subquery expression
|
||||
if order:
|
||||
self = self.order_by(order)
|
||||
query = self.filter(sound__file__isnull=False, sound__is_removed=False).values_list("sound__file", flat=True)
|
||||
return [os.path.join(d_settings.MEDIA_ROOT, file) for file in query]
|
||||
|
||||
|
||||
class EpisodeSound(models.Model):
|
||||
"""Element of an episode playlist."""
|
||||
|
||||
episode = models.ForeignKey(Episode, on_delete=models.CASCADE)
|
||||
sound = models.ForeignKey(Sound, on_delete=models.CASCADE)
|
||||
position = models.PositiveSmallIntegerField(
|
||||
_("order"),
|
||||
default=0,
|
||||
help_text=_("position in the playlist"),
|
||||
)
|
||||
broadcast = models.BooleanField(
|
||||
_("Broadcast"),
|
||||
blank=None,
|
||||
help_text=_("The sound is broadcasted on air"),
|
||||
)
|
||||
|
||||
objects = EpisodeSoundQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Podcast")
|
||||
verbose_name_plural = _("Podcasts")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.broadcast is None:
|
||||
self.broadcast = self.sound.broadcast
|
||||
super().save(*args, **kwargs)
|
||||
|
153
aircox/models/file.py
Normal file
153
aircox/models/file.py
Normal file
@ -0,0 +1,153 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils import timezone as tz
|
||||
|
||||
from .program import Program
|
||||
|
||||
|
||||
class FileQuerySet(models.QuerySet):
|
||||
def station(self, station=None, id=None):
|
||||
id = station.pk if id is None else id
|
||||
return self.filter(program__station__id=id)
|
||||
|
||||
def available(self):
|
||||
return self.exclude(is_removed=False)
|
||||
|
||||
def public(self):
|
||||
"""Return sounds available as podcasts."""
|
||||
return self.filter(is_public=True)
|
||||
|
||||
def path(self, paths):
|
||||
if isinstance(paths, str):
|
||||
return self.filter(file=paths.replace(settings.MEDIA_ROOT + "/", ""))
|
||||
return self.filter(file__in=(p.replace(settings.MEDIA_ROOT + "/", "") for p in paths))
|
||||
|
||||
def search(self, query):
|
||||
return self.filter(Q(name__icontains=query) | Q(file__icontains=query) | Q(program__title__icontains=query))
|
||||
|
||||
|
||||
class File(models.Model):
|
||||
def _upload_to(self, filename):
|
||||
dir = self.program and self.program.path or self.default_upload_path
|
||||
subdir = self.get_upload_dir()
|
||||
if subdir:
|
||||
return os.path.join(dir, subdir, filename)
|
||||
return os.path.join(dir, filename)
|
||||
|
||||
program = models.ForeignKey(
|
||||
Program,
|
||||
models.SET_NULL,
|
||||
verbose_name=_("Program"),
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
file = models.FileField(
|
||||
_("file"),
|
||||
upload_to=_upload_to,
|
||||
max_length=256,
|
||||
db_index=True,
|
||||
)
|
||||
name = models.CharField(
|
||||
_("name"),
|
||||
max_length=64,
|
||||
db_index=True,
|
||||
)
|
||||
description = models.TextField(
|
||||
_("description"),
|
||||
max_length=256,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
mtime = models.DateTimeField(
|
||||
_("modification time"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("last modification date and time"),
|
||||
)
|
||||
is_public = models.BooleanField(
|
||||
_("public"),
|
||||
help_text=_("file is publicly accessible"),
|
||||
default=False,
|
||||
)
|
||||
is_removed = models.BooleanField(
|
||||
_("removed"),
|
||||
help_text=_("file has been removed from server"),
|
||||
default=False,
|
||||
db_index=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
objects = FileQuerySet.as_manager()
|
||||
|
||||
default_upload_path = Path(settings.MEDIA_ROOT)
|
||||
"""Default upload directory when no program is provided."""
|
||||
upload_dir = "uploads"
|
||||
"""Upload sub-directory."""
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.file and self.file.url
|
||||
|
||||
def get_upload_dir(self):
|
||||
return self.upload_dir
|
||||
|
||||
def get_mtime(self):
|
||||
"""Get the last modification date from file."""
|
||||
mtime = os.stat(self.file.path).st_mtime
|
||||
mtime = tz.datetime.fromtimestamp(mtime)
|
||||
mtime = mtime.replace(microsecond=0)
|
||||
return tz.make_aware(mtime, tz.get_current_timezone())
|
||||
|
||||
def file_updated(self):
|
||||
"""Return True when file has been updated on filesystem."""
|
||||
exists = self.file_exists()
|
||||
if self.is_removed != (not exists):
|
||||
return True
|
||||
return exists and self.mtime != self.get_mtime()
|
||||
|
||||
def file_exists(self):
|
||||
"""Return true if the file still exists."""
|
||||
return os.path.exists(self.file.path)
|
||||
|
||||
def sync_fs(self, on_update=False):
|
||||
"""Sync model to file on the filesystem.
|
||||
|
||||
:param bool on_update: only check if `file_updated`.
|
||||
:return True wether a change happened.
|
||||
"""
|
||||
if on_update and not self.file_updated():
|
||||
return
|
||||
|
||||
# check on name/remove/modification time
|
||||
name = self.name
|
||||
if not self.name and self.file and self.file.name:
|
||||
name = os.path.basename(self.file.name)
|
||||
name = os.path.splitext(name)[0]
|
||||
name = name.replace("_", " ").strip()
|
||||
|
||||
is_removed = not self.file_exists()
|
||||
mtime = (not is_removed and self.get_mtime()) or None
|
||||
|
||||
changed = is_removed != self.is_removed or mtime != self.mtime or name != self.name
|
||||
self.name, self.is_removed, self.mtime = name, is_removed, mtime
|
||||
|
||||
# read metadata
|
||||
if changed and not self.is_removed:
|
||||
metadata = self.read_metadata()
|
||||
metadata and self.__dict__.update(metadata)
|
||||
return changed
|
||||
|
||||
def read_metadata(self):
|
||||
return {}
|
||||
|
||||
def save(self, sync=True, *args, **kwargs):
|
||||
if sync and self.file_exists():
|
||||
self.sync_fs(on_update=True)
|
||||
super().save(*args, **kwargs)
|
@ -1,5 +1,6 @@
|
||||
import datetime
|
||||
import logging
|
||||
import operator
|
||||
from collections import deque
|
||||
|
||||
from django.db import models
|
||||
@ -7,8 +8,10 @@ from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .diffusion import Diffusion
|
||||
from .sound import Sound, Track
|
||||
from .sound import Sound
|
||||
from .station import Station
|
||||
from .track import Track
|
||||
from .page import Renderable
|
||||
|
||||
logger = logging.getLogger("aircox")
|
||||
|
||||
@ -30,6 +33,9 @@ class LogQuerySet(models.QuerySet):
|
||||
def after(self, date):
|
||||
return self.filter(date__gte=date) if isinstance(date, tz.datetime) else self.filter(date__date__gte=date)
|
||||
|
||||
def before(self, date):
|
||||
return self.filter(date__lte=date) if isinstance(date, tz.datetime) else self.filter(date__date__lte=date)
|
||||
|
||||
def on_air(self):
|
||||
return self.filter(type=Log.TYPE_ON_AIR)
|
||||
|
||||
@ -46,13 +52,15 @@ class LogQuerySet(models.QuerySet):
|
||||
return self.filter(track__isnull=not with_it)
|
||||
|
||||
|
||||
class Log(models.Model):
|
||||
class Log(Renderable, models.Model):
|
||||
"""Log sounds and diffusions that are played on the station.
|
||||
|
||||
This only remember what has been played on the outputs, not on each
|
||||
source; Source designate here which source is responsible of that.
|
||||
"""
|
||||
|
||||
template_prefix = "log"
|
||||
|
||||
TYPE_STOP = 0x00
|
||||
"""Source has been stopped, e.g. manually."""
|
||||
# Rule: \/ diffusion != null \/ sound != null
|
||||
@ -90,7 +98,7 @@ class Log(models.Model):
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("source"),
|
||||
help_text=_("identifier of the source related to this log"),
|
||||
help_text=_("Identifier of the log's source."),
|
||||
)
|
||||
comment = models.CharField(
|
||||
max_length=512,
|
||||
@ -160,21 +168,22 @@ class Log(models.Model):
|
||||
object_list += [cls(obj) for obj in items]
|
||||
|
||||
@classmethod
|
||||
def merge_diffusions(cls, logs, diffs, count=None):
|
||||
def merge_diffusions(cls, logs, diffs, count=None, diff_count=None, group_logs=False):
|
||||
"""Merge logs and diffusions together.
|
||||
|
||||
`logs` can either be a queryset or a list ordered by `Log.date`.
|
||||
"""
|
||||
# TODO: limit count
|
||||
# FIXME: log may be iterable (in stats view)
|
||||
if isinstance(logs, models.QuerySet):
|
||||
logs = list(logs.order_by("-date"))
|
||||
diffs = deque(diffs.on_air().before().order_by("-start"))
|
||||
diffs = diffs.on_air().order_by("-start")
|
||||
if diff_count:
|
||||
diffs = diffs[:diff_count]
|
||||
diffs = deque(diffs)
|
||||
object_list = []
|
||||
|
||||
while True:
|
||||
if not len(diffs):
|
||||
object_list += logs
|
||||
cls._append_logs(object_list, logs, len(logs), group=group_logs)
|
||||
break
|
||||
|
||||
if not len(logs):
|
||||
@ -184,13 +193,8 @@ class Log(models.Model):
|
||||
diff = diffs.popleft()
|
||||
|
||||
# - takes all logs after diff start
|
||||
index = next(
|
||||
(i for i, v in enumerate(logs) if v.date <= diff.end),
|
||||
len(logs),
|
||||
)
|
||||
if index is not None and index > 0:
|
||||
object_list += logs[:index]
|
||||
logs = logs[index:]
|
||||
index = cls._next_index(logs, diff.end, len(logs), pred=operator.le)
|
||||
cls._append_logs(object_list, logs, index, group=group_logs)
|
||||
|
||||
if len(logs):
|
||||
# FIXME
|
||||
@ -199,10 +203,7 @@ class Log(models.Model):
|
||||
# object_list.append(logs[0])
|
||||
|
||||
# - skips logs while diff is running
|
||||
index = next(
|
||||
(i for i, v in enumerate(logs) if v.date < diff.start),
|
||||
len(logs),
|
||||
)
|
||||
index = cls._next_index(logs, diff.start, len(logs))
|
||||
if index is not None and index > 0:
|
||||
logs = logs[index:]
|
||||
|
||||
@ -211,6 +212,40 @@ class Log(models.Model):
|
||||
|
||||
return object_list if count is None else object_list[:count]
|
||||
|
||||
@classmethod
|
||||
def _next_index(cls, items, date, default, pred=operator.lt):
|
||||
iter = (i for i, v in enumerate(items) if pred(v.date, date))
|
||||
return next(iter, default)
|
||||
|
||||
@classmethod
|
||||
def _append_logs(cls, object_list, logs, count, group=False):
|
||||
logs = logs[:count]
|
||||
if not logs:
|
||||
return object_list
|
||||
|
||||
if group:
|
||||
grouped = cls._group_logs_by_time(logs)
|
||||
object_list.extend(grouped)
|
||||
else:
|
||||
object_list += logs
|
||||
return object_list
|
||||
|
||||
@classmethod
|
||||
def _group_logs_by_time(cls, logs):
|
||||
last_time = -1
|
||||
cum = []
|
||||
for log in logs:
|
||||
hour = log.date.time().hour
|
||||
if hour != last_time:
|
||||
if cum:
|
||||
yield cum
|
||||
cum = []
|
||||
last_time = hour
|
||||
# reverse from lowest to highest date
|
||||
cum.insert(0, log)
|
||||
if cum:
|
||||
yield cum
|
||||
|
||||
def print(self):
|
||||
r = []
|
||||
if self.diffusion:
|
||||
|
@ -16,6 +16,7 @@ from model_utils.managers import InheritanceQuerySet
|
||||
from .station import Station
|
||||
|
||||
__all__ = (
|
||||
"Renderable",
|
||||
"Category",
|
||||
"PageQuerySet",
|
||||
"Page",
|
||||
@ -25,7 +26,17 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
headline_re = re.compile(r"(<p>)?" r"(?P<headline>[^\n]{1,140}(\n|[^\.]*?\.))" r"(</p>)?")
|
||||
headline_clean_re = re.compile(r"\n(\s| )+", re.MULTILINE)
|
||||
headline_re = re.compile(r"(?P<headline>([\S+]|\s+){1,240}\S+)", re.MULTILINE)
|
||||
|
||||
|
||||
class Renderable:
|
||||
template_prefix = "page"
|
||||
template_name = "aircox/widgets/{prefix}.html"
|
||||
|
||||
def get_template_name(self, widget):
|
||||
"""Return template name for the provided widget."""
|
||||
return self.template_name.format(prefix=self.template_prefix, widget=widget)
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
@ -50,6 +61,9 @@ class BasePageQuerySet(InheritanceQuerySet):
|
||||
def trash(self):
|
||||
return self.filter(status=Page.STATUS_TRASH)
|
||||
|
||||
def by_last(self):
|
||||
return self.order_by("-pub_date")
|
||||
|
||||
def parent(self, parent=None, id=None):
|
||||
"""Return pages having this parent."""
|
||||
return self.filter(parent=parent) if id is None else self.filter(parent__id=id)
|
||||
@ -60,7 +74,7 @@ class BasePageQuerySet(InheritanceQuerySet):
|
||||
return self.filter(title__icontains=q)
|
||||
|
||||
|
||||
class BasePage(models.Model):
|
||||
class BasePage(Renderable, models.Model):
|
||||
"""Base class for publishable content."""
|
||||
|
||||
STATUS_DRAFT = 0x00
|
||||
@ -72,14 +86,6 @@ class BasePage(models.Model):
|
||||
(STATUS_TRASH, _("trash")),
|
||||
)
|
||||
|
||||
parent = models.ForeignKey(
|
||||
"self",
|
||||
models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
related_name="child_set",
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
slug = models.SlugField(_("slug"), max_length=120, blank=True, unique=True, db_index=True)
|
||||
status = models.PositiveSmallIntegerField(
|
||||
@ -102,11 +108,14 @@ class BasePage(models.Model):
|
||||
objects = BasePageQuerySet.as_manager()
|
||||
|
||||
detail_url_name = None
|
||||
item_template_name = "aircox/widgets/page_item.html"
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def cover_url(self):
|
||||
return self.cover_id and self.cover.url
|
||||
|
||||
def __str__(self):
|
||||
return "{}".format(self.title or self.pk)
|
||||
|
||||
@ -116,13 +125,12 @@ class BasePage(models.Model):
|
||||
count = Page.objects.filter(slug__startswith=self.slug).count()
|
||||
if count:
|
||||
self.slug += "-" + str(count)
|
||||
|
||||
if self.parent and not self.cover:
|
||||
self.cover = self.parent.cover
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(self.detail_url_name, kwargs={"slug": self.slug}) if self.is_published else "#"
|
||||
if self.is_published:
|
||||
return reverse(self.detail_url_name, kwargs={"slug": self.slug})
|
||||
return ""
|
||||
|
||||
@property
|
||||
def is_draft(self):
|
||||
@ -138,17 +146,35 @@ class BasePage(models.Model):
|
||||
|
||||
@property
|
||||
def display_title(self):
|
||||
if self.is_published():
|
||||
return self.title
|
||||
return self.parent.display_title()
|
||||
return self.is_published and self.title or ""
|
||||
|
||||
@cached_property
|
||||
def headline(self):
|
||||
if not self.content:
|
||||
return ""
|
||||
def display_headline(self):
|
||||
content = bleach.clean(self.content, tags=[], strip=True)
|
||||
content = headline_clean_re.sub("\n", content)
|
||||
if content.startswith("\n"):
|
||||
content = content[1:]
|
||||
headline = headline_re.search(content)
|
||||
return mark_safe(headline.groupdict()["headline"]) if headline else ""
|
||||
if not headline:
|
||||
return ""
|
||||
|
||||
headline = headline.groupdict()["headline"]
|
||||
suffix = "<b>...</b>" if len(headline) < len(content) else ""
|
||||
|
||||
headline = headline.split("\n")[:3]
|
||||
headline[-1] += suffix
|
||||
return mark_safe(" ".join(headline))
|
||||
|
||||
_url_re = re.compile(
|
||||
"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def display_content(self):
|
||||
if "<p>" in self.content:
|
||||
return self.content
|
||||
content = self._url_re.sub(r'<a href="\1" target="new">\1</a>', self.content)
|
||||
return content.replace("\n\n", "\n").replace("\n", "<br>")
|
||||
|
||||
@classmethod
|
||||
def get_init_kwargs_from(cls, page, **kwargs):
|
||||
@ -161,6 +187,7 @@ class BasePage(models.Model):
|
||||
return cls(**cls.get_init_kwargs_from(page, **kwargs))
|
||||
|
||||
|
||||
# FIXME: rename
|
||||
class PageQuerySet(BasePageQuerySet):
|
||||
def published(self):
|
||||
return self.filter(status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now())
|
||||
@ -189,18 +216,67 @@ class Page(BasePage):
|
||||
|
||||
objects = PageQuerySet.as_manager()
|
||||
|
||||
detail_url_name = ""
|
||||
list_url_name = "page-list"
|
||||
edit_url_name = ""
|
||||
|
||||
@classmethod
|
||||
def get_list_url(cls, kwargs={}):
|
||||
return reverse(cls.list_url_name, kwargs=kwargs)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Publication")
|
||||
verbose_name_plural = _("Publications")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__initial_cover = self.cover
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_published and self.pub_date is None:
|
||||
self.pub_date = tz.now()
|
||||
elif not self.is_published:
|
||||
self.pub_date = None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.parent and not self.category:
|
||||
self.category = self.parent.category
|
||||
|
||||
class ChildPage(Page):
|
||||
parent = models.ForeignKey(Page, models.CASCADE, blank=True, null=True, db_index=True, related_name="%(class)s_set")
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def display_title(self):
|
||||
if self.is_published:
|
||||
return self.title
|
||||
return self.parent and self.parent.title or ""
|
||||
|
||||
@property
|
||||
def display_headline(self):
|
||||
if not self.content or not self.is_published:
|
||||
return self.parent and self.parent.display_headline or ""
|
||||
return super().display_headline
|
||||
|
||||
@cached_property
|
||||
def parent_subclass(self):
|
||||
if self.parent_id:
|
||||
return Page.objects.get_subclass(id=self.parent_id)
|
||||
return None
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.is_published and self.parent_subclass:
|
||||
return self.parent_subclass.get_absolute_url()
|
||||
return super().get_absolute_url()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.parent:
|
||||
if self.parent == self:
|
||||
self.parent = None
|
||||
if not self.cover:
|
||||
self.cover = self.parent.cover
|
||||
if not self.category:
|
||||
self.category = self.parent.category
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@ -209,45 +285,37 @@ class StaticPage(BasePage):
|
||||
|
||||
detail_url_name = "static-page-detail"
|
||||
|
||||
ATTACH_TO_HOME = 0x00
|
||||
ATTACH_TO_DIFFUSIONS = 0x01
|
||||
ATTACH_TO_LOGS = 0x02
|
||||
ATTACH_TO_PROGRAMS = 0x03
|
||||
ATTACH_TO_EPISODES = 0x04
|
||||
ATTACH_TO_ARTICLES = 0x05
|
||||
class Target(models.TextChoices):
|
||||
NONE = "", _("None")
|
||||
HOME = "home", _("Home Page")
|
||||
TIMETABLE = "timetable-list", _("Timetable")
|
||||
PROGRAMS = "program-list", _("Programs list")
|
||||
EPISODES = "episode-list", _("Episodes list")
|
||||
ARTICLES = "article-list", _("Articles list")
|
||||
PAGES = "page-list", _("Publications list")
|
||||
PODCASTS = "podcast-list", _("Podcasts list")
|
||||
|
||||
ATTACH_TO_CHOICES = (
|
||||
(ATTACH_TO_HOME, _("Home page")),
|
||||
(ATTACH_TO_DIFFUSIONS, _("Diffusions page")),
|
||||
(ATTACH_TO_LOGS, _("Logs page")),
|
||||
(ATTACH_TO_PROGRAMS, _("Programs list")),
|
||||
(ATTACH_TO_EPISODES, _("Episodes list")),
|
||||
(ATTACH_TO_ARTICLES, _("Articles list")),
|
||||
)
|
||||
VIEWS = {
|
||||
ATTACH_TO_HOME: "home",
|
||||
ATTACH_TO_DIFFUSIONS: "diffusion-list",
|
||||
ATTACH_TO_LOGS: "log-list",
|
||||
ATTACH_TO_PROGRAMS: "program-list",
|
||||
ATTACH_TO_EPISODES: "episode-list",
|
||||
ATTACH_TO_ARTICLES: "article-list",
|
||||
}
|
||||
|
||||
attach_to = models.SmallIntegerField(
|
||||
attach_to = models.CharField(
|
||||
_("attach to"),
|
||||
choices=ATTACH_TO_CHOICES,
|
||||
choices=Target.choices,
|
||||
max_length=32,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("display this page content to related element"),
|
||||
)
|
||||
|
||||
def get_related_view(self):
|
||||
from ..views.page import attached_views
|
||||
|
||||
return self.attach_to and attached_views.get(self.attach_to) or None
|
||||
|
||||
def get_absolute_url(self):
|
||||
if self.attach_to:
|
||||
return reverse(self.VIEWS[self.attach_to])
|
||||
return reverse(self.attach_to)
|
||||
return super().get_absolute_url()
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
class Comment(Renderable, models.Model):
|
||||
page = models.ForeignKey(
|
||||
Page,
|
||||
models.CASCADE,
|
||||
@ -260,7 +328,7 @@ class Comment(models.Model):
|
||||
date = models.DateTimeField(auto_now_add=True)
|
||||
content = models.TextField(_("content"), max_length=1024)
|
||||
|
||||
item_template_name = "aircox/widgets/comment_item.html"
|
||||
template_prefix = "comment"
|
||||
|
||||
@cached_property
|
||||
def parent(self):
|
||||
@ -268,7 +336,7 @@ class Comment(models.Model):
|
||||
return Page.objects.select_subclasses().filter(id=self.page_id).first()
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.parent.get_absolute_url()
|
||||
return self.parent.get_absolute_url() + f"#{self._meta.label_lower}-{self.pk}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Comment")
|
||||
@ -281,7 +349,7 @@ class NavItem(models.Model):
|
||||
station = models.ForeignKey(Station, models.CASCADE, verbose_name=_("station"))
|
||||
menu = models.SlugField(_("menu"), max_length=24)
|
||||
order = models.PositiveSmallIntegerField(_("order"))
|
||||
text = models.CharField(_("title"), max_length=64)
|
||||
text = models.CharField(_("title"), max_length=64, blank=True, null=True)
|
||||
url = models.CharField(_("url"), max_length=256, blank=True, null=True)
|
||||
page = models.ForeignKey(
|
||||
StaticPage,
|
||||
@ -300,14 +368,21 @@ class NavItem(models.Model):
|
||||
def get_url(self):
|
||||
return self.url if self.url else self.page.get_absolute_url() if self.page else None
|
||||
|
||||
def get_label(self):
|
||||
if self.text:
|
||||
return self.text
|
||||
elif self.page:
|
||||
return self.page.title
|
||||
|
||||
def render(self, request, css_class="", active_class=""):
|
||||
url = self.get_url()
|
||||
label = self.get_label()
|
||||
if active_class and request.path.startswith(url):
|
||||
css_class += " " + active_class
|
||||
|
||||
if not url:
|
||||
return self.text
|
||||
return label
|
||||
elif not css_class:
|
||||
return format_html('<a href="{}">{}</a>', url, self.text)
|
||||
return format_html('<a href="{}">{}</a>', url, label)
|
||||
else:
|
||||
return format_html('<a href="{}" class="{}">{}</a>', url, css_class, self.text)
|
||||
return format_html('<a href="{}" class="{}">{}</a>', url, css_class, label)
|
||||
|
@ -1,11 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.conf import settings as conf
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import models
|
||||
from django.db.models import F
|
||||
from django.db.models.functions import Concat, Substr
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from aircox.conf import settings
|
||||
@ -13,13 +10,11 @@ from aircox.conf import settings
|
||||
from .page import Page, PageQuerySet
|
||||
from .station import Station
|
||||
|
||||
logger = logging.getLogger("aircox")
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ProgramQuerySet",
|
||||
"Program",
|
||||
"ProgramChildQuerySet",
|
||||
"ProgramQuerySet",
|
||||
"Stream",
|
||||
)
|
||||
|
||||
@ -32,6 +27,16 @@ class ProgramQuerySet(PageQuerySet):
|
||||
def active(self):
|
||||
return self.filter(active=True)
|
||||
|
||||
def editor(self, user):
|
||||
"""Return programs for which user is an editor.
|
||||
|
||||
Superuser is considered as editor of all groups.
|
||||
"""
|
||||
if user.is_superuser:
|
||||
return self
|
||||
groups = self.request.user.groups.all()
|
||||
return self.filter(editors_group__in=groups)
|
||||
|
||||
|
||||
class Program(Page):
|
||||
"""A Program can either be a Streamed or a Scheduled program.
|
||||
@ -58,9 +63,12 @@ class Program(Page):
|
||||
default=True,
|
||||
help_text=_("update later diffusions according to schedule changes"),
|
||||
)
|
||||
editors_group = models.ForeignKey(Group, models.CASCADE, verbose_name=_("editors"))
|
||||
|
||||
objects = ProgramQuerySet.as_manager()
|
||||
detail_url_name = "program-detail"
|
||||
list_url_name = "program-list"
|
||||
edit_url_name = "program-edit"
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
@ -80,11 +88,10 @@ class Program(Page):
|
||||
def excerpts_path(self):
|
||||
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
|
||||
|
||||
def __init__(self, *kargs, **kwargs):
|
||||
super().__init__(*kargs, **kwargs)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.slug:
|
||||
self.__initial_path = self.path
|
||||
self.__initial_cover = self.cover
|
||||
|
||||
@classmethod
|
||||
def get_from_path(cl, path):
|
||||
@ -116,27 +123,21 @@ class Program(Page):
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *kargs, **kwargs):
|
||||
from .sound import Sound
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.editors_group_id:
|
||||
from aircox import permissions
|
||||
|
||||
super().save(*kargs, **kwargs)
|
||||
saved = permissions.program.init(self)
|
||||
if saved:
|
||||
return
|
||||
|
||||
# TODO: move in signals
|
||||
path_ = getattr(self, "__initial_path", None)
|
||||
abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_)
|
||||
if path_ is not None and path_ != self.path and os.path.exists(abspath) and not os.path.exists(self.abspath):
|
||||
logger.info(
|
||||
"program #%s's dir changed to %s - update it.",
|
||||
self.id,
|
||||
self.title,
|
||||
)
|
||||
|
||||
shutil.move(abspath, self.abspath)
|
||||
Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_))))
|
||||
super().save()
|
||||
|
||||
|
||||
class ProgramChildQuerySet(PageQuerySet):
|
||||
def station(self, station=None, id=None):
|
||||
# lookup `__program` is due to parent being a page subclass (page is
|
||||
# concrete).
|
||||
return (
|
||||
self.filter(parent__program__station=station)
|
||||
if id is None
|
||||
@ -146,6 +147,10 @@ class ProgramChildQuerySet(PageQuerySet):
|
||||
def program(self, program=None, id=None):
|
||||
return self.parent(program, id)
|
||||
|
||||
def editor(self, user):
|
||||
programs = Program.objects.editor(user)
|
||||
return self.filter(parent__program__in=programs)
|
||||
|
||||
|
||||
class Stream(models.Model):
|
||||
"""When there are no program scheduled, it is possible to play sounds in
|
||||
|
@ -1,5 +1,6 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .program import Program
|
||||
@ -45,7 +46,7 @@ class Rerun(models.Model):
|
||||
models.SET_NULL,
|
||||
related_name="rerun_set",
|
||||
verbose_name=_("rerun of"),
|
||||
limit_choices_to={"initial__isnull": True},
|
||||
limit_choices_to=Q(initial__isnull=True) & Q(program=F("program")),
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
@ -74,7 +75,10 @@ class Rerun(models.Model):
|
||||
raise ValidationError({"initial": _("rerun must happen after original")})
|
||||
|
||||
def save_rerun(self):
|
||||
self.program = self.initial.program
|
||||
if not self.program_id:
|
||||
self.program = self.initial.program
|
||||
if self.program != self.initial.program:
|
||||
raise ValidationError("Program for the rerun should be the same")
|
||||
|
||||
def save_initial(self):
|
||||
pass
|
||||
|
@ -42,6 +42,7 @@ class Schedule(Rerun):
|
||||
second_and_fourth = 0b001010, _("2nd and 4th {day} of the month")
|
||||
every = 0b011111, _("{day}")
|
||||
one_on_two = 0b100000, _("one {day} on two")
|
||||
# every_weekday = 0b10000000 _("from Monday to Friday")
|
||||
|
||||
date = models.DateField(
|
||||
_("date"),
|
||||
@ -71,6 +72,10 @@ class Schedule(Rerun):
|
||||
verbose_name = _("Schedule")
|
||||
verbose_name_plural = _("Schedules")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._initial = kwargs
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return "{} - {}, {}".format(
|
||||
self.program.title,
|
||||
@ -110,16 +115,28 @@ class Schedule(Rerun):
|
||||
date = tz.datetime.combine(date, self.time)
|
||||
return date.replace(tzinfo=self.tz)
|
||||
|
||||
def dates_of_month(self, date):
|
||||
"""Return normalized diffusion dates of provided date's month."""
|
||||
if self.frequency == Schedule.Frequency.ponctual:
|
||||
def dates_of_month(self, date, frequency=None, sched_date=None):
|
||||
"""Return normalized diffusion dates of provided date's month.
|
||||
|
||||
:param Date date: date of the month to get dates from;
|
||||
:param Schedule.Frequency frequency: frequency (defaults to ``self.frequency``)
|
||||
:param Date sched_date: schedule start date (defaults to ``self.date``)
|
||||
:return list of diffusion dates
|
||||
"""
|
||||
if frequency is None:
|
||||
frequency = self.frequency
|
||||
|
||||
if sched_date is None:
|
||||
sched_date = self.date
|
||||
|
||||
if frequency == Schedule.Frequency.ponctual:
|
||||
return []
|
||||
|
||||
sched_wday, freq = self.date.weekday(), self.frequency
|
||||
sched_wday = sched_date.weekday()
|
||||
date = date.replace(day=1)
|
||||
|
||||
# last of the month
|
||||
if freq == Schedule.Frequency.last:
|
||||
if frequency == Schedule.Frequency.last:
|
||||
date = date.replace(day=calendar.monthrange(date.year, date.month)[1])
|
||||
date_wday = date.weekday()
|
||||
|
||||
@ -134,33 +151,42 @@ class Schedule(Rerun):
|
||||
date_wday, month = date.weekday(), date.month
|
||||
date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) - date_wday + sched_wday)
|
||||
|
||||
if freq == Schedule.Frequency.one_on_two:
|
||||
if frequency == Schedule.Frequency.one_on_two:
|
||||
# - adjust date with modulo 14 (= 2 weeks in days)
|
||||
# - there are max 3 "weeks on two" per month
|
||||
if (date - self.date).days % 14:
|
||||
if (date - sched_date).days % 14:
|
||||
date += tz.timedelta(days=7)
|
||||
dates = (date + tz.timedelta(days=14 * i) for i in range(0, 3))
|
||||
else:
|
||||
dates = (date + tz.timedelta(days=7 * week) for week in range(0, 5) if freq & (0b1 << week))
|
||||
dates = (date + tz.timedelta(days=7 * week) for week in range(0, 5) if frequency & (0b1 << week))
|
||||
|
||||
return [self.normalize(date) for date in dates if date.month == month]
|
||||
|
||||
def diffusions_of_month(self, date):
|
||||
def diffusions_of_month(self, date, frequency=None, sched_date=None):
|
||||
"""Get episodes and diffusions for month of provided date, including
|
||||
reruns.
|
||||
|
||||
:param Date date: date of the month to get diffusions from;
|
||||
:param Schedule.Frequency frequency: frequency (defaults to ``self.frequency``)
|
||||
:param Date sched_date: schedule start date (defaults to ``self.date``)
|
||||
:returns: tuple([Episode], [Diffusion])
|
||||
"""
|
||||
from .diffusion import Diffusion
|
||||
from .episode import Episode
|
||||
|
||||
if self.initial is not None or self.frequency == Schedule.Frequency.ponctual:
|
||||
if frequency is None:
|
||||
frequency = self.frequency
|
||||
|
||||
if sched_date is None:
|
||||
sched_date = self.date
|
||||
|
||||
if self.initial is not None or frequency == Schedule.Frequency.ponctual:
|
||||
return [], []
|
||||
|
||||
# dates for self and reruns as (date, initial)
|
||||
reruns = [(rerun, rerun.date - self.date) for rerun in self.rerun_set.all()]
|
||||
reruns = [(rerun, rerun.date - sched_date) for rerun in self.rerun_set.all()]
|
||||
|
||||
dates = {date: None for date in self.dates_of_month(date)}
|
||||
dates = {date: None for date in self.dates_of_month(date, frequency, sched_date)}
|
||||
dates.update(
|
||||
(rerun.normalize(date.date() + delta), date) for date in list(dates.keys()) for rerun, delta in reruns
|
||||
)
|
||||
|
@ -1,16 +1,27 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from django.conf import settings as conf
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.db import transaction
|
||||
from django.db.models import signals
|
||||
from django.db.models import signals, F
|
||||
from django.db.models.functions import Concat, Substr
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone as tz
|
||||
|
||||
from aircox import utils
|
||||
from aircox.conf import settings
|
||||
from .article import Article
|
||||
from .diffusion import Diffusion
|
||||
from .episode import Episode
|
||||
from .page import Page
|
||||
from .program import Program
|
||||
from .schedule import Schedule
|
||||
from .sound import Sound
|
||||
|
||||
|
||||
logger = logging.getLogger("aircox")
|
||||
|
||||
|
||||
# Add a default group to a user when it is created. It also assigns a list
|
||||
@ -39,27 +50,43 @@ def user_default_groups(sender, instance, created, *args, **kwargs):
|
||||
instance.groups.add(group)
|
||||
|
||||
|
||||
# ---- page
|
||||
@receiver(signals.post_save, sender=Page)
|
||||
def page_post_save(sender, instance, created, *args, **kwargs):
|
||||
if not created and instance.cover:
|
||||
Page.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover)
|
||||
def page_post_save__child_page_defaults(sender, instance, created, *args, **kwargs):
|
||||
initial_cover = getattr(instance, "__initial_cover", None)
|
||||
if initial_cover is None and instance.cover is not None:
|
||||
Episode.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover)
|
||||
Article.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover)
|
||||
|
||||
|
||||
# ---- program
|
||||
@receiver(signals.post_save, sender=Program)
|
||||
def program_post_save(sender, instance, created, *args, **kwargs):
|
||||
"""Clean-up later diffusions when a program becomes inactive."""
|
||||
def program_post_save__clean_later_episodes(sender, instance, created, *args, **kwargs):
|
||||
if not instance.active:
|
||||
Diffusion.objects.program(instance).after(tz.now()).delete()
|
||||
Episode.objects.parent(instance).filter(diffusion__isnull=True).delete()
|
||||
|
||||
cover = getattr(instance, "__initial_cover", None)
|
||||
if cover is None and instance.cover is not None:
|
||||
Episode.objects.parent(instance).filter(cover__isnull=True).update(cover=instance.cover)
|
||||
|
||||
@receiver(signals.post_save, sender=Program)
|
||||
def program_post_save__mv_sounds(sender, instance, created, *args, **kwargs):
|
||||
path_ = getattr(instance, "__initial_path", None)
|
||||
if path_ in (None, instance.path):
|
||||
return
|
||||
|
||||
abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_)
|
||||
if os.path.exists(abspath) and not os.path.exists(instance.abspath):
|
||||
logger.info(
|
||||
f"program #{instance.pk}'s dir changed to {instance.title} - update it.", instance.id, instance.title
|
||||
)
|
||||
|
||||
shutil.move(abspath, instance.abspath)
|
||||
Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_))))
|
||||
|
||||
|
||||
# ---- schedule
|
||||
@receiver(signals.pre_save, sender=Schedule)
|
||||
def schedule_pre_save(sender, instance, *args, **kwargs):
|
||||
if getattr(instance, "pk") is not None:
|
||||
if getattr(instance, "pk") is not None and "raw" not in kwargs:
|
||||
instance._initial = Schedule.objects.get(pk=instance.pk)
|
||||
|
||||
|
||||
@ -88,9 +115,23 @@ def schedule_post_save(sender, instance, created, *args, **kwargs):
|
||||
def schedule_pre_delete(sender, instance, *args, **kwargs):
|
||||
"""Delete later corresponding diffusion to a changed schedule."""
|
||||
Diffusion.objects.filter(schedule=instance).after(tz.now()).delete()
|
||||
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete()
|
||||
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
|
||||
|
||||
|
||||
# ---- diffusion
|
||||
@receiver(signals.post_delete, sender=Diffusion)
|
||||
def diffusion_post_delete(sender, instance, *args, **kwargs):
|
||||
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete()
|
||||
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
|
||||
|
||||
|
||||
# ---- files
|
||||
@receiver(signals.post_delete, sender=Sound)
|
||||
def delete_file(sender, instance, *args, **kwargs):
|
||||
"""Deletes file on `post_delete`"""
|
||||
if not instance.file:
|
||||
return
|
||||
|
||||
path = instance.file.path
|
||||
qs = sender.objects.filter(file=path)
|
||||
if not qs.exists() and os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
@ -1,304 +1,195 @@
|
||||
import logging
|
||||
from datetime import date
|
||||
import os
|
||||
import re
|
||||
|
||||
from django.conf import settings as conf
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone as tz
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from aircox import utils
|
||||
from aircox.conf import settings
|
||||
|
||||
from .episode import Episode
|
||||
from .program import Program
|
||||
|
||||
logger = logging.getLogger("aircox")
|
||||
from .file import File, FileQuerySet
|
||||
|
||||
|
||||
__all__ = ("Sound", "SoundQuerySet", "Track")
|
||||
__all__ = ("Sound", "SoundQuerySet")
|
||||
|
||||
|
||||
class SoundQuerySet(models.QuerySet):
|
||||
def station(self, station=None, id=None):
|
||||
id = station.pk if id is None else id
|
||||
return self.filter(program__station__id=id)
|
||||
|
||||
def episode(self, episode=None, id=None):
|
||||
id = episode.pk if id is None else id
|
||||
return self.filter(episode__id=id)
|
||||
|
||||
def diffusion(self, diffusion=None, id=None):
|
||||
id = diffusion.pk if id is None else id
|
||||
return self.filter(episode__diffusion__id=id)
|
||||
|
||||
def available(self):
|
||||
return self.exclude(type=Sound.TYPE_REMOVED)
|
||||
|
||||
def public(self):
|
||||
"""Return sounds available as podcasts."""
|
||||
return self.filter(is_public=True)
|
||||
|
||||
class SoundQuerySet(FileQuerySet):
|
||||
def downloadable(self):
|
||||
"""Return sounds available as podcasts."""
|
||||
return self.filter(is_downloadable=True)
|
||||
|
||||
def archive(self):
|
||||
def broadcast(self):
|
||||
"""Return sounds that are archives."""
|
||||
return self.filter(type=Sound.TYPE_ARCHIVE)
|
||||
return self.filter(broadcast=True, is_removed=False)
|
||||
|
||||
def path(self, paths):
|
||||
if isinstance(paths, str):
|
||||
return self.filter(file=paths.replace(conf.MEDIA_ROOT + "/", ""))
|
||||
return self.filter(file__in=(p.replace(conf.MEDIA_ROOT + "/", "") for p in paths))
|
||||
|
||||
def playlist(self, archive=True, order_by=True):
|
||||
def playlist(self, order_by="file"):
|
||||
"""Return files absolute paths as a flat list (exclude sound without
|
||||
path).
|
||||
|
||||
If `order_by` is True, order by path.
|
||||
"""
|
||||
if archive:
|
||||
self = self.archive()
|
||||
path)."""
|
||||
if order_by:
|
||||
self = self.order_by("file")
|
||||
self = self.order_by(order_by)
|
||||
return [
|
||||
os.path.join(conf.MEDIA_ROOT, file)
|
||||
for file in self.filter(file__isnull=False).values_list("file", flat=True)
|
||||
]
|
||||
|
||||
def search(self, query):
|
||||
return self.filter(
|
||||
Q(name__icontains=query)
|
||||
| Q(file__icontains=query)
|
||||
| Q(program__title__icontains=query)
|
||||
| Q(episode__title__icontains=query)
|
||||
)
|
||||
|
||||
|
||||
# TODO:
|
||||
# - provide a default name based on program and episode
|
||||
class Sound(models.Model):
|
||||
"""A Sound is the representation of a sound file that can be either an
|
||||
excerpt or a complete archive of the related diffusion."""
|
||||
|
||||
TYPE_OTHER = 0x00
|
||||
TYPE_ARCHIVE = 0x01
|
||||
TYPE_EXCERPT = 0x02
|
||||
TYPE_REMOVED = 0x03
|
||||
TYPE_CHOICES = (
|
||||
(TYPE_OTHER, _("other")),
|
||||
(TYPE_ARCHIVE, _("archive")),
|
||||
(TYPE_EXCERPT, _("excerpt")),
|
||||
(TYPE_REMOVED, _("removed")),
|
||||
)
|
||||
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
program = models.ForeignKey(
|
||||
Program,
|
||||
models.CASCADE,
|
||||
blank=True, # NOT NULL
|
||||
verbose_name=_("program"),
|
||||
help_text=_("program related to it"),
|
||||
db_index=True,
|
||||
)
|
||||
episode = models.ForeignKey(
|
||||
Episode,
|
||||
models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("episode"),
|
||||
db_index=True,
|
||||
)
|
||||
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
|
||||
position = models.PositiveSmallIntegerField(
|
||||
_("order"),
|
||||
default=0,
|
||||
help_text=_("position in the playlist"),
|
||||
)
|
||||
|
||||
def _upload_to(self, filename):
|
||||
subdir = settings.SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else settings.SOUND_EXCERPTS_SUBDIR
|
||||
return os.path.join(self.program.path, subdir, filename)
|
||||
|
||||
file = models.FileField(
|
||||
_("file"),
|
||||
upload_to=_upload_to,
|
||||
max_length=256,
|
||||
db_index=True,
|
||||
unique=True,
|
||||
)
|
||||
class Sound(File):
|
||||
duration = models.TimeField(
|
||||
_("duration"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("duration of the sound"),
|
||||
)
|
||||
mtime = models.DateTimeField(
|
||||
_("modification time"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("last modification date and time"),
|
||||
)
|
||||
is_good_quality = models.BooleanField(
|
||||
_("good quality"),
|
||||
help_text=_("sound meets quality requirements"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
is_public = models.BooleanField(
|
||||
_("public"),
|
||||
help_text=_("whether it is publicly available as podcast"),
|
||||
default=False,
|
||||
)
|
||||
is_downloadable = models.BooleanField(
|
||||
_("downloadable"),
|
||||
help_text=_("whether it can be publicly downloaded by visitors (sound must be " "public)"),
|
||||
help_text=_("Sound can be downloaded by website visitors."),
|
||||
default=False,
|
||||
)
|
||||
broadcast = models.BooleanField(
|
||||
_("Broadcast"),
|
||||
default=False,
|
||||
help_text=_("The sound is broadcasted on air"),
|
||||
)
|
||||
|
||||
objects = SoundQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Sound")
|
||||
verbose_name_plural = _("Sounds")
|
||||
verbose_name = _("Sound file")
|
||||
verbose_name_plural = _("Sound files")
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.file and self.file.url
|
||||
_path_re = re.compile(
|
||||
"^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
|
||||
"(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
|
||||
"(_(?P<n>[0-9]+))?"
|
||||
"_?[ -]*(?P<name>.*)$"
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return "/".join(self.file.path.split("/")[-3:])
|
||||
@classmethod
|
||||
def read_path(cls, path):
|
||||
"""Parse path name returning dictionary of extracted info. It can
|
||||
contain:
|
||||
|
||||
def save(self, check=True, *args, **kwargs):
|
||||
if self.episode is not None and self.program is None:
|
||||
self.program = self.episode.program
|
||||
if check:
|
||||
self.check_on_file()
|
||||
if not self.is_public:
|
||||
self.is_downloadable = False
|
||||
self.__check_name()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# TODO: rename get_file_mtime(self)
|
||||
def get_mtime(self):
|
||||
"""Get the last modification date from file."""
|
||||
mtime = os.stat(self.file.path).st_mtime
|
||||
mtime = tz.datetime.fromtimestamp(mtime)
|
||||
mtime = mtime.replace(microsecond=0)
|
||||
return tz.make_aware(mtime, tz.get_current_timezone())
|
||||
|
||||
def file_exists(self):
|
||||
"""Return true if the file still exists."""
|
||||
|
||||
return os.path.exists(self.file.path)
|
||||
|
||||
# TODO: rename to sync_fs()
|
||||
def check_on_file(self):
|
||||
"""Check sound file info again'st self, and update informations if
|
||||
needed (do not save).
|
||||
|
||||
Return True if there was changes.
|
||||
- `year`, `month`, `day`: diffusion date
|
||||
- `hour`, `minute`: diffusion time
|
||||
- `n`: sound arbitrary number (used for sound ordering)
|
||||
- `name`: cleaned name extracted or file name (without extension)
|
||||
"""
|
||||
if not self.file_exists():
|
||||
if self.type == self.TYPE_REMOVED:
|
||||
return
|
||||
logger.debug("sound %s: has been removed", self.file.name)
|
||||
self.type = self.TYPE_REMOVED
|
||||
return True
|
||||
basename = os.path.basename(path)
|
||||
basename = os.path.splitext(basename)[0]
|
||||
reg_match = cls._path_re.search(basename)
|
||||
if reg_match:
|
||||
info = reg_match.groupdict()
|
||||
for k in ("year", "month", "day", "hour", "minute", "n"):
|
||||
if info.get(k) is not None:
|
||||
info[k] = int(info[k])
|
||||
|
||||
# not anymore removed
|
||||
changed = False
|
||||
name = info.get("name")
|
||||
info["name"] = name and cls._as_name(name) or basename
|
||||
else:
|
||||
info = {"name": basename}
|
||||
return info
|
||||
|
||||
if self.type == self.TYPE_REMOVED and self.program:
|
||||
changed = True
|
||||
self.type = (
|
||||
self.TYPE_ARCHIVE if self.file.name.startswith(self.program.archives_path) else self.TYPE_EXCERPT
|
||||
@classmethod
|
||||
def _as_name(cls, name):
|
||||
name = name.replace("_", " ")
|
||||
return " ".join(r.capitalize() for r in name.split(" "))
|
||||
|
||||
def find_episode(self, path_info=None):
|
||||
"""Base on self's file name, match date to an initial diffusion and
|
||||
return corresponding episode or ``None``."""
|
||||
pi = path_info or self.read_path(self.file.path)
|
||||
if "year" not in pi:
|
||||
return None
|
||||
|
||||
year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
|
||||
if pi.get("hour") is not None:
|
||||
at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
|
||||
at = tz.make_aware(at)
|
||||
else:
|
||||
at = date(year, month, day)
|
||||
|
||||
diffusion = self.program.diffusion_set.at(at).first()
|
||||
return diffusion and diffusion.episode or None
|
||||
|
||||
def find_playlist(self, meta=None):
|
||||
"""Find a playlist file corresponding to the sound path, such as:
|
||||
my_sound.ogg => my_sound.csv.
|
||||
|
||||
Use provided sound's metadata if any and no csv file has been
|
||||
found.
|
||||
"""
|
||||
from aircox.controllers.playlist_import import PlaylistImport
|
||||
from .track import Track
|
||||
|
||||
if self.track_set.count() > 1:
|
||||
return
|
||||
|
||||
# import playlist
|
||||
path_noext, ext = os.path.splitext(self.file.path)
|
||||
path = path_noext + ".csv"
|
||||
if os.path.exists(path):
|
||||
PlaylistImport(path, sound=self).run()
|
||||
# use metadata
|
||||
elif meta and meta.tags:
|
||||
title, artist, album, year = tuple(
|
||||
t and ", ".join(t) for t in (meta.tags.get(k) for k in ("title", "artist", "album", "year"))
|
||||
)
|
||||
|
||||
# check mtime -> reset quality if changed (assume file changed)
|
||||
mtime = self.get_mtime()
|
||||
|
||||
if self.mtime != mtime:
|
||||
self.mtime = mtime
|
||||
self.is_good_quality = None
|
||||
logger.debug(
|
||||
"sound %s: m_time has changed. Reset quality info",
|
||||
self.file.name,
|
||||
title = title or path_noext
|
||||
info = "{} ({})".format(album, year) if album and year else album or year or ""
|
||||
track = Track(
|
||||
sound=self,
|
||||
position=int(meta.tags.get("tracknumber", 0)),
|
||||
title=title,
|
||||
artist=artist or _("unknown"),
|
||||
info=info,
|
||||
)
|
||||
return True
|
||||
track.save()
|
||||
|
||||
def get_upload_dir(self):
|
||||
if self.broadcast:
|
||||
return settings.SOUND_BROADCASTS_SUBDIR
|
||||
return settings.SOUND_EXCERPTS_SUBDIR
|
||||
|
||||
meta = None
|
||||
"""Provided by read_metadata: Mutagen's metadata."""
|
||||
|
||||
def sync_fs(self, *args, find_playlist=False, **kwargs):
|
||||
changed = super().sync_fs(*args, **kwargs)
|
||||
if changed and not self.is_removed:
|
||||
if not self.program:
|
||||
self.program = Program.get_from_path(self.file.path)
|
||||
changed = True
|
||||
if find_playlist and self.meta:
|
||||
not self.pk and self.save(sync=False)
|
||||
self.find_playlist(self.meta)
|
||||
return changed
|
||||
|
||||
def __check_name(self):
|
||||
if not self.name and self.file and self.file.name:
|
||||
# FIXME: later, remove date?
|
||||
name = os.path.basename(self.file.name)
|
||||
name = os.path.splitext(name)[0]
|
||||
self.name = name.replace("_", " ").strip()
|
||||
def read_metadata(self):
|
||||
import mutagen
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.__check_name()
|
||||
meta = mutagen.File(self.file.path)
|
||||
|
||||
metadata = {"duration": utils.seconds_to_time(meta.info.length), "meta": meta}
|
||||
|
||||
class Track(models.Model):
|
||||
"""Track of a playlist of an object.
|
||||
|
||||
The position can either be expressed as the position in the playlist
|
||||
or as the moment in seconds it started.
|
||||
"""
|
||||
|
||||
episode = models.ForeignKey(
|
||||
Episode,
|
||||
models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("episode"),
|
||||
)
|
||||
sound = models.ForeignKey(
|
||||
Sound,
|
||||
models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("sound"),
|
||||
)
|
||||
position = models.PositiveSmallIntegerField(
|
||||
_("order"),
|
||||
default=0,
|
||||
help_text=_("position in the playlist"),
|
||||
)
|
||||
timestamp = models.PositiveSmallIntegerField(
|
||||
_("timestamp"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("position (in seconds)"),
|
||||
)
|
||||
title = models.CharField(_("title"), max_length=128)
|
||||
artist = models.CharField(_("artist"), max_length=128)
|
||||
album = models.CharField(_("album"), max_length=128, null=True, blank=True)
|
||||
tags = TaggableManager(verbose_name=_("tags"), blank=True)
|
||||
year = models.IntegerField(_("year"), blank=True, null=True)
|
||||
# FIXME: remove?
|
||||
info = models.CharField(
|
||||
_("information"),
|
||||
max_length=128,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_(
|
||||
"additional informations about this track, such as " "the version, if is it a remix, features, etc."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Track")
|
||||
verbose_name_plural = _("Tracks")
|
||||
ordering = ("position",)
|
||||
path_info = self.read_path(self.file.path)
|
||||
if name := path_info.get("name"):
|
||||
metadata["name"] = name
|
||||
return metadata
|
||||
|
||||
def __str__(self):
|
||||
return "{self.artist} -- {self.title} -- {self.position}".format(self=self)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if (self.sound is None and self.episode is None) or (self.sound is not None and self.episode is not None):
|
||||
raise ValueError("sound XOR episode is required")
|
||||
super().save(*args, **kwargs)
|
||||
infos = ""
|
||||
if self.is_removed:
|
||||
infos += _("removed")
|
||||
if infos:
|
||||
return f"{self.file.name} [{infos}]"
|
||||
return f"{self.file.name}"
|
||||
|
@ -1,11 +1,8 @@
|
||||
import os
|
||||
|
||||
from django.db import models
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from filer.fields.image import FilerImageField
|
||||
|
||||
from aircox.conf import settings
|
||||
|
||||
__all__ = ("Station", "StationQuerySet", "Port")
|
||||
|
||||
@ -32,13 +29,6 @@ class Station(models.Model):
|
||||
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
slug = models.SlugField(_("slug"), max_length=64, unique=True)
|
||||
# FIXME: remove - should be decided only by Streamer controller + settings
|
||||
path = models.CharField(
|
||||
_("path"),
|
||||
help_text=_("path to the working directory"),
|
||||
max_length=256,
|
||||
blank=True,
|
||||
)
|
||||
default = models.BooleanField(
|
||||
_("default station"),
|
||||
default=False,
|
||||
@ -67,7 +57,7 @@ class Station(models.Model):
|
||||
max_length=2048,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Audio streams urls used by station's player. One url " "a line."),
|
||||
help_text=_("Audio streams urls used by station's player. One url a line."),
|
||||
)
|
||||
default_cover = FilerImageField(
|
||||
on_delete=models.SET_NULL,
|
||||
@ -76,6 +66,14 @@ class Station(models.Model):
|
||||
blank=True,
|
||||
related_name="+",
|
||||
)
|
||||
music_stream_title = models.CharField(
|
||||
_("Music stream's title"),
|
||||
max_length=64,
|
||||
default=_("Music stream"),
|
||||
)
|
||||
legal_label = models.CharField(
|
||||
_("Legal label"), max_length=64, blank=True, default="", help_text=_("Displayed at the bottom of pages.")
|
||||
)
|
||||
|
||||
objects = StationQuerySet.as_manager()
|
||||
|
||||
@ -88,12 +86,6 @@ class Station(models.Model):
|
||||
return self.name
|
||||
|
||||
def save(self, make_sources=True, *args, **kwargs):
|
||||
if not self.path:
|
||||
self.path = os.path.join(
|
||||
settings.CONTROLLERS_WORKING_DIR,
|
||||
self.slug.replace("-", "_"),
|
||||
)
|
||||
|
||||
if self.default:
|
||||
qs = Station.objects.filter(default=True)
|
||||
if self.pk is not None:
|
||||
|
72
aircox/models/track.py
Normal file
72
aircox/models/track.py
Normal file
@ -0,0 +1,72 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
|
||||
from .episode import Episode
|
||||
from .sound import Sound
|
||||
|
||||
|
||||
__all__ = ("Track",)
|
||||
|
||||
|
||||
class Track(models.Model):
|
||||
"""Track of a playlist of an object.
|
||||
|
||||
The position can either be expressed as the position in the playlist
|
||||
or as the moment in seconds it started.
|
||||
"""
|
||||
|
||||
episode = models.ForeignKey(
|
||||
Episode,
|
||||
models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("episode"),
|
||||
)
|
||||
sound = models.ForeignKey(
|
||||
Sound,
|
||||
models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("sound"),
|
||||
)
|
||||
position = models.PositiveSmallIntegerField(
|
||||
_("order"),
|
||||
default=0,
|
||||
help_text=_("position in the playlist"),
|
||||
)
|
||||
timestamp = models.PositiveSmallIntegerField(
|
||||
_("timestamp"),
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_("position (in seconds)"),
|
||||
)
|
||||
title = models.CharField(_("title"), max_length=128)
|
||||
artist = models.CharField(_("artist"), max_length=128)
|
||||
album = models.CharField(_("album"), max_length=128, null=True, blank=True)
|
||||
tags = TaggableManager(verbose_name=_("tags"), blank=True)
|
||||
year = models.IntegerField(_("year"), blank=True, null=True)
|
||||
# FIXME: remove?
|
||||
info = models.CharField(
|
||||
_("information"),
|
||||
max_length=128,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_(
|
||||
"additional informations about this track, such as " "the version, if is it a remix, features, etc."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Track")
|
||||
verbose_name_plural = _("Tracks")
|
||||
ordering = ("position",)
|
||||
|
||||
def __str__(self):
|
||||
return "{self.artist} -- {self.title} -- {self.position}".format(self=self)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if (self.sound is None and self.episode is None) or (self.sound is not None and self.episode is not None):
|
||||
raise ValueError("sound XOR episode is required")
|
||||
super().save(*args, **kwargs)
|
@ -14,5 +14,5 @@ class UserSettings(models.Model):
|
||||
verbose_name=_("User"),
|
||||
related_name="aircox_settings",
|
||||
)
|
||||
playlist_editor_columns = models.JSONField(_("Playlist Editor Columns"))
|
||||
playlist_editor_sep = models.CharField(_("Playlist Editor Separator"), max_length=16)
|
||||
tracklist_editor_columns = models.JSONField(_("Playlist Editor Columns"))
|
||||
tracklist_editor_sep = models.CharField(_("Playlist Editor Separator"), max_length=16)
|
||||
|
Reference in New Issue
Block a user