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

View File

@ -21,7 +21,7 @@ class CategoryAdmin(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_editable = ("status",)
list_filter = ("status",)
@ -42,7 +42,9 @@ class BasePageAdmin(admin.ModelAdmin):
(
_("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 ""
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):
filters = QueryDict(request.GET.get("_changelist_filters", ""))
extra_context = self._get_common_context(filters, extra_context)
@ -88,12 +73,36 @@ class PageAdmin(BasePageAdmin):
list_editable = BasePageAdmin.list_editable + ("category",)
list_filter = BasePageAdmin.list_filter + ("category", "pub_date")
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[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)
class StaticPageAdmin(BasePageAdmin):
list_display = BasePageAdmin.list_display + ("attach_to",)

View File

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

View File

@ -13,7 +13,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AddField(
model_name="program",
name="editors",
name="editors_group",
field=models.ForeignKey(
blank=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):
dependencies = [
("aircox", "0022_set_group_ownership"),
("filer", "0017_image__transparent"),
("aircox", "0021_alter_schedule_timezone"),
]
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 .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"

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)
@ -200,7 +204,7 @@ class Diffusion(Rerun):
@property
def is_live(self):
"""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):
"""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 .page import Page
from .page import ChildPage
from .program import ProgramChildQuerySet
from .sound import Sound
@ -19,10 +19,11 @@ class EpisodeQuerySet(ProgramChildQuerySet):
return self.filter(episodesound__sound__is_public=True).distinct()
class Episode(Page):
class Episode(ChildPage):
objects = EpisodeQuerySet.as_manager()
detail_url_name = "episode-detail"
list_url_name = "episode-list"
edit_url_name = "episode-edit"
template_prefix = "episode"
@property
@ -59,11 +60,6 @@ class Episode(Page):
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")

View File

@ -86,14 +86,6 @@ class BasePage(Renderable, 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(
@ -133,12 +125,6 @@ class BasePage(Renderable, models.Model):
count = Page.objects.filter(slug__startswith=self.slug).count()
if 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)
def get_absolute_url(self):
@ -160,14 +146,10 @@ class BasePage(Renderable, models.Model):
@property
def display_title(self):
if self.is_published:
return self.title
return self.parent and self.parent.title or ""
return self.is_published and self.title or ""
@cached_property
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 = headline_clean_re.sub("\n", content)
if content.startswith("\n"):
@ -205,6 +187,7 @@ class BasePage(Renderable, 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())
@ -232,8 +215,48 @@ 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)
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):
@ -246,21 +269,13 @@ class Page(BasePage):
return self.parent_subclass.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):
if self.is_published and self.pub_date is None:
self.pub_date = tz.now()
elif not self.is_published:
self.pub_date = None
if self.parent and not self.category:
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)

View File

@ -1,13 +1,9 @@
import logging
import os
import shutil
from django.conf import settings as conf
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
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
@ -15,13 +11,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",
)
@ -34,6 +28,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.
@ -60,11 +64,12 @@ class Program(Page):
default=True,
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()
detail_url_name = "program-detail"
list_url_name = "program-list"
edit_url_name = "program-edit"
@property
def path(self):
@ -84,19 +89,10 @@ class Program(Page):
def excerpts_path(self):
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
@property
def editors_group_name(self):
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)
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):
@ -121,18 +117,36 @@ class Program(Page):
os.makedirs(path, exist_ok=True)
return os.path.exists(path)
def set_group_ownership(self):
editors, created = Group.objects.get_or_create(name=self.editors_group_name)
def can_update(self, user):
"""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:
self.editors = editors
super().save()
if not self.pk:
self.save(check_groups=False)
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),
defaults={"name": f"change program {self.title}"},
defaults={"name": self._perm_update_name.format(self=self)},
)
if permission not in editors.permissions.all():
editors.permissions.add(permission)
if permission not in self.editors_group.permissions.all():
self.editors_group.permissions.add(permission)
class Meta:
verbose_name = _("Program")
@ -141,29 +155,11 @@ class Program(Page):
def __str__(self):
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):
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
@ -173,6 +169,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,13 +1,18 @@
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
@ -16,6 +21,9 @@ 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
# 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)
# ---- page
@receiver(signals.post_save, sender=Page)
def page_post_save(sender, instance, created, *args, **kwargs):
if not created and instance.cover and "raw" not in kwargs:
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 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()
# ---- diffusion
@receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs):
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`"""

View File

@ -169,6 +169,9 @@
.preview .headings a:hover {
color: var(--heading-link-hv-fg) !important;
}
.preview.tiny .title {
font-size: calc(var(--preview-title-sz) * 0.8);
}
.preview.tiny .content {
font-size: 1rem;
}
@ -256,6 +259,7 @@
}
.list-item .actions {
text-align: right;
align-items: center;
}
.list-item:not(.wide) .media {
padding: 0.6rem;
@ -9689,6 +9693,26 @@ a.tag:hover {
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 {
display: grid;
grid-template-columns: 1fr 1fr;
@ -9819,6 +9843,13 @@ a.tag:hover {
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) {
border: none;
background-color: rgba(0, 0, 0, 0);

View File

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

View File

@ -86,9 +86,9 @@ Usefull context:
{% endspaceless %}
{% block header-container %}
{% if page or cover or title %}
<header class="container header preview preview-header {% if cover %}has-cover{% endif %}">
{% block header %}
{% spaceless %}
<figure class="header-cover">
{% block header-cover %}
{% if cover %}
@ -96,6 +96,7 @@ Usefull context:
{% endif %}
{% endblock %}
</figure>
{% endspaceless %}
<div class="headings preview-card-headings">
{% block headings %}
<div>
@ -119,7 +120,6 @@ Usefull context:
</div>
{% endblock %}
</header>
{% endif %}
{% endblock %}
{% block content-container %}
@ -144,15 +144,6 @@ Usefull context:
{{ render }}
{% endfor %}
{% 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 %}
{% 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-content">
{% block user-menu %}
<a class="dropdown-item" href="{% url "dashboard" %}">
{% translate "Dashboard" %}
</a>
{% endblock %}
{% if user.is_admin %}
{% 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" %}
</a>
{% endblock %}

View File

@ -12,7 +12,7 @@
{% if object.podcasts %}
{% spaceless %}
<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"
name="{{ page.title }}"
list-class="menu-list" item-class="menu-item"
@ -25,7 +25,7 @@
{% if tracks %}
<section class="container">
<h3 class="title">{% translate "Playlist" %}</h3>
<h2 class="title is-2">{% translate "Playlist" %}</h2>
<table class="table is-hoverable is-fullwidth">
<thead>
<tr>

View File

@ -8,7 +8,7 @@
<hr/>
{% include "./dashboard/tracklist_editor.html" with formset=tracklist_formset formset_data=tracklist_formset_data %}
<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 %}
</template>
</a-episode>

View File

@ -37,7 +37,7 @@ Context:
{% if related_objects %}
<section class="container">
{% 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 %}
{% endwith %}
@ -48,7 +48,7 @@ Context:
{% block comments %}
{% if comments %}
<section class="container">
<h2 class="title">{% translate "Comments" %}</h2>
<h2 class="title is-2">{% translate "Comments" %}</h2>
{% for object in comments %}
{% page_widget "item" object %}
@ -58,7 +58,7 @@ Context:
{% if comment_form %}
<section class="container">
<h2 class="title">{% translate "Post a comment" %}</h2>
<h2 class="title is-2">{% translate "Post a comment" %}</h2>
<form method="POST">
{% csrf_token %}
{% render_honeypot_field "website" %}

View File

@ -2,20 +2,8 @@
{% comment %}Detail page of a show{% endcomment %}
{% 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 %}
{% with schedules=program.schedule_set.all %}
{% with schedules=object.schedule_set.all %}
{% if schedules %}
<header class="container schedules">
{% for schedule in schedules %}
@ -49,7 +37,7 @@
{% if episodes %}
<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") %}
</section>
{% endif %}
@ -57,7 +45,7 @@
{% if articles %}
<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") %}
</section>
{% 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,14 +3,8 @@
{% block outer %}
{% with diffusion.is_now as is_active %}
{% if admin %}
{% with object|admin_url:"change" as url %}
{{ block.super }}
{% endwith %}
{% else %}
{{ block.super }}
{% endif %}
{% endwith %}
{% endblock %}
{% block subtitle %}
@ -29,10 +23,10 @@
{% endblock %}
{% block actions %}
{{ block.super }}
{% 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">
@ -54,8 +48,16 @@
<span class="tag is-warning">
{{ diffusion.get_type_display }}</span>
{% endif %}
</div>
{{ block.super }}
</div>
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}
{% block actions %}
{{ block.super }}
{% if object.sound_set.count %}
<button class="button action" @click="player.playButtonClick($event)"
data-sounds="{{ object.podcasts|json }}">
@ -65,4 +67,5 @@
<label>{% translate "Listen" %}</label>
</button>
{% endif %}
{% endblock %}

View File

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

View File

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

View File

@ -7,6 +7,10 @@ Content related context:
- subtitle: subtitle
- content: content to display
Components:
- no_cover: don't show cover
- no_content: don't show content
Styling related context:
- is_active: add "active" css class
- is_small: add "small" css class
@ -18,7 +22,7 @@ Styling related context:
{% endcomment %}
{% 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 headings-container %}
<header class="headings{% block headings-class %}{% endblock %}"{% block headings-tag-extra %}{% endblock %}>
@ -40,7 +44,7 @@ Styling related context:
{% block content-container %}
<section class="content headings-container">
{% block content %}
{% if content and with_content %}
{% if content and not no_content %}
{% autoescape off %}
{{ content|striptags|linebreaks }}
{% endautoescape %}
@ -50,9 +54,15 @@ Styling related context:
{% endblock %}
{% block actions-container %}
{% spaceless %}
<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>
{% endspaceless %}
{% endblock %}
{% endblock %}
</{{ 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 rest_framework.routers import DefaultRouter
from . import forms, models, views, viewsets
from . import models, views, viewsets
from .converters import DateConverter, PagePathConverter, WeekConverter
__all__ = ["api", "urls"]
@ -70,7 +70,7 @@ urls = [
name="page-list",
),
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),
name="page-list",
),
@ -98,47 +98,29 @@ urls = [
views.ProgramDetailView.as_view(),
name="program-detail",
),
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>/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>/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>/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>/publications"),
_("programs/<slug:parent_slug>/publications/"),
views.PageListView.as_view(model=models.Page, attach_to_value=models.StaticPage.Target.PAGES),
name="page-list",
),
# ---- episodes
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(
_("programs/episodes/<slug:slug>"),
_("programs/episodes/<slug:slug>/"),
views.EpisodeDetailView.as_view(),
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/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
path(
_("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"),
path("errors/no-station/", views.errors.NoStationErrorView.as_view(), name="errors-no-station"),
]

View File

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

View File

@ -9,6 +9,7 @@ __all__ = ("BaseView", "BaseAPIView")
class BaseView(TemplateResponseMixin, ContextMixin):
related_count = 4
related_carousel_count = 8
title = ""
@property
def station(self):
@ -58,8 +59,10 @@ class BaseView(TemplateResponseMixin, ContextMixin):
page = kwargs.get("page")
if page:
kwargs["title"] = page.display_title
kwargs["cover"] = page.cover and page.cover.url
kwargs.setdefault("title", page.display_title)
kwargs.setdefault("cover", page.cover and page.cover.url)
elif self.title:
kwargs.setdefault("title", self.title)
if "nav_menu" not in kwargs:
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 .mixins import VueFormDataMixin
from .page import PageListView
from .program import ProgramPageDetailView, BaseProgramMixin
from .page import PageUpdateView
from .page import PageDetailView, PageListView, PageUpdateView
__all__ = (
@ -18,7 +16,7 @@ __all__ = (
)
class EpisodeDetailView(ProgramPageDetailView):
class EpisodeDetailView(PageDetailView):
model = Episode
def get_context_data(self, **kwargs):
@ -47,14 +45,14 @@ class PodcastListView(EpisodeListView):
queryset = Episode.objects.published().with_podcasts().order_by("-pub_date")
class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, BaseProgramMixin, PageUpdateView):
class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
model = Episode
form_class = forms.EpisodeForm
template_name = "aircox/episode_form.html"
def test_func(self):
program = self.get_object().program
return self.request.user.has_perm("aircox.%s" % program.change_permission_codename)
obj = self.get_object().parent_subclass
return obj.can_update(self.request.user)
def get_tracklist_queryset(self, episode):
return Track.objects.filter(episode=episode).order_by("position")

View File

@ -54,7 +54,7 @@ class HomeView(AttachedToMixin, BaseView, ListView):
parents = set()
items = []
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:
continue
items.append(publication)

View File

@ -33,7 +33,7 @@ class BasePageMixin:
if page:
if getattr(page, "category_id", None):
return page.category
if page.parent_id:
if getattr(page, "parent_id", None):
return self.get_category(page.parent_subclass)
if slug := self.kwargs.get("category_slug"):
return Category.objects.get(slug=slug)
@ -160,8 +160,8 @@ class PageDetailView(BasePageDetailView):
kwargs["comment_form"] = self.get_comment_form()
kwargs["comments"] = Comment.objects.filter(page=self.object).order_by("-date")
if self.object.parent_subclass:
kwargs["parent"] = self.object.parent_subclass
if parent_subclass := getattr(self.object, "parent_subclass", None):
kwargs["parent"] = parent_subclass
if "related_objects" not in kwargs:
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 aircox.forms import ProgramForm
from aircox.models import Article, Episode, Page, Program, StaticPage
from .mixins import ParentMixin
from aircox.models import Article, Episode, Program, StaticPage
from .page import PageDetailView, PageListView, PageUpdateView
__all__ = ["ProgramPageDetailView", "ProgramDetailView", "ProgramPageListView"]
__all__ = (
"ProgramDetailView",
"ProgramDetailView",
)
class BaseProgramMixin:
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):
class ProgramDetailView(PageDetailView):
model = Program
def get_related_queryset(self):
@ -52,18 +44,6 @@ class ProgramDetailView(BaseProgramMixin, PageDetailView):
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):
model = Program
attach_to_value = StaticPage.Target.PROGRAMS
@ -72,22 +52,13 @@ class ProgramListView(PageListView):
return super().get_queryset().order_by("title")
# FIXME: not used
class ProgramPageDetailView(BaseProgramMixin, ParentMixin, PageDetailView):
"""Base view class for a page that is displayed as a program's child
page."""
class ProgramUpdateView(UserPassesTestMixin, PageUpdateView):
model = Program
form_class = ProgramForm
parent_model = Program
def test_func(self):
obj = self.get_object()
return obj.can_update(self.request.user)
def get_program(self):
self.parent = self.object.program
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
def get_success_url(self):
return reverse("program-detail", kwargs={"slug": self.get_object().slug})

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 {
.content {
font-size: v.$text-size;
}
.title { font-size: calc(var(--preview-title-sz) * 0.8); }
.content { font-size: v.$text-size; }
}
}
@ -367,6 +366,7 @@
.actions {
text-align: right;
align-items: center;
}
&:not(.wide) .media {

View File

@ -54,7 +54,16 @@
.height-20 { height: 20em; }
.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 {
display: grid;
grid-template-columns: 1fr 1fr;
@ -70,7 +79,7 @@
.grid-2 { @include grid; @include grid-2; }
.grid-3 { @include grid; @include grid-3; }
// ---- flex
// ---- ---- flex
.flex-row { display: flex; flex-direction: row }
.flex-column { display: flex; flex-direction: column }
.flex-grow-0 { flex-grow: 0 !important; }
@ -124,3 +133,14 @@
background-color: v.$red !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);
}
}