diff --git a/aircox/admin/episode.py b/aircox/admin/episode.py
index a8570a7..3a6fa67 100644
--- a/aircox/admin/episode.py
+++ b/aircox/admin/episode.py
@@ -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]
diff --git a/aircox/admin/page.py b/aircox/admin/page.py
index eb8c075..3a278bc 100644
--- a/aircox/admin/page.py
+++ b/aircox/admin/page.py
@@ -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('
'.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",)
diff --git a/aircox/forms.py b/aircox/forms.py
index a939e5d..ac6ae04 100644
--- a/aircox/forms.py
+++ b/aircox/forms.py
@@ -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):
diff --git a/aircox/migrations/0015_program_editors.py b/aircox/migrations/0015_program_editors.py
index 9a3964f..2c921b0 100644
--- a/aircox/migrations/0015_program_editors.py
+++ b/aircox/migrations/0015_program_editors.py
@@ -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,
diff --git a/aircox/migrations/0022_set_group_ownership.py b/aircox/migrations/0022_set_group_ownership.py
deleted file mode 100644
index 7e0d3bd..0000000
--- a/aircox/migrations/0022_set_group_ownership.py
+++ /dev/null
@@ -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),
- ]
diff --git a/aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py b/aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py
index 6a9b7ad..dba7c62 100644
--- a/aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py
+++ b/aircox/migrations/0023_station_legal_label_alter_schedule_timezone.py
@@ -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 = [
diff --git a/aircox/migrations/0027_remove_page_parent_remove_staticpage_parent_and_more.py b/aircox/migrations/0027_remove_page_parent_remove_staticpage_parent_and_more.py
new file mode 100644
index 0000000..a927021
--- /dev/null
+++ b/aircox/migrations/0027_remove_page_parent_remove_staticpage_parent_and_more.py
@@ -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),
+ ]
diff --git a/aircox/models/article.py b/aircox/models/article.py
index 0c86d77..c75387c 100644
--- a/aircox/models/article.py
+++ b/aircox/models/article.py
@@ -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"
diff --git a/aircox/models/diffusion.py b/aircox/models/diffusion.py
index c04d1c0..d9beaff 100644
--- a/aircox/models/diffusion.py
+++ b/aircox/models/diffusion.py
@@ -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
diff --git a/aircox/models/episode.py b/aircox/models/episode.py
index b7e666b..59e2caf 100644
--- a/aircox/models/episode.py
+++ b/aircox/models/episode.py
@@ -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")
diff --git a/aircox/models/page.py b/aircox/models/page.py
index 0581fd1..84b1ade 100644
--- a/aircox/models/page.py
+++ b/aircox/models/page.py
@@ -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,22 +269,14 @@ 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:
- self.category = self.parent.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)
diff --git a/aircox/models/program.py b/aircox/models/program.py
index 85c1f06..f9b8ec4 100644
--- a/aircox/models/program.py
+++ b/aircox/models/program.py
@@ -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()
- permission, _ = Permission.objects.get_or_create(
- codename=self.change_permission_codename,
- content_type=ContentType.objects.get_for_model(self),
- defaults={"name": f"change program {self.title}"},
- )
- if permission not in editors.permissions.all():
- editors.permissions.add(permission)
+ if not self.pk:
+ self.save(check_groups=False)
+ permission, _ = Permission.objects.get_or_create(
+ codename=self._perm_update_codename.format(self=self),
+ content_type=ContentType.objects.get_for_model(self),
+ defaults={"name": self._perm_update_name.format(self=self)},
+ )
+ 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
diff --git a/aircox/models/signals.py b/aircox/models/signals.py
index b9d5dd4..b465310 100755
--- a/aircox/models/signals.py
+++ b/aircox/models/signals.py
@@ -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`"""
diff --git a/aircox/static/aircox/css/chunk-common.css b/aircox/static/aircox/css/chunk-common.css
index ae6fbdf..a12a3f9 100644
--- a/aircox/static/aircox/css/chunk-common.css
+++ b/aircox/static/aircox/css/chunk-common.css
@@ -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);
diff --git a/aircox/static/aircox/css/public.css b/aircox/static/aircox/css/public.css
index 76f16c6..d54bbf6 100644
--- a/aircox/static/aircox/css/public.css
+++ b/aircox/static/aircox/css/public.css
@@ -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;
diff --git a/aircox/templates/aircox/base.html b/aircox/templates/aircox/base.html
index 5e42e48..9416902 100644
--- a/aircox/templates/aircox/base.html
+++ b/aircox/templates/aircox/base.html
@@ -86,9 +86,9 @@ Usefull context:
{% endspaceless %}
{% block header-container %}
- {% if page or cover or title %}