add missing files; improve dashboard; rewrite urls

This commit is contained in:
bkfox 2024-04-11 00:28:43 +02:00
parent a24318bc84
commit 1bd4e03f02
40 changed files with 584 additions and 357 deletions

View File

@ -3,7 +3,7 @@ from django.contrib import admin
from django.forms import ModelForm from django.forms import ModelForm
from aircox.models import Episode, EpisodeSound from aircox.models import Episode, EpisodeSound
from .page import PageAdmin from .page import ChildPageAdmin
from .sound import TrackInline from .sound import TrackInline
from .diffusion import DiffusionInline from .diffusion import DiffusionInline
@ -15,14 +15,14 @@ class EpisodeAdminForm(ModelForm):
@admin.register(Episode) @admin.register(Episode)
class EpisodeAdmin(SortableAdminBase, PageAdmin): class EpisodeAdmin(SortableAdminBase, ChildPageAdmin):
form = EpisodeAdminForm form = EpisodeAdminForm
list_display = PageAdmin.list_display list_display = ChildPageAdmin.list_display
list_filter = tuple(f for f in PageAdmin.list_filter if f != "pub_date") + ( list_filter = tuple(f for f in ChildPageAdmin.list_filter if f != "pub_date") + (
"diffusion__start", "diffusion__start",
"pub_date", "pub_date",
) )
search_fields = PageAdmin.search_fields + ("parent__title",) search_fields = ChildPageAdmin.search_fields + ("parent__title",)
# readonly_fields = ('parent',) # readonly_fields = ('parent',)
inlines = [TrackInline, DiffusionInline] inlines = [TrackInline, DiffusionInline]

View File

@ -21,7 +21,7 @@ class CategoryAdmin(admin.ModelAdmin):
class BasePageAdmin(admin.ModelAdmin): class BasePageAdmin(admin.ModelAdmin):
list_display = ("cover_thumb", "title", "status", "parent") list_display = ("cover_thumb", "title", "status")
list_display_links = ("cover_thumb", "title") list_display_links = ("cover_thumb", "title")
list_editable = ("status",) list_editable = ("status",)
list_filter = ("status",) list_filter = ("status",)
@ -42,7 +42,9 @@ class BasePageAdmin(admin.ModelAdmin):
( (
_("Publication Settings"), _("Publication Settings"),
{ {
"fields": ["status", "parent"], "fields": [
"status",
],
}, },
), ),
] ]
@ -54,23 +56,6 @@ class BasePageAdmin(admin.ModelAdmin):
return mark_safe('<img src="{}"/>'.format(obj.cover.icons["64"])) return mark_safe('<img src="{}"/>'.format(obj.cover.icons["64"]))
return "" return ""
def get_changeform_initial_data(self, request):
data = super().get_changeform_initial_data(request)
filters = QueryDict(request.GET.get("_changelist_filters", ""))
data["parent"] = filters.get("parent", None)
return data
def _get_common_context(self, query, extra_context=None):
extra_context = extra_context or {}
parent = query.get("parent", None)
extra_context["parent"] = None if parent is None else Page.objects.get_subclass(id=parent)
return extra_context
def render_change_form(self, request, context, *args, **kwargs):
if context["original"] and "parent" not in context:
context["parent"] = context["original"].parent
return super().render_change_form(request, context, *args, **kwargs)
def add_view(self, request, form_url="", extra_context=None): def add_view(self, request, form_url="", extra_context=None):
filters = QueryDict(request.GET.get("_changelist_filters", "")) filters = QueryDict(request.GET.get("_changelist_filters", ""))
extra_context = self._get_common_context(filters, extra_context) extra_context = self._get_common_context(filters, extra_context)
@ -88,12 +73,36 @@ class PageAdmin(BasePageAdmin):
list_editable = BasePageAdmin.list_editable + ("category",) list_editable = BasePageAdmin.list_editable + ("category",)
list_filter = BasePageAdmin.list_filter + ("category", "pub_date") list_filter = BasePageAdmin.list_filter + ("category", "pub_date")
search_fields = BasePageAdmin.search_fields + ("category__title",) search_fields = BasePageAdmin.search_fields + ("category__title",)
fieldsets = deepcopy(BasePageAdmin.fieldsets)
fieldsets = deepcopy(BasePageAdmin.fieldsets)
fieldsets[0][1]["fields"].insert(fieldsets[0][1]["fields"].index("slug") + 1, "category") fieldsets[0][1]["fields"].insert(fieldsets[0][1]["fields"].index("slug") + 1, "category")
fieldsets[1][1]["fields"] += ("featured", "allow_comments") fieldsets[1][1]["fields"] += ("featured", "allow_comments")
class ChildPageAdmin(PageAdmin):
list_display = PageAdmin.list_display + ("parent",)
fieldsets = deepcopy(PageAdmin.fieldsets)
fieldsets[1][1]["fields"] += ("parent",)
def get_changeform_initial_data(self, request):
data = super().get_changeform_initial_data(request)
filters = QueryDict(request.GET.get("_changelist_filters", ""))
data["parent"] = filters.get("parent", None)
return data
def _get_common_context(self, query, extra_context=None):
extra_context = extra_context or {}
parent = query.get("parent", None)
extra_context["parent"] = None if parent is None else Page.objects.get_subclass(id=parent)
return extra_context
def render_change_form(self, request, context, *args, **kwargs):
if context["original"] and "parent" not in context:
context["parent"] = context["original"].parent
return super().render_change_form(request, context, *args, **kwargs)
@admin.register(StaticPage) @admin.register(StaticPage)
class StaticPageAdmin(BasePageAdmin): class StaticPageAdmin(BasePageAdmin):
list_display = BasePageAdmin.list_display + ("attach_to",) list_display = BasePageAdmin.list_display + ("attach_to",)

View File

@ -27,6 +27,12 @@ class ImageForm(forms.Form):
class PageForm(forms.ModelForm): class PageForm(forms.ModelForm):
class Meta:
fields = ("title", "category", "status", "cover", "content")
model = models.Page
class ChildPageForm(forms.ModelForm):
class Meta: class Meta:
fields = ("title", "status", "cover", "content") fields = ("title", "status", "cover", "content")
model = models.Page model = models.Page
@ -41,7 +47,7 @@ class ProgramForm(PageForm):
class EpisodeForm(PageForm): class EpisodeForm(PageForm):
class Meta: class Meta:
model = models.Episode model = models.Episode
fields = PageForm.Meta.fields fields = ChildPageForm.Meta.fields
class SoundForm(forms.ModelForm): class SoundForm(forms.ModelForm):

View File

@ -13,7 +13,7 @@ class Migration(migrations.Migration):
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name="program", model_name="program",
name="editors", name="editors_group",
field=models.ForeignKey( field=models.ForeignKey(
blank=True, blank=True,
null=True, null=True,

View File

@ -1,18 +0,0 @@
from django.db import migrations
from aircox.models import Program
def set_group_ownership(*args):
for program in Program.objects.all():
program.set_group_ownership()
class Migration(migrations.Migration):
dependencies = [
("aircox", "0021_alter_schedule_timezone"),
]
operations = [
migrations.RunPython(set_group_ownership),
]

View File

@ -6,7 +6,8 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("aircox", "0022_set_group_ownership"), ("filer", "0017_image__transparent"),
("aircox", "0021_alter_schedule_timezone"),
] ]
operations = [ operations = [

View File

@ -0,0 +1,83 @@
# Generated by Django 5.0 on 2024-04-10 08:38
import django.db.models.deletion
from django.db import migrations, models
children_infos = {}
def get_children_infos(apps, schema_editor):
Page = apps.get_model("aircox", "page")
query = Page.objects.filter(parent__isnull=False).values("pk", "parent_id", "category_id", "parent__category_id")
children_infos.update((r["pk"], r) for r in query)
def restore_children_infos(apps, schema_editor):
Episode = apps.get_model("aircox", "Episode")
pks = set(children_infos.keys())
eps = _restore_for_objs(Episode.objects.filter(pk__in=pks))
Episode.objects.bulk_update(eps, ("parent_id", "category_id"))
print(f">> {len(eps)} episodes restored")
def _restore_for_objs(objs):
updated = []
for obj in objs:
info = children_infos.get(obj.pk)
if info:
obj.parent_id = info["parent_id"]
obj.category_id = info["category_id"] or info["parent__category_id"]
updated.append(obj)
return updated
def set_group_ownership(*args):
for program in Program.objects.all():
program.set_group_ownership()
class Migration(migrations.Migration):
dependencies = [
("aircox", "0026_alter_sound_options_remove_sound_episode_and_more"),
]
operations = [
migrations.RunPython(get_children_infos),
migrations.RemoveField(
model_name="page",
name="parent",
),
migrations.RemoveField(
model_name="staticpage",
name="parent",
),
migrations.RemoveField(
model_name="station",
name="path",
),
migrations.AddField(
model_name="article",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="aircox.page",
),
),
migrations.AddField(
model_name="episode",
name="parent",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="aircox.page",
),
),
migrations.RunPython(restore_children_infos),
]

View File

@ -1,12 +1,12 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .page import Page from .page import ChildPage
from .program import ProgramChildQuerySet from .program import ProgramChildQuerySet
__all__ = ("Article",) __all__ = ("Article",)
class Article(Page): class Article(ChildPage):
detail_url_name = "article-detail" detail_url_name = "article-detail"
template_prefix = "article" template_prefix = "article"

View File

@ -17,6 +17,10 @@ __all__ = ("Diffusion", "DiffusionQuerySet")
class DiffusionQuerySet(RerunQuerySet): 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): def episode(self, episode=None, id=None):
"""Diffusions for this episode.""" """Diffusions for this episode."""
return self.filter(episode=episode) if id is None else self.filter(episode__id=id) return self.filter(episode=episode) if id is None else self.filter(episode__id=id)
@ -200,7 +204,7 @@ class Diffusion(Rerun):
@property @property
def is_live(self): def is_live(self):
"""True if Diffusion is live (False if there are sounds files).""" """True if Diffusion is live (False if there are sounds files)."""
return self.type == self.TYPE_ON_AIR and self.episode.episodesound_set.all().broadcast().empty() return self.type == self.TYPE_ON_AIR and not self.episode.episodesound_set.all().broadcast()
def is_date_in_range(self, date=None): def is_date_in_range(self, date=None):
"""Return true if the given date is in the diffusion's start-end """Return true if the given date is in the diffusion's start-end

View File

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from aircox.conf import settings from aircox.conf import settings
from .page import Page from .page import ChildPage
from .program import ProgramChildQuerySet from .program import ProgramChildQuerySet
from .sound import Sound from .sound import Sound
@ -19,10 +19,11 @@ class EpisodeQuerySet(ProgramChildQuerySet):
return self.filter(episodesound__sound__is_public=True).distinct() return self.filter(episodesound__sound__is_public=True).distinct()
class Episode(Page): class Episode(ChildPage):
objects = EpisodeQuerySet.as_manager() objects = EpisodeQuerySet.as_manager()
detail_url_name = "episode-detail" detail_url_name = "episode-detail"
list_url_name = "episode-list" list_url_name = "episode-list"
edit_url_name = "episode-edit"
template_prefix = "episode" template_prefix = "episode"
@property @property
@ -59,11 +60,6 @@ class Episode(Page):
verbose_name = _("Episode") verbose_name = _("Episode")
verbose_name_plural = _("Episodes") 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): def save(self, *args, **kwargs):
if self.parent is None: if self.parent is None:
raise ValueError("missing parent program") raise ValueError("missing parent program")

View File

@ -86,14 +86,6 @@ class BasePage(Renderable, models.Model):
(STATUS_TRASH, _("trash")), (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) title = models.CharField(max_length=100)
slug = models.SlugField(_("slug"), max_length=120, blank=True, unique=True, db_index=True) slug = models.SlugField(_("slug"), max_length=120, blank=True, unique=True, db_index=True)
status = models.PositiveSmallIntegerField( status = models.PositiveSmallIntegerField(
@ -133,12 +125,6 @@ class BasePage(Renderable, models.Model):
count = Page.objects.filter(slug__startswith=self.slug).count() count = Page.objects.filter(slug__startswith=self.slug).count()
if count: if count:
self.slug += "-" + str(count) self.slug += "-" + str(count)
if self.parent:
if self.parent == self:
self.parent = None
if not self.cover:
self.cover = self.parent.cover
super().save(*args, **kwargs) super().save(*args, **kwargs)
def get_absolute_url(self): def get_absolute_url(self):
@ -160,14 +146,10 @@ class BasePage(Renderable, models.Model):
@property @property
def display_title(self): def display_title(self):
if self.is_published: return self.is_published and self.title or ""
return self.title
return self.parent and self.parent.title or ""
@cached_property @cached_property
def display_headline(self): def display_headline(self):
if not self.content or not self.is_published:
return self.parent and self.parent.display_headline or ""
content = bleach.clean(self.content, tags=[], strip=True) content = bleach.clean(self.content, tags=[], strip=True)
content = headline_clean_re.sub("\n", content) content = headline_clean_re.sub("\n", content)
if content.startswith("\n"): if content.startswith("\n"):
@ -205,6 +187,7 @@ class BasePage(Renderable, models.Model):
return cls(**cls.get_init_kwargs_from(page, **kwargs)) return cls(**cls.get_init_kwargs_from(page, **kwargs))
# FIXME: rename
class PageQuerySet(BasePageQuerySet): class PageQuerySet(BasePageQuerySet):
def published(self): def published(self):
return self.filter(status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now()) return self.filter(status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now())
@ -232,8 +215,48 @@ class Page(BasePage):
) )
objects = PageQuerySet.as_manager() objects = PageQuerySet.as_manager()
detail_url_name = "" detail_url_name = ""
list_url_name = "page-list" 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)
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 @cached_property
def parent_subclass(self): def parent_subclass(self):
@ -246,22 +269,14 @@ class Page(BasePage):
return self.parent_subclass.get_absolute_url() return self.parent_subclass.get_absolute_url()
return super().get_absolute_url() return super().get_absolute_url()
@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 save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.is_published and self.pub_date is None: if self.parent:
self.pub_date = tz.now() if self.parent == self:
elif not self.is_published: self.parent = None
self.pub_date = None if not self.cover:
self.cover = self.parent.cover
if self.parent and not self.category: if not self.category:
self.category = self.parent.category self.category = self.parent.category
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@ -1,13 +1,9 @@
import logging
import os import os
import shutil
from django.conf import settings as conf from django.conf import settings as conf
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db import models 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 django.utils.translation import gettext_lazy as _
from aircox.conf import settings from aircox.conf import settings
@ -15,13 +11,11 @@ from aircox.conf import settings
from .page import Page, PageQuerySet from .page import Page, PageQuerySet
from .station import Station from .station import Station
logger = logging.getLogger("aircox")
__all__ = ( __all__ = (
"ProgramQuerySet",
"Program", "Program",
"ProgramChildQuerySet", "ProgramChildQuerySet",
"ProgramQuerySet",
"Stream", "Stream",
) )
@ -34,6 +28,16 @@ class ProgramQuerySet(PageQuerySet):
def active(self): def active(self):
return self.filter(active=True) 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): class Program(Page):
"""A Program can either be a Streamed or a Scheduled program. """A Program can either be a Streamed or a Scheduled program.
@ -60,11 +64,12 @@ class Program(Page):
default=True, default=True,
help_text=_("update later diffusions according to schedule changes"), help_text=_("update later diffusions according to schedule changes"),
) )
editors = models.ForeignKey(Group, models.CASCADE, blank=True, null=True, verbose_name=_("editors")) editors_group = models.ForeignKey(Group, models.CASCADE, blank=True, null=True, verbose_name=_("editors"))
objects = ProgramQuerySet.as_manager() objects = ProgramQuerySet.as_manager()
detail_url_name = "program-detail" detail_url_name = "program-detail"
list_url_name = "program-list" list_url_name = "program-list"
edit_url_name = "program-edit"
@property @property
def path(self): def path(self):
@ -84,19 +89,10 @@ class Program(Page):
def excerpts_path(self): def excerpts_path(self):
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR) return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
@property def __init__(self, *args, **kwargs):
def editors_group_name(self): super().__init__(*args, **kwargs)
return f"{self.title} editors"
@property
def change_permission_codename(self):
return f"change_program_{self.slug}"
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
if self.slug: if self.slug:
self.__initial_path = self.path self.__initial_path = self.path
self.__initial_cover = self.cover
@classmethod @classmethod
def get_from_path(cl, path): def get_from_path(cl, path):
@ -121,18 +117,36 @@ class Program(Page):
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
return os.path.exists(path) return os.path.exists(path)
def set_group_ownership(self): def can_update(self, user):
editors, created = Group.objects.get_or_create(name=self.editors_group_name) """Return True if user can update program."""
if user.is_superuser:
return True
perm = self._perm_update_codename.format(self=self)
return user.has_perm("aircox." + perm)
# permissions and editor group format. Use of pk in codename makes it
# consistent in case program title changes.
_editor_group_name = "{self.title}: editors"
_perm_update_codename = "program_{self.pk}_update"
_perm_update_name = "{self.title}: update"
def init_editor_group(self):
if not self.editors_group:
name = self._editor_group_name.format(self=self)
self.editors_group, created = Group.objects.get_or_create(name=name)
else:
created = False
if created: if created:
self.editors = editors if not self.pk:
super().save() self.save(check_groups=False)
permission, _ = Permission.objects.get_or_create( permission, _ = Permission.objects.get_or_create(
codename=self.change_permission_codename, codename=self._perm_update_codename.format(self=self),
content_type=ContentType.objects.get_for_model(self), content_type=ContentType.objects.get_for_model(self),
defaults={"name": f"change program {self.title}"}, defaults={"name": self._perm_update_name.format(self=self)},
) )
if permission not in editors.permissions.all(): if permission not in self.editors_group.permissions.all():
editors.permissions.add(permission) self.editors_group.permissions.add(permission)
class Meta: class Meta:
verbose_name = _("Program") verbose_name = _("Program")
@ -141,29 +155,11 @@ class Program(Page):
def __str__(self): def __str__(self):
return self.title return self.title
def save(self, *kargs, **kwargs):
from .sound import Sound
super().save(*kargs, **kwargs)
# 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_))))
self.set_group_ownership()
class ProgramChildQuerySet(PageQuerySet): class ProgramChildQuerySet(PageQuerySet):
def station(self, station=None, id=None): def station(self, station=None, id=None):
# lookup `__program` is due to parent being a page subclass (page is
# concrete).
return ( return (
self.filter(parent__program__station=station) self.filter(parent__program__station=station)
if id is None if id is None
@ -173,6 +169,10 @@ class ProgramChildQuerySet(PageQuerySet):
def program(self, program=None, id=None): def program(self, program=None, id=None):
return self.parent(program, id) 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): class Stream(models.Model):
"""When there are no program scheduled, it is possible to play sounds in """When there are no program scheduled, it is possible to play sounds in

View File

@ -1,13 +1,18 @@
import logging
import os import os
import shutil
from django.conf import settings as conf
from django.contrib.auth.models import Group, Permission, User from django.contrib.auth.models import Group, Permission, User
from django.db import transaction 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.dispatch import receiver
from django.utils import timezone as tz from django.utils import timezone as tz
from aircox import utils from aircox import utils
from aircox.conf import settings from aircox.conf import settings
from .article import Article
from .diffusion import Diffusion from .diffusion import Diffusion
from .episode import Episode from .episode import Episode
from .page import Page from .page import Page
@ -16,6 +21,9 @@ from .schedule import Schedule
from .sound import Sound from .sound import Sound
logger = logging.getLogger("aircox")
# Add a default group to a user when it is created. It also assigns a list # Add a default group to a user when it is created. It also assigns a list
# of permissions to the group if it is created. # of permissions to the group if it is created.
# #
@ -42,24 +50,40 @@ def user_default_groups(sender, instance, created, *args, **kwargs):
instance.groups.add(group) instance.groups.add(group)
# ---- page
@receiver(signals.post_save, sender=Page) @receiver(signals.post_save, sender=Page)
def page_post_save(sender, instance, created, *args, **kwargs): def page_post_save__child_page_defaults(sender, instance, created, *args, **kwargs):
if not created and instance.cover and "raw" not in kwargs: initial_cover = getattr(instance, "__initial_cover", None)
Page.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover) 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) @receiver(signals.post_save, sender=Program)
def program_post_save(sender, instance, created, *args, **kwargs): def program_post_save__clean_later_episodes(sender, instance, created, *args, **kwargs):
"""Clean-up later diffusions when a program becomes inactive."""
if not instance.active: if not instance.active:
Diffusion.objects.program(instance).after(tz.now()).delete() Diffusion.objects.program(instance).after(tz.now()).delete()
Episode.objects.parent(instance).filter(diffusion__isnull=True).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: @receiver(signals.post_save, sender=Program)
Episode.objects.parent(instance).filter(cover__isnull=True).update(cover=instance.cover) 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) @receiver(signals.pre_save, sender=Schedule)
def schedule_pre_save(sender, instance, *args, **kwargs): def schedule_pre_save(sender, instance, *args, **kwargs):
if getattr(instance, "pk") is not None and "raw" not in kwargs: if getattr(instance, "pk") is not None and "raw" not in kwargs:
@ -94,11 +118,13 @@ def schedule_pre_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete() Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
# ---- diffusion
@receiver(signals.post_delete, sender=Diffusion) @receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs): def diffusion_post_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete() Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
# ---- files
@receiver(signals.post_delete, sender=Sound) @receiver(signals.post_delete, sender=Sound)
def delete_file(sender, instance, *args, **kwargs): def delete_file(sender, instance, *args, **kwargs):
"""Deletes file on `post_delete`""" """Deletes file on `post_delete`"""

View File

@ -169,6 +169,9 @@
.preview .headings a:hover { .preview .headings a:hover {
color: var(--heading-link-hv-fg) !important; color: var(--heading-link-hv-fg) !important;
} }
.preview.tiny .title {
font-size: calc(var(--preview-title-sz) * 0.8);
}
.preview.tiny .content { .preview.tiny .content {
font-size: 1rem; font-size: 1rem;
} }
@ -256,6 +259,7 @@
} }
.list-item .actions { .list-item .actions {
text-align: right; text-align: right;
align-items: center;
} }
.list-item:not(.wide) .media { .list-item:not(.wide) .media {
padding: 0.6rem; padding: 0.6rem;
@ -9689,6 +9693,26 @@ a.tag:hover {
height: 25em; height: 25em;
} }
.gap-1 {
gap: 0.2rem !important;
}
.gap-2 {
gap: 0.4rem !important;
}
.gap-3 {
gap: 0.6rem !important;
}
.gap-4 {
gap: 1.2rem !important;
}
.gap-5 {
gap: 1.6rem !important;
}
.grid { .grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -9819,6 +9843,13 @@ a.tag:hover {
border-color: #b00 !important; border-color: #b00 !important;
} }
.box-shadow:hover {
box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.2);
}
.box-shadow.active {
box-shadow: 0em 0em 1em rgba(0, 0, 0, 0.4);
}
input.half-field:not(:active):not(:hover) { input.half-field:not(:active):not(:hover) {
border: none; border: none;
background-color: rgba(0, 0, 0, 0); background-color: rgba(0, 0, 0, 0);

View File

@ -169,6 +169,9 @@
.preview .headings a:hover { .preview .headings a:hover {
color: var(--heading-link-hv-fg) !important; color: var(--heading-link-hv-fg) !important;
} }
.preview.tiny .title {
font-size: calc(var(--preview-title-sz) * 0.8);
}
.preview.tiny .content { .preview.tiny .content {
font-size: 1rem; font-size: 1rem;
} }
@ -256,6 +259,7 @@
} }
.list-item .actions { .list-item .actions {
text-align: right; text-align: right;
align-items: center;
} }
.list-item:not(.wide) .media { .list-item:not(.wide) .media {
padding: 0.6rem; padding: 0.6rem;

View File

@ -86,9 +86,9 @@ Usefull context:
{% endspaceless %} {% endspaceless %}
{% block header-container %} {% block header-container %}
{% if page or cover or title %}
<header class="container header preview preview-header {% if cover %}has-cover{% endif %}"> <header class="container header preview preview-header {% if cover %}has-cover{% endif %}">
{% block header %} {% block header %}
{% spaceless %}
<figure class="header-cover"> <figure class="header-cover">
{% block header-cover %} {% block header-cover %}
{% if cover %} {% if cover %}
@ -96,6 +96,7 @@ Usefull context:
{% endif %} {% endif %}
{% endblock %} {% endblock %}
</figure> </figure>
{% endspaceless %}
<div class="headings preview-card-headings"> <div class="headings preview-card-headings">
{% block headings %} {% block headings %}
<div> <div>
@ -119,7 +120,6 @@ Usefull context:
</div> </div>
{% endblock %} {% endblock %}
</header> </header>
{% endif %}
{% endblock %} {% endblock %}
{% block content-container %} {% block content-container %}
@ -144,15 +144,6 @@ Usefull context:
{{ render }} {{ render }}
{% endfor %} {% endfor %}
{% endcomment %} {% endcomment %}
{% if not request.user.is_authenticated %}
<a class="nav-item" href="{% url "profile" %}" target="new"
title="{% translate "Profile" %}">
<span class="small icon">
<i class="fa fa-user"></i>
</span>
</a>
{% endif %}
{% endblock %} {% endblock %}
{% if request.station and request.station.legal_label %} {% if request.station and request.station.legal_label %}

View File

@ -0,0 +1,8 @@
{% extends "../base.html" %}
{% load i18n %}
{% block head-title %}
{% block title %}{{ block.super }}{% endblock %}
&mdash;
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "./base.html" %}
{% load i18n aircox %}
{% block subtitle %}
<span class="icon">
<i class="fa fa-user"></i>
</span>
{{ block.super }}
{% if user.is_superuser %}
&mdash; {% translate "administrator" %}
{% endif %}
{% endblock %}
{% block content-container %}
<section class="container grid-2 gap-4">
<div>
<h2 class="title is-2">{% translate "Programs" %}</h2>
<div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;">
{% for object in programs %}
{% page_widget "item" object admin=True is_tiny=True %}
{% empty %}
<div>{% translate "No diffusion to come" %}</div>
{% endfor %}
</div>
</div>
<div>
<h2 class="title is-2">{% translate "Next diffusions" %}</h2>
<div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;">
{% for object in next_diffs|slice:"0:25" %}
{% page_widget "item" object.episode diffusion=object timetable=True admin=True is_tiny=True %}
{% empty %}
<div>{% translate "No diffusion to come" %}</div>
{% endfor %}
</div>
</div>
<div>
<h2 class="title is-2">{% translate "Last Comments" %}</h2>
<div class="box box-shadow p-3" style="max-height: 35rem; overflow-y:auto;">
{% for object in comments|slice:"0:25" %}
{% page_widget "item" object admin=True is_tiny=True %}
{% empty %}
<div>{% translate "No diffusion to come" %}</div>
{% endfor %}
</div>
</div></section>
{% endblock %}

View File

@ -10,10 +10,13 @@
<div class="dropdown-menu" id="dropdown-menu" role="menu" style="z-index:200"> <div class="dropdown-menu" id="dropdown-menu" role="menu" style="z-index:200">
<div class="dropdown-content"> <div class="dropdown-content">
{% block user-menu %} {% block user-menu %}
<a class="dropdown-item" href="{% url "dashboard" %}">
{% translate "Dashboard" %}
</a>
{% endblock %} {% endblock %}
{% if user.is_admin %} {% if user.is_admin %}
{% block admin-menu %} {% block admin-menu %}
<a class="nav-item" href="{% url "admin:index" %}" target="new"> <a class="dropdown-item" href="{% url "admin:index" %}" target="new">
{% translate "Admin" %} {% translate "Admin" %}
</a> </a>
{% endblock %} {% endblock %}

View File

@ -12,7 +12,7 @@
{% if object.podcasts %} {% if object.podcasts %}
{% spaceless %} {% spaceless %}
<section class="container no-border"> <section class="container no-border">
<h3 class="title">{% translate "Podcasts" %}</h3> <h2 class="title is-2">{% translate "Podcasts" %}</h2>
<a-playlist v-if="page" :set="podcasts" <a-playlist v-if="page" :set="podcasts"
name="{{ page.title }}" name="{{ page.title }}"
list-class="menu-list" item-class="menu-item" list-class="menu-list" item-class="menu-item"
@ -25,7 +25,7 @@
{% if tracks %} {% if tracks %}
<section class="container"> <section class="container">
<h3 class="title">{% translate "Playlist" %}</h3> <h2 class="title is-2">{% translate "Playlist" %}</h2>
<table class="table is-hoverable is-fullwidth"> <table class="table is-hoverable is-fullwidth">
<thead> <thead>
<tr> <tr>

View File

@ -8,7 +8,7 @@
<hr/> <hr/>
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset formset_data=tracklist_formset_data %} {% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset formset_data=tracklist_formset_data %}
<hr/> <hr/>
<h3 class="title">{% translate "Podcasts" %}</h3> <h2 class="title is-2">{% translate "Podcasts" %}</h2>
{% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset formset_data=soundlist_formset_data %} {% include "./dashboard/soundlist_editor.html" with formset=soundlist_formset formset_data=soundlist_formset_data %}
</template> </template>
</a-episode> </a-episode>

View File

@ -37,7 +37,7 @@ Context:
{% if related_objects %} {% if related_objects %}
<section class="container"> <section class="container">
{% with models=object|verbose_name:True %} {% with models=object|verbose_name:True %}
<h3 class="title">{% blocktranslate %}Related {{models}}{% endblocktranslate %}</h3> <h2 class="title is-2">{% blocktranslate %}Related {{models}}{% endblocktranslate %}</h2>
{% include "./widgets/carousel.html" with objects=related_objects url_name=object.list_url_name url_category=object.category %} {% include "./widgets/carousel.html" with objects=related_objects url_name=object.list_url_name url_category=object.category %}
{% endwith %} {% endwith %}
@ -48,7 +48,7 @@ Context:
{% block comments %} {% block comments %}
{% if comments %} {% if comments %}
<section class="container"> <section class="container">
<h2 class="title">{% translate "Comments" %}</h2> <h2 class="title is-2">{% translate "Comments" %}</h2>
{% for object in comments %} {% for object in comments %}
{% page_widget "item" object %} {% page_widget "item" object %}
@ -58,7 +58,7 @@ Context:
{% if comment_form %} {% if comment_form %}
<section class="container"> <section class="container">
<h2 class="title">{% translate "Post a comment" %}</h2> <h2 class="title is-2">{% translate "Post a comment" %}</h2>
<form method="POST"> <form method="POST">
{% csrf_token %} {% csrf_token %}
{% render_honeypot_field "website" %} {% render_honeypot_field "website" %}

View File

@ -2,20 +2,8 @@
{% comment %}Detail page of a show{% endcomment %} {% comment %}Detail page of a show{% endcomment %}
{% load i18n aircox %} {% load i18n aircox %}
{% block top-nav-tools %}
{% has_perm page page.change_permission_codename simple=True as can_edit %}
{% if can_edit %}
<a class="navbar-item" href="{% url 'program-edit' page.pk %}" target="_self">
<span class="icon is-small">
<i class="fa fa-pen"></i>
</span>&nbsp;
<span>{% translate "Edit" %}</span>
</a>
{% endif %}
{% endblock %}
{% block content-container %} {% block content-container %}
{% with schedules=program.schedule_set.all %} {% with schedules=object.schedule_set.all %}
{% if schedules %} {% if schedules %}
<header class="container schedules"> <header class="container schedules">
{% for schedule in schedules %} {% for schedule in schedules %}
@ -49,7 +37,7 @@
{% if episodes %} {% if episodes %}
<section class="container"> <section class="container">
<h3 class="title">{% translate "Last Episodes" %}</h3> <h2 class="title is-2">{% translate "Last Episodes" %}</h2>
{% include "./widgets/carousel.html" with objects=episodes url_name="episode-list" url_parent=object url_label=_("All episodes") %} {% include "./widgets/carousel.html" with objects=episodes url_name="episode-list" url_parent=object url_label=_("All episodes") %}
</section> </section>
{% endif %} {% endif %}
@ -57,7 +45,7 @@
{% if articles %} {% if articles %}
<section class="container"> <section class="container">
<h3 class="title">{% translate "Last Articles" %}</h3> <h2 class="title is-2">{% translate "Last Articles" %}</h2>
{% include "./widgets/carousel.html" with objects=articles url_name="article-list" url_parent=object url_label=_("All articles") %} {% include "./widgets/carousel.html" with objects=articles url_name="article-list" url_parent=object url_label=_("All articles") %}
</section> </section>
{% endif %} {% endif %}

View File

@ -0,0 +1,26 @@
{% comment %}
Context:
- object: diffusion
{% endcomment %}
{% load i18n %}
{% if object.type == object.TYPE_ON_AIR %}
<span class="tag is-info">
<span class="icon is-small">
{% if object.is_live %}
<i class="fa fa-microphone"
title="{% translate "Live diffusion" %}"></i>
{% else %}
<i class="fa fa-music"
title="{% translate "Differed diffusion" %}"></i>
{% endif %}
</span>
&nbsp;
{{ object.get_type_display }}
</span>
{% elif object.type == object.TYPE_CANCEL %}
<span class="tag is-danger">
{{ object.get_type_display }}</span>
{% elif object.type == object.TYPE_UNCONFIRMED %}
<span class="tag is-warning">
{{ object.get_type_display }}</span>
{% endif %}

View File

@ -3,13 +3,7 @@
{% block outer %} {% block outer %}
{% with diffusion.is_now as is_active %} {% with diffusion.is_now as is_active %}
{% if admin %} {{ block.super }}
{% with object|admin_url:"change" as url %}
{{ block.super }}
{% endwith %}
{% else %}
{{ block.super }}
{% endif %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}
@ -29,33 +23,41 @@
{% endblock %} {% endblock %}
{% block actions-container %}
{% if admin and diffusion %}
<div class="flex-row">
<div class="flex-grow-1">
{% if diffusion.type == diffusion.TYPE_ON_AIR %}
<span class="tag is-info">
<span class="icon is-small">
{% if diffusion.is_live %}
<i class="fa fa-microphone"
title="{% translate "Live diffusion" %}"></i>
{% else %}
<i class="fa fa-music"
title="{% translate "Differed diffusion" %}"></i>
{% endif %}
</span>
&nbsp;
{{ diffusion.get_type_display }}
</span>
{% elif diffusion.type == diffusion.TYPE_CANCEL %}
<span class="tag is-danger">
{{ diffusion.get_type_display }}</span>
{% elif diffusion.type == diffusion.TYPE_UNCONFIRMED %}
<span class="tag is-warning">
{{ diffusion.get_type_display }}</span>
{% endif %}
</div>
{{ block.super }}
</div>
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}
{% block actions %} {% block actions %}
{{ block.super }} {{ block.super }}
{% if admin and diffusion %}
{% if diffusion.type == diffusion.TYPE_ON_AIR %}
<span class="tag is-info">
<span class="icon is-small">
{% if diffusion.is_live %}
<i class="fa fa-microphone"
title="{% translate "Live diffusion" %}"></i>
{% else %}
<i class="fa fa-music"
title="{% translate "Differed diffusion" %}"></i>
{% endif %}
</span>
&nbsp;
{{ diffusion.get_type_display }}
</span>
{% elif diffusion.type == diffusion.TYPE_CANCEL %}
<span class="tag is-danger">
{{ diffusion.get_type_display }}</span>
{% elif diffusion.type == diffusion.TYPE_UNCONFIRMED %}
<span class="tag is-warning">
{{ diffusion.get_type_display }}</span>
{% endif %}
{% endif %}
{% if object.sound_set.count %} {% if object.sound_set.count %}
<button class="button action" @click="player.playButtonClick($event)" <button class="button action" @click="player.playButtonClick($event)"
data-sounds="{{ object.podcasts|json }}"> data-sounds="{{ object.podcasts|json }}">
@ -65,4 +67,5 @@
<label>{% translate "Listen" %}</label> <label>{% translate "Listen" %}</label>
</button> </button>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -16,7 +16,7 @@
{% block headings-container %}{{ block.super }}{% endblock %} {% block headings-container %}{{ block.super }}{% endblock %}
{% block content-container %} {% block content-container %}
<div class="media"> <div class="media">
{% if object.cover %} {% if object.cover and not no_cover %}
<a href="{{ object.get_absolute_url }}" <a href="{{ object.get_absolute_url }}"
class="media-left preview-cover small" class="media-left preview-cover small"
style="background-image: url({{ object.cover.url }})"> style="background-image: url({{ object.cover.url }})">
@ -24,17 +24,9 @@
{% endif %} {% endif %}
<div class="media-content flex-column"> <div class="media-content flex-column">
<section class="content flex-grow-1"> <section class="content flex-grow-1">
{% block content %} {% block content %}{{ block.super }}{% endblock %}
{% if content and with_content %}
{% autoescape off %}
{{ content|striptags|linebreaks }}
{% endautoescape %}
{% endif %}
{% endblock %}
</section> </section>
{% block actions-container %} {% block actions-container %}{{ block.super }}{% endblock %}
{{ block.super }}
{% endblock %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -4,15 +4,9 @@
{% block outer %} {% block outer %}
{% with cover|default:object.cover_url as cover %} {% with cover|default:object.cover_url as cover %}
{% if admin %} {% with url|default:object.get_absolute_url as url %}
{% with object|admin_url:"change" as url %} {{ block.super }}
{{ block.super }} {% endwith %}
{% endwith %}
{% else %}
{% with url|default:object.get_absolute_url as url %}
{{ block.super }}
{% endwith %}
{% endif %}
{% endwith %} {% endwith %}
{% endblock %} {% endblock %}
@ -27,11 +21,12 @@
{% block content %} {% block content %}
{% if content %} {% if not content and object %}
{{ content }} {% with object.display_headline as content %}
{% elif object %} {{ block.super }}
{% endwith %}
{% else %}
{{ block.super }} {{ block.super }}
{{ object.display_headline }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -7,6 +7,10 @@ Content related context:
- subtitle: subtitle - subtitle: subtitle
- content: content to display - content: content to display
Components:
- no_cover: don't show cover
- no_content: don't show content
Styling related context: Styling related context:
- is_active: add "active" css class - is_active: add "active" css class
- is_small: add "small" css class - is_small: add "small" css class
@ -18,7 +22,7 @@ Styling related context:
{% endcomment %} {% endcomment %}
{% block outer %} {% block outer %}
<{{ tag|default:"article" }} class="preview {% if not cover %}no-cover {% endif %}{% if is_active %}active {% endif %}{% block tag-class %}{{ tag_class|default:"" }} {% endblock %}" {% block tag-extra %}{% endblock %}> <{{ tag|default:"article" }} class="preview {% if not cover %}no-cover {% endif %}{% if is_active %}active {% endif %}{% if is_tiny %}tiny{% elif is_small %}small{% endif %}{% block tag-class %}{{ tag_class|default:"" }} {% endblock %}" {% block tag-extra %}{% endblock %}>
{% block inner %} {% block inner %}
{% block headings-container %} {% block headings-container %}
<header class="headings{% block headings-class %}{% endblock %}"{% block headings-tag-extra %}{% endblock %}> <header class="headings{% block headings-class %}{% endblock %}"{% block headings-tag-extra %}{% endblock %}>
@ -40,7 +44,7 @@ Styling related context:
{% block content-container %} {% block content-container %}
<section class="content headings-container"> <section class="content headings-container">
{% block content %} {% block content %}
{% if content and with_content %} {% if content and not no_content %}
{% autoescape off %} {% autoescape off %}
{{ content|striptags|linebreaks }} {{ content|striptags|linebreaks }}
{% endautoescape %} {% endautoescape %}
@ -50,9 +54,15 @@ Styling related context:
{% endblock %} {% endblock %}
{% block actions-container %} {% block actions-container %}
{% spaceless %}
<div class="actions"> <div class="actions">
{% block actions %}{% endblock %} {% block actions %}
{% if admin and object.edit_url_name %}
<a href="{% url object.edit_url_name pk=object.pk %}">{% translate "Edit" %}</a>
{% endif %}
{% endblock %}
</div> </div>
{% endspaceless %}
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}
</{{ tag|default:"article" }}> </{{ tag|default:"article" }}>

View File

@ -2,7 +2,7 @@ from django.urls import include, path, register_converter
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from . import forms, models, views, viewsets from . import models, views, viewsets
from .converters import DateConverter, PagePathConverter, WeekConverter from .converters import DateConverter, PagePathConverter, WeekConverter
__all__ = ["api", "urls"] __all__ = ["api", "urls"]
@ -70,7 +70,7 @@ urls = [
name="page-list", name="page-list",
), ),
path( path(
_("publications/c/<slug:category_slug>"), _("publications/c/<slug:category_slug>/"),
views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES), views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES),
name="page-list", name="page-list",
), ),
@ -98,47 +98,29 @@ urls = [
views.ProgramDetailView.as_view(), views.ProgramDetailView.as_view(),
name="program-detail", name="program-detail",
), ),
path(_("programs/<slug:parent_slug>/articles"), views.ArticleListView.as_view(), name="article-list"), path(_("programs/<slug:parent_slug>/articles/"), views.ArticleListView.as_view(), name="article-list"),
path(_("programs/<slug:parent_slug>/podcasts"), views.PodcastListView.as_view(), name="podcast-list"), path(_("programs/<slug:parent_slug>/podcasts/"), views.PodcastListView.as_view(), name="podcast-list"),
path(_("programs/<slug:parent_slug>/episodes"), views.EpisodeListView.as_view(), name="episode-list"), path(_("programs/<slug:parent_slug>/episodes/"), views.EpisodeListView.as_view(), name="episode-list"),
path(_("programs/<slug:parent_slug>/diffusions"), views.DiffusionListView.as_view(), name="diffusion-list"), path(_("programs/<slug:parent_slug>/diffusions/"), views.DiffusionListView.as_view(), name="diffusion-list"),
path( path(
_("programs/<slug:parent_slug>/publications"), _("programs/<slug:parent_slug>/publications/"),
views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES), views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES),
name="page-list", name="page-list",
), ),
# ---- episodes # ---- episodes
path(_("programs/episodes/"), views.EpisodeListView.as_view(), name="episode-list"), path(_("programs/episodes/"), views.EpisodeListView.as_view(), name="episode-list"),
path(_("programs/episodes/c/<slug:category_slug>"), views.EpisodeListView.as_view(), name="episode-list"), path(_("programs/episodes/c/<slug:category_slug>/"), views.EpisodeListView.as_view(), name="episode-list"),
path( path(
_("programs/episodes/<slug:slug>"), _("programs/episodes/<slug:slug>/"),
views.EpisodeDetailView.as_view(), views.EpisodeDetailView.as_view(),
name="episode-detail", name="episode-detail",
), ),
path(
_("programs/episodes/<pk>/edit/"),
views.EpisodeUpdateView.as_view(),
name="episode-edit",
),
path(_("podcasts/"), views.PodcastListView.as_view(), name="podcast-list"), path(_("podcasts/"), views.PodcastListView.as_view(), name="podcast-list"),
path(_("podcasts/c/<slug:category_slug>/"), views.PodcastListView.as_view(), name="podcast-list"), path(_("podcasts/c/<slug:category_slug>/"), views.PodcastListView.as_view(), name="podcast-list"),
# ---- dashboard
path(_("dashboard/"), views.dashboard.DashboardView.as_view(), name="dashboard"),
path(_("dashboard/program/<pk>/"), views.ProgramUpdateView.as_view(), name="program-edit"),
path(_("dashboard/episodes/<pk>/"), views.EpisodeUpdateView.as_view(), name="episode-edit"),
# ---- others # ---- others
path( path("errors/no-station/", views.errors.NoStationErrorView.as_view(), name="errors-no-station"),
_("program/<pk>/edit/"),
views.ProgramUpdateView.as_view(),
name="program-edit",
),
path(
"errors/no-station",
views.errors.NoStationErrorView.as_view(),
name="errors-no-station",
),
# ---- backoffice
path(_("edit/"), views.ProfileView.as_view(), name="profile"),
path(
_("edit/programs/<slug:slug>"),
views.PageUpdateView.as_view(model=models.Program, form_class=forms.ProgramForm),
name="program-update",
),
path("accounts/profile/", views.ProfileView.as_view(), name="profile"),
] ]

View File

@ -1,4 +1,4 @@
from . import admin, errors from . import admin, dashboard, errors
from .article import ArticleDetailView, ArticleListView from .article import ArticleDetailView, ArticleListView
from .base import BaseAPIView, BaseView from .base import BaseAPIView, BaseView
from .diffusion import DiffusionListView, TimeTableView from .diffusion import DiffusionListView, TimeTableView
@ -12,19 +12,18 @@ from .page import (
PageListView, PageListView,
PageUpdateView, PageUpdateView,
) )
from .profile import ProfileView
from .program import ( from .program import (
ProgramDetailView, ProgramDetailView,
ProgramListView, ProgramListView,
ProgramPageDetailView,
ProgramPageListView,
ProgramUpdateView, ProgramUpdateView,
) )
__all__ = ( __all__ = (
"admin", "admin",
"dashboard",
"errors", "errors",
"attached",
"BaseAPIView", "BaseAPIView",
"BaseView", "BaseView",
"ArticleDetailView", "ArticleDetailView",
@ -43,13 +42,9 @@ __all__ = (
"PageDetailView", "PageDetailView",
"PageUpdateView", "PageUpdateView",
"PageListView", "PageListView",
"ProfileView",
"ProgramDetailView", "ProgramDetailView",
"ProgramListView", "ProgramListView",
"ProgramPageDetailView",
"ProgramPageListView",
"ProgramUpdateView", "ProgramUpdateView",
"attached",
) )

View File

@ -9,6 +9,7 @@ __all__ = ("BaseView", "BaseAPIView")
class BaseView(TemplateResponseMixin, ContextMixin): class BaseView(TemplateResponseMixin, ContextMixin):
related_count = 4 related_count = 4
related_carousel_count = 8 related_carousel_count = 8
title = ""
@property @property
def station(self): def station(self):
@ -58,8 +59,10 @@ class BaseView(TemplateResponseMixin, ContextMixin):
page = kwargs.get("page") page = kwargs.get("page")
if page: if page:
kwargs["title"] = page.display_title kwargs.setdefault("title", page.display_title)
kwargs["cover"] = page.cover and page.cover.url kwargs.setdefault("cover", page.cover and page.cover.url)
elif self.title:
kwargs.setdefault("title", self.title)
if "nav_menu" not in kwargs: if "nav_menu" not in kwargs:
kwargs["nav_menu"] = self.get_nav_menu() kwargs["nav_menu"] = self.get_nav_menu()

31
aircox/views/dashboard.py Normal file
View File

@ -0,0 +1,31 @@
from django.db.models import Q
from django.contrib.auth.mixins import LoginRequiredMixin
from django.utils.translation import gettext_lazy as _
from django.views.generic.base import TemplateView
from aircox import models
from .base import BaseView
class DashboardView(LoginRequiredMixin, BaseView, TemplateView):
template_name = "aircox/dashboard/dashboard.html"
title = _("Dashboard")
def get_context_data(self, **kwargs):
programs = models.Program.objects.editor(self.request.user)
comments = models.Comment.objects.filter(
Q(page__in=programs) | Q(page__episode__parent__in=programs) | Q(page__article__parent__in=programs)
)
kwargs.update(
{
"subtitle": self.request.user.get_username(),
"programs": programs.order_by("title"),
"comments": comments.order_by("-date"),
"next_diffs": models.Diffusion.objects.editor(self.request.user)
.select_related("episode")
.after()
.order_by("start"),
}
)
return super().get_context_data(**kwargs)

View File

@ -5,9 +5,7 @@ from aircox.models import Episode, Program, StaticPage, Track
from aircox import forms, filters from aircox import forms, filters
from .mixins import VueFormDataMixin from .mixins import VueFormDataMixin
from .page import PageListView from .page import PageDetailView, PageListView, PageUpdateView
from .program import ProgramPageDetailView, BaseProgramMixin
from .page import PageUpdateView
__all__ = ( __all__ = (
@ -18,7 +16,7 @@ __all__ = (
) )
class EpisodeDetailView(ProgramPageDetailView): class EpisodeDetailView(PageDetailView):
model = Episode model = Episode
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -47,14 +45,14 @@ class PodcastListView(EpisodeListView):
queryset = Episode.objects.published().with_podcasts().order_by("-pub_date") queryset = Episode.objects.published().with_podcasts().order_by("-pub_date")
class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, BaseProgramMixin, PageUpdateView): class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
model = Episode model = Episode
form_class = forms.EpisodeForm form_class = forms.EpisodeForm
template_name = "aircox/episode_form.html" template_name = "aircox/episode_form.html"
def test_func(self): def test_func(self):
program = self.get_object().program obj = self.get_object().parent_subclass
return self.request.user.has_perm("aircox.%s" % program.change_permission_codename) return obj.can_update(self.request.user)
def get_tracklist_queryset(self, episode): def get_tracklist_queryset(self, episode):
return Track.objects.filter(episode=episode).order_by("position") return Track.objects.filter(episode=episode).order_by("position")

View File

@ -54,7 +54,7 @@ class HomeView(AttachedToMixin, BaseView, ListView):
parents = set() parents = set()
items = [] items = []
for publication in qs: for publication in qs:
parent_id = publication.parent_id parent_id = getattr(publication, "parent_id", None)
if parent_id is not None and parent_id in parents: if parent_id is not None and parent_id in parents:
continue continue
items.append(publication) items.append(publication)

View File

@ -33,7 +33,7 @@ class BasePageMixin:
if page: if page:
if getattr(page, "category_id", None): if getattr(page, "category_id", None):
return page.category return page.category
if page.parent_id: if getattr(page, "parent_id", None):
return self.get_category(page.parent_subclass) return self.get_category(page.parent_subclass)
if slug := self.kwargs.get("category_slug"): if slug := self.kwargs.get("category_slug"):
return Category.objects.get(slug=slug) return Category.objects.get(slug=slug)
@ -160,8 +160,8 @@ class PageDetailView(BasePageDetailView):
kwargs["comment_form"] = self.get_comment_form() kwargs["comment_form"] = self.get_comment_form()
kwargs["comments"] = Comment.objects.filter(page=self.object).order_by("-date") kwargs["comments"] = Comment.objects.filter(page=self.object).order_by("-date")
if self.object.parent_subclass: if parent_subclass := getattr(self.object, "parent_subclass", None):
kwargs["parent"] = self.object.parent_subclass kwargs["parent"] = parent_subclass
if "related_objects" not in kwargs: if "related_objects" not in kwargs:
related = self.get_related_queryset() related = self.get_related_queryset()

View File

@ -1,15 +0,0 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.base import TemplateView
from aircox.models import Program
from aircox.views import BaseView
class ProfileView(LoginRequiredMixin, BaseView, TemplateView):
template_name = "accounts/profile.html"
def get_context_data(self, **kwargs):
groups = self.request.user.groups.all()
programs = Program.objects.filter(editors__in=groups)
kwargs.update({"user": self.request.user, "programs": programs})
return super().get_context_data(**kwargs)

View File

@ -4,24 +4,16 @@ from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse from django.urls import reverse
from aircox.forms import ProgramForm from aircox.forms import ProgramForm
from aircox.models import Article, Episode, Page, Program, StaticPage from aircox.models import Article, Episode, Program, StaticPage
from .mixins import ParentMixin
from .page import PageDetailView, PageListView, PageUpdateView from .page import PageDetailView, PageListView, PageUpdateView
__all__ = ["ProgramPageDetailView", "ProgramDetailView", "ProgramPageListView"] __all__ = (
"ProgramDetailView",
"ProgramDetailView",
)
class BaseProgramMixin: class ProgramDetailView(PageDetailView):
def get_program(self):
return self.object
def get_context_data(self, **kwargs):
self.program = self.get_program()
kwargs["program"] = self.program
return super().get_context_data(**kwargs)
class ProgramDetailView(BaseProgramMixin, PageDetailView):
model = Program model = Program
def get_related_queryset(self): def get_related_queryset(self):
@ -52,18 +44,6 @@ class ProgramDetailView(BaseProgramMixin, PageDetailView):
return super().get_template_names() + ["aircox/program_detail.html"] return super().get_template_names() + ["aircox/program_detail.html"]
class ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
model = Program
form_class = ProgramForm
def test_func(self):
program = self.get_object()
return self.request.user.has_perm("aircox.%s" % program.change_permission_codename)
def get_success_url(self):
return reverse("program-detail", kwargs={"slug": self.get_object().slug})
class ProgramListView(PageListView): class ProgramListView(PageListView):
model = Program model = Program
attach_to_value = StaticPage.Target.PROGRAMS attach_to_value = StaticPage.Target.PROGRAMS
@ -72,22 +52,13 @@ class ProgramListView(PageListView):
return super().get_queryset().order_by("title") return super().get_queryset().order_by("title")
# FIXME: not used class ProgramUpdateView(UserPassesTestMixin, PageUpdateView):
class ProgramPageDetailView(BaseProgramMixin, ParentMixin, PageDetailView): model = Program
"""Base view class for a page that is displayed as a program's child form_class = ProgramForm
page."""
parent_model = Program def test_func(self):
obj = self.get_object()
return obj.can_update(self.request.user)
def get_program(self): def get_success_url(self):
self.parent = self.object.program return reverse("program-detail", kwargs={"slug": self.get_object().slug})
return self.object.program
class ProgramPageListView(BaseProgramMixin, PageListView):
model = Page
parent_model = Program
queryset = Page.objects.select_subclasses()
def get_program(self):
return self.parent

19
aircox_streamer/conf.py Normal file
View File

@ -0,0 +1,19 @@
import os
import tempfile
from aircox.conf import BaseSettings
__all__ = ("Settings", "settings")
class Settings(BaseSettings):
WORKING_DIR = os.path.join(tempfile.gettempdir(), "aircox")
"""Parent working directory for all stations."""
def get_dir(self, station, *paths):
"""Return working directory for the provided station."""
return os.path.join(self.WORKING_DIR, station.slug.replace("-", "_"), *paths)
settings = Settings("AIRCOX_STREAMER")

View File

@ -261,9 +261,8 @@
} }
&.tiny { &.tiny {
.content { .title { font-size: calc(var(--preview-title-sz) * 0.8); }
font-size: v.$text-size; .content { font-size: v.$text-size; }
}
} }
} }
@ -367,6 +366,7 @@
.actions { .actions {
text-align: right; text-align: right;
align-items: center;
} }
&:not(.wide) .media { &:not(.wide) .media {

View File

@ -54,7 +54,16 @@
.height-20 { height: 20em; } .height-20 { height: 20em; }
.height-25 { height: 25em; } .height-25 { height: 25em; }
// ---- grid // ---- grid / flex
.gap-1 { gap: v.$mp-1 !important; }
.gap-2 { gap: v.$mp-2 !important; }
.gap-3 { gap: v.$mp-3 !important; }
.gap-4 { gap: v.$mp-4 !important; }
.gap-5 { gap: v.$mp-5 !important; }
// ---- ---- grid
@mixin grid { @mixin grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -70,7 +79,7 @@
.grid-2 { @include grid; @include grid-2; } .grid-2 { @include grid; @include grid-2; }
.grid-3 { @include grid; @include grid-3; } .grid-3 { @include grid; @include grid-3; }
// ---- flex // ---- ---- flex
.flex-row { display: flex; flex-direction: row } .flex-row { display: flex; flex-direction: row }
.flex-column { display: flex; flex-direction: column } .flex-column { display: flex; flex-direction: column }
.flex-grow-0 { flex-grow: 0 !important; } .flex-grow-0 { flex-grow: 0 !important; }
@ -124,3 +133,14 @@
background-color: v.$red !important; background-color: v.$red !important;
border-color: v.$red-dark !important; border-color: v.$red-dark !important;
} }
.box-shadow {
&:hover {
box-shadow: 0em 0em 1em rgba(0,0,0,0.2);
}
&.active {
box-shadow: 0em 0em 1em rgba(0,0,0,0.4);
}
}