#132 | #121: backoffice / dev-1.0-121 (#131)

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:
2024-04-28 22:02:09 +02:00
committed by Thomas Kairos
parent 1e17a1334a
commit 55123c386d
348 changed files with 124397 additions and 17879 deletions

View File

@ -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",

View File

@ -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()

View File

@ -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

View File

@ -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
View 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)

View File

@ -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:

View File

@ -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|&nbsp;)+", 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)

View File

@ -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

View File

@ -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

View File

@ -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
)

View File

@ -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)

View File

@ -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}"

View File

@ -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
View 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)

View File

@ -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)