Compare commits

...

140 Commits

Author SHA1 Message Date
a2a399e531 manage program editors 2024-04-22 23:54:44 +02:00
b28105c659 vue 3.4 2024-04-21 23:50:00 +02:00
07d72d799d rm file 2024-04-19 15:06:23 +02:00
1d321a0de6 imrpove statistics rendering + filters 2024-04-12 16:49:05 +02:00
8a6f72ca83 imrpove statistics rendering + filters 2024-04-12 16:36:05 +02:00
0ee72f30c5 integrate statistics 2024-04-12 15:48:55 +02:00
7841fed17d preview.tiny; dashboard list order 2024-04-11 00:33:05 +02:00
1bd4e03f02 add missing files; improve dashboard; rewrite urls 2024-04-11 00:28:43 +02:00
a24318bc84 #137: Sound et EpisodeSound, dashboard UI improvements (into #121) (#138)
#137

Deployment: **Upgrade to Liquidsoap 2.4**: code has been adapted to work with liquidsoap 2.4

Co-authored-by: bkfox <thomas bkfox net>
Reviewed-on: #138
2024-04-05 18:45:15 +02:00
bda4efe336 rows fix 2024-03-26 01:19:20 +01:00
c3c748eebb delete sound in list 2024-03-26 00:36:51 +01:00
3fb9e0d62a make EpisodeUpdateView work 2024-03-25 23:50:08 +01:00
8d4b4c5896 make EpisodeUpdateView work 2024-03-25 23:48:25 +01:00
1f716891ac work on sound list 2024-03-25 18:07:38 +01:00
70a55607a5 work on sound list 2024-03-25 18:05:55 +01:00
f41cc3ce0c work on playlist & tracklist 2024-03-23 17:22:52 +01:00
21f856e731 page_form: some fields horizontal 2024-03-20 01:55:36 +01:00
d293eb4a00 rename playlist-editor to tracklist-editor; refactor player 2024-03-20 01:42:01 +01:00
3ad886764c upload selector improvements 2024-03-17 21:14:26 +01:00
024db5f307 upload selector improvements 2024-03-17 21:00:07 +01:00
de858f45e8 work on episode form 2024-03-16 22:37:56 +01:00
eaf453086d cover into modal; add Dashboard js app 2024-03-16 08:06:32 +01:00
3c56dc8b53 fix assets 2024-03-16 07:24:12 +01:00
44b9a608ee work on page form; add image selector 2024-03-16 06:02:23 +01:00
eb5bdcf167 work on page form; add image selector 2024-03-16 06:00:15 +01:00
c74ec6fb16 Merge branch 'dev-1.0-118-design' into dev-1.0-121 2024-03-15 20:54:56 +01:00
c79f040fa1 fix 2024-03-15 20:54:52 +01:00
7cdf44b901 templates: always display profile link 2024-03-14 09:25:27 +01:00
dff7b1cf8c update translations 2024-03-14 09:22:03 +01:00
f55d747034 templates/episode_form: add an horizontal rule 2024-03-14 09:04:30 +01:00
37ecf9875b templates/edit-link: display detail view link; use small font size 2024-03-14 08:59:39 +01:00
f8401c76e3 templates/edit-link: use fontawesome icons 2024-03-14 08:54:51 +01:00
306eb20257 docs: update user manual with simplified program management for animators 2024-02-28 17:19:21 +01:00
5ae85083a5 db: create program editors groups 2024-02-28 17:19:21 +01:00
1ac83f1066 db: add missing migration on schedule timezone 2024-02-28 17:19:21 +01:00
7e0e6e9652 episode-form: add tracks inline formset 2024-02-28 17:19:21 +01:00
ab1b152a46 templatetags: display edit-links for admins 2024-02-28 17:19:21 +01:00
8821cd86c6 templates: update after merging branch 118-design 2024-02-28 17:19:21 +01:00
4ea93c9eff templates: add in-context edition links 2024-02-28 17:19:21 +01:00
40ca2064d9 db: migrations merge 2024-02-28 17:19:21 +01:00
e840fbabac templates: update container block names 2024-02-28 17:19:21 +01:00
ff2a8ff6d4 signals: disable schedule_pre_save when using loaddata 2024-02-28 17:19:21 +01:00
d33256edb8 templates: set document type to html, prevent quicks mode 2024-02-28 17:19:21 +01:00
10b9e9280f context_processors: prevent a null station error when no default station is defined 2024-02-28 17:19:21 +01:00
ee7f301f44 views/program: allow changing program cover 2024-02-28 17:19:21 +01:00
64984d69d5 misc: add a profile view for authenticated users 2024-02-28 17:19:21 +01:00
4f856c0705 misc: use the django authentication system 2024-02-28 17:19:21 +01:00
8b4da52760 misc: move station and audio_streams to context_processors (in order to have them available in accounts views) 2024-02-28 17:19:21 +01:00
aa171375e5 misc: edit programs in site 2024-02-28 17:19:18 +01:00
1674266890 templatetags: parametrize has_perm() in order to enable aircox namespace permissions 2024-02-28 15:17:38 +01:00
dd71f984ed models/program: link to editor groups 2024-02-28 15:17:31 +01:00
0ba0f8ae72 mobile device support 2024-02-12 23:28:59 +01:00
afc2e41bdb Merge branch 'dev-1.0-118-design' of git.radiocampus.be:rc/aircox into dev-1.0-118-design 2024-02-12 14:41:19 +01:00
bba4935791 grid and mobile 2024-02-12 14:41:09 +01:00
dab4146735 box shadow 2024-02-12 14:40:43 +01:00
1aababe2ae docs: update user manual with simplified program management for animators 2024-02-06 09:57:21 +01:00
0dd961e0bb db: create program editors groups 2024-02-06 09:57:20 +01:00
f9da318a38 db: add missing migration on schedule timezone 2024-02-06 09:57:20 +01:00
26fa426416 views: avoid failing on missing parent cover 2024-02-06 09:40:45 +01:00
71f4d2473e episode-form: add tracks inline formset 2024-02-06 09:40:45 +01:00
2e9ebaded2 templatetags: display edit-links for admins 2024-02-06 09:40:45 +01:00
c6a4196319 templates: update after merging branch 118-design 2024-02-06 09:40:37 +01:00
be224d0efb templatetags: return on none type object 2024-02-05 10:29:58 +01:00
89f80ad103 templates: add in-context edition links 2024-02-05 10:29:58 +01:00
6d556fcd5d db: migrations merge 2024-02-05 10:29:55 +01:00
4201d50f4b templates: update container block names 2024-02-05 10:24:48 +01:00
6c942f36fa templatetags: avoid failing on nav_items when no station is defined 2024-02-05 10:24:48 +01:00
d51b9ee58b signals: disable schedule_pre_save when using loaddata 2024-02-05 10:24:48 +01:00
1a27ae2a76 misc: add in-site episode management for animators 2024-02-05 10:24:46 +01:00
e5862ee59b templates: set document type to html, prevent quicks mode 2024-02-05 10:22:16 +01:00
8f88b15536 ProgramUpdateView: use ckeditor RichTextField 2024-02-05 10:22:16 +01:00
10dfe3811b context_processors: prevent a null station error when no default station is defined 2024-02-05 10:22:16 +01:00
f71c201020 views/program: allow changing program cover 2024-02-05 10:22:16 +01:00
0812f3a0a1 misc: add a profile view for authenticated users 2024-02-05 10:22:14 +01:00
269b29b2c1 aircox/conf: user cannot edit all programs/episode 2024-02-05 10:19:05 +01:00
ad2ed17c34 misc: use the django authentication system 2024-02-05 10:19:05 +01:00
9db69580e0 misc: move station and audio_streams to context_processors (in order to have them available in accounts views) 2024-02-05 10:19:05 +01:00
4ead6b154b misc: edit programs in site 2024-02-05 10:19:05 +01:00
811cc97e07 templatetags: parametrize has_perm() in order to enable aircox namespace permissions 2024-02-05 10:19:05 +01:00
b794e24d0c models/program: link to editor groups 2024-02-05 10:19:05 +01:00
df41885cca various fixes 2024-02-02 19:36:02 +01:00
2a75608701 admin rendering 2024-02-01 20:01:57 +01:00
e1cf455384 page glitch 2024-02-01 19:45:14 +01:00
93e286fa62 music stream 2024-02-01 19:31:30 +01:00
e3966ca5cb logs 2024-02-01 18:15:24 +01:00
c335ed9fb9 logs 2024-02-01 17:58:42 +01:00
ad90255570 design 2024-02-01 17:29:35 +01:00
cab6cacd0b design / mockups 2024-01-31 20:24:38 +01:00
1475a80316 fixes 2024-01-30 20:19:44 +01:00
b9148933f4 rendering 2024-01-27 19:24:24 +01:00
5bb52a9d67 rendering 2024-01-27 18:56:05 +01:00
8cf57c07b2 rendering 2024-01-26 22:43:16 +01:00
20aa3aba9d rendering 2024-01-26 22:28:08 +01:00
d53cb3e935 missing file 2024-01-26 21:56:56 +01:00
25ceacdff9 work on main design & layout 2024-01-26 21:55:43 +01:00
0adcacf375 merge upstream 2024-01-24 16:20:18 +01:00
c31d776504 breadcrumbs; urls 2024-01-24 16:13:35 +01:00
69b77a675b breadcrumbs 2024-01-24 16:13:02 +01:00
ac9b3c8ede rendering styles 2024-01-16 15:37:04 +01:00
825ed03dbd rendering styles; view order; i18n 2024-01-16 14:36:09 +01:00
561914ee78 bkp 2024-01-09 20:05:39 +01:00
ccea2a5ea6 player link; page rendering 2024-01-05 19:17:10 +01:00
c52e87acd2 home page fixes; various issues fix 2024-01-05 16:23:23 +01:00
294c848415 player button & playlist header fix; timetable order 2024-01-03 19:51:39 +01:00
1f6381bf07 migration files 2023-12-13 17:27:42 +01:00
73d8ff32d5 fix bug & remove dynamic 2023-12-12 21:24:21 +01:00
46a9008cda navigation & breadcrumbs 2023-12-12 20:07:58 +01:00
eaa1e2412a page headers, various fixes, responsive 2023-12-11 23:29:49 +01:00
a3c21c64ed fix integration into admin interface 2023-12-10 15:47:04 +01:00
0e444f0502 fix integration into admin interface 2023-12-10 15:21:30 +01:00
4778803ee0 page headers 2023-12-01 20:50:28 +01:00
9c3eaf05c7 page headers 2023-12-01 20:49:34 +01:00
f05e47af1c page headers 2023-12-01 20:43:12 +01:00
1de9548111 player shadow 2023-11-29 15:45:22 +01:00
8202a9324c responsive menus 2023-11-29 15:41:15 +01:00
f5ce00795e page loader 2023-11-29 02:05:14 +01:00
4e04cfae7e add pdocasts 2023-11-28 02:36:24 +01:00
d2ed8df2ac add pdocasts 2023-11-28 02:22:58 +01:00
712ab223ba add pdocasts 2023-11-28 02:16:40 +01:00
ed9affbef6 carousel, display logs 2023-11-28 01:23:56 +01:00
cb5a6a3ee8 carousel, display logs 2023-11-28 01:04:39 +01:00
bc697bd4bd clean-up css; related publications; pagination 2023-11-26 21:35:37 +01:00
d075fecbce attach static page to page-list 2023-11-24 22:11:55 +01:00
0c07586787 player: progress bar position 2023-11-24 21:56:58 +01:00
9661e98a70 player: progress bar position 2023-11-24 21:39:20 +01:00
69d77e1d0c player: progress bar position 2023-11-24 21:27:59 +01:00
62ada47352 podcasts & player 2023-11-24 20:46:56 +01:00
474016f776 merge develop-1.0 2023-11-22 21:17:37 +01:00
6a21a9d094 design 2023-11-22 21:09:59 +01:00
b4c12def13 work on templates 2023-11-22 17:33:51 +01:00
36ae12af3d fix: static 2023-11-02 22:10:41 +01:00
0a86d4e0a3 add statics 2023-11-02 22:09:49 +01:00
a53aebb5b8 rm static 2023-11-02 22:07:27 +01:00
1af0348c89 add vue files 2023-11-02 22:03:55 +01:00
8ab8ef5b1c add missing files 2023-11-02 21:59:30 +01:00
bf9da835b2 add missing files 2023-11-02 21:58:13 +01:00
7b28149d7e add radiocampus app 2023-11-02 21:56:22 +01:00
87a2ee5a45 feat: work on date menu 2023-11-02 21:54:15 +01:00
ab231e9a89 work on design 2023-10-27 21:09:58 +02:00
1661601caf work on design: items, components 2023-10-24 18:29:34 +02:00
240 changed files with 30610 additions and 14630 deletions

View File

@ -30,9 +30,9 @@ class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin):
end_date.short_description = _("end")
list_display = ("episode", "start_date", "end_date", "type", "initial")
list_display = ("episode", "start", "end", "type", "initial")
list_filter = ("type", "start", "program")
list_editable = ("type",)
list_editable = ("type", "start", "end")
ordering = ("-start", "id")
fields = ("type", "start", "end", "initial", "program", "schedule")

View File

@ -2,9 +2,9 @@ from adminsortable2.admin import SortableAdminBase
from django.contrib import admin
from django.forms import ModelForm
from aircox.models import Episode
from .page import PageAdmin
from .sound import SoundInline, TrackInline
from aircox.models import Episode, EpisodeSound
from .page import ChildPageAdmin
from .sound import TrackInline
from .diffusion import DiffusionInline
@ -15,17 +15,17 @@ 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, SoundInline, DiffusionInline]
inlines = [TrackInline, DiffusionInline]
def add_view(self, request, object_id, form_url="", context=None):
context = context or {}
@ -38,3 +38,8 @@ class EpisodeAdmin(SortableAdminBase, PageAdmin):
context["init_app"] = True
context["init_el"] = "#inline-tracks"
return super().change_view(request, object_id, form_url, context)
@admin.register(EpisodeSound)
class EpisodeSoundAdmin(admin.ModelAdmin):
list_display = ("episode", "sound", "broadcast")

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",
],
},
),
]
@ -50,24 +52,9 @@ class BasePageAdmin(admin.ModelAdmin):
change_form_template = "admin/aircox/page_change_form.html"
def cover_thumb(self, obj):
return mark_safe('<img src="{}"/>'.format(obj.cover.icons["64"])) if obj.cover else ""
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)
if obj.cover and obj.cover.thumbnails:
return mark_safe('<img src="{}"/>'.format(obj.cover.icons["64"]))
return ""
def add_view(self, request, form_url="", extra_context=None):
filters = QueryDict(request.GET.get("_changelist_filters", ""))
@ -86,15 +73,40 @@ 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",)
list_editable = BasePageAdmin.list_editable + ("attach_to",)
fieldsets = deepcopy(BasePageAdmin.fieldsets)
fieldsets[1][1]["fields"] += ("attach_to",)

View File

@ -25,15 +25,16 @@ class SoundTrackInline(TrackInline):
class SoundInline(admin.TabularInline):
model = Sound
fields = [
"type",
"name",
"audio",
"duration",
"broadcast",
"is_good_quality",
"is_public",
"is_downloadable",
"is_removed",
]
readonly_fields = ["type", "audio", "duration", "is_good_quality"]
readonly_fields = ["broadcast", "audio", "duration", "is_good_quality"]
extra = 0
max_num = 0
@ -52,20 +53,20 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
list_display = [
"id",
"name",
"related",
"type",
# "related",
"broadcast",
"duration",
"is_public",
"is_good_quality",
"is_downloadable",
"audio",
]
list_filter = ("type", "is_good_quality", "is_public")
list_filter = ("broadcast", "is_good_quality", "is_public")
list_editable = ["name", "is_public", "is_downloadable"]
search_fields = ["name", "program__title"]
fieldsets = [
(None, {"fields": ["name", "file", "type", "program", "episode"]}),
(None, {"fields": ["name", "file", "broadcast", "program", "episode"]}),
(
None,
{
@ -79,21 +80,19 @@ class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
},
),
]
readonly_fields = ("file", "duration", "type")
readonly_fields = ("file", "duration", "is_removed")
inlines = [SoundTrackInline]
def related(self, obj):
# TODO: link to episode or program edit
return obj.episode.title if obj.episode else obj.program.title if obj.program else ""
# # TODO: link to episode or program edit
return obj.program.title if obj.program else ""
related.short_description = _("Program / Episode")
# return obj.episode.title if obj.episode else obj.program.title if obj.program else ""
related.short_description = _("Program")
def audio(self, obj):
return (
mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url))
if obj.type != Sound.TYPE_REMOVED
else ""
)
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) if not obj.is_removed else ""
audio.short_description = _("Audio")

View File

@ -86,8 +86,8 @@ class Settings(BaseSettings):
# TODO include content_type in order to avoid clash with potential
# extra applications
# aircox
"change_program",
"change_episode",
"view_program",
"view_episode",
"change_diffusion",
"add_comment",
"change_comment",
@ -140,7 +140,7 @@ class Settings(BaseSettings):
"""In days, minimal age of a log before it is archived."""
# --- Sounds
SOUND_ARCHIVES_SUBDIR = "archives"
SOUND_BROADCASTS_SUBDIR = "archives"
"""Sub directory used for the complete episode sounds."""
SOUND_EXCERPTS_SUBDIR = "excerpts"
"""Sub directory used for the excerpts of the episode."""

View File

@ -0,0 +1,4 @@
def station(request):
station = request.station
audio_streams = station.streams if station else None
return {"station": station, "audio_streams": audio_streams}

View File

@ -21,23 +21,18 @@ parameters given by the setting SOUND_QUALITY. This script requires
Sox (and soxi).
"""
import logging
import os
import re
from datetime import date
import mutagen
from django.conf import settings as conf
from django.utils import timezone as tz
from django.utils.translation import gettext as _
from aircox import utils
from aircox.models import Program, Sound, Track
from aircox.models import Program, Sound, EpisodeSound
from .playlist_import import PlaylistImport
logger = logging.getLogger("aircox.commands")
__all__ = ("SoundFile",)
class SoundFile:
"""Handle synchronisation between sounds on files and database."""
@ -61,153 +56,40 @@ class SoundFile:
def sync(self, sound=None, program=None, deleted=False, keep_deleted=False, **kwargs):
"""Update related sound model and save it."""
if deleted:
return self._on_delete(self.path, keep_deleted)
self.sound = self._on_delete(self.path, keep_deleted)
return self.sound
# FIXME: sound.program as not null
if not program:
program = Program.get_from_path(self.path)
logger.debug('program from path "%s" -> %s', self.path, program)
kwargs["program_id"] = program.pk
program = sound and sound.program or Program.get_from_path(self.path)
if program:
kwargs["program_id"] = program.pk
if sound:
created = False
else:
created = False
if not sound:
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs)
self.sound = sound
self.path_info = self.read_path(self.path)
sound.program = program
if created or sound.check_on_file():
sound.name = self.path_info.get("name")
self.info = self.read_file_info()
if self.info is not None:
sound.duration = utils.seconds_to_time(self.info.info.length)
# check for episode
if sound.episode is None and "year" in self.path_info:
sound.episode = self.find_episode(sound, self.path_info)
sound.sync_fs(on_update=True, find_playlist=True)
sound.save()
# check for playlist
self.find_playlist(sound)
if not sound.episodesound_set.all().exists():
self.create_episode_sound(sound)
return sound
def create_episode_sound(self, sound):
episode = sound.find_episode()
if episode:
# FIXME: position from name
item = EpisodeSound(
episode=episode, sound=sound, position=episode.episodesound_set.all().count(), broadcast=sound.broadcast
)
item.save()
def _on_delete(self, path, keep_deleted):
# TODO: remove from db on delete
sound = None
if keep_deleted:
sound = Sound.objects.path(self.path).first()
if sound:
if keep_deleted:
sound.type = sound.TYPE_REMOVED
sound.check_on_file()
sound.save()
return sound
else:
Sound.objects.path(self.path).delete()
def read_path(self, path):
"""Parse path name returning dictionary of extracted info. It can
contain:
- `year`, `month`, `day`: diffusion date
- `hour`, `minute`: diffusion time
- `n`: sound arbitrary number (used for sound ordering)
- `name`: cleaned name extracted or file name (without extension)
"""
basename = os.path.basename(path)
basename = os.path.splitext(basename)[0]
reg_match = self._path_re.search(basename)
if reg_match:
info = reg_match.groupdict()
for k in ("year", "month", "day", "hour", "minute", "n"):
if info.get(k) is not None:
info[k] = int(info[k])
name = info.get("name")
info["name"] = name and self._into_name(name) or basename
else:
info = {"name": basename}
return info
_path_re = re.compile(
"^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
"(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
"(_(?P<n>[0-9]+))?"
"_?[ -]*(?P<name>.*)$"
)
def _into_name(self, name):
name = name.replace("_", " ")
return " ".join(r.capitalize() for r in name.split(" "))
def read_file_info(self):
"""Read file information and metadata."""
try:
if os.path.exists(self.path):
return mutagen.File(self.path)
except Exception:
pass
return None
def find_episode(self, sound, path_info):
"""For a given program, check if there is an initial diffusion to
associate to, using the date info we have. Update self.sound and save
it consequently.
We only allow initial diffusion since there should be no rerun.
"""
program, pi = sound.program, path_info
if "year" not in pi or not sound or sound.episode:
return None
year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
if pi.get("hour") is not None:
at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
at = tz.make_aware(at)
else:
at = date(year, month, day)
diffusion = program.diffusion_set.at(at).first()
if not diffusion:
return None
logger.debug("%s <--> %s", sound.file.name, str(diffusion.episode))
return diffusion.episode
def find_playlist(self, sound=None, use_meta=True):
"""Find a playlist file corresponding to the sound path, such as:
my_sound.ogg => my_sound.csv.
Use sound's file metadata if no corresponding playlist has been
found and `use_meta` is True.
"""
if sound is None:
sound = self.sound
if sound.track_set.count() > 1:
return
# import playlist
path_noext, ext = os.path.splitext(self.sound.file.path)
path = path_noext + ".csv"
if os.path.exists(path):
PlaylistImport(path, sound=sound).run()
# use metadata
elif use_meta:
if self.info is None:
self.read_file_info()
if self.info and self.info.tags:
tags = self.info.tags
title, artist, album, year = tuple(
t and ", ".join(t) for t in (tags.get(k) for k in ("title", "artist", "album", "year"))
)
title = title or (self.path_info and self.path_info.get("name")) or os.path.basename(path_noext)
info = "{} ({})".format(album, year) if album and year else album or year or ""
track = Track(
sound=sound,
position=int(tags.get("tracknumber", 0)),
title=title,
artist=artist or _("unknown"),
info=info,
)
track.save()
if sound := Sound.objects.path(self.path).first():
sound.is_removed = True
sound.save(sync=False)
elif sound := Sound.objects.path(self.path):
sound.delete()
return sound

View File

@ -105,8 +105,7 @@ class MoveTask(Task):
def __call__(self, event, **kw):
sound = Sound.objects.filter(file=event.src_path).first()
if sound:
kw["sound"] = sound
kw["path"] = event.src_path
kw = {**kw, "sound": sound, "path": event.src_path}
else:
kw["path"] = event.dest_path
return super().__call__(event, **kw)
@ -214,15 +213,15 @@ class SoundMonitor:
logger.info(f"#{program.id} {program.title}")
self.scan_for_program(
program,
settings.SOUND_ARCHIVES_SUBDIR,
settings.SOUND_BROADCASTS_SUBDIR,
logger=logger,
type=Sound.TYPE_ARCHIVE,
broadcast=True,
)
self.scan_for_program(
program,
settings.SOUND_EXCERPTS_SUBDIR,
logger=logger,
type=Sound.TYPE_EXCERPT,
broadcast=False,
)
dirs.append(program.abspath)
return dirs
@ -234,12 +233,12 @@ class SoundMonitor:
if not program.ensure_dir(subdir):
return
subdir = os.path.join(program.abspath, subdir)
abs_subdir = os.path.join(program.abspath, subdir)
sounds = []
# sounds in directory
for path in os.listdir(subdir):
path = os.path.join(subdir, path)
for path in os.listdir(abs_subdir):
path = os.path.join(abs_subdir, path)
if not path.endswith(settings.SOUND_FILE_EXT):
continue
@ -248,14 +247,14 @@ class SoundMonitor:
sounds.append(sound_file.sound.pk)
# sounds in db & unchecked
sounds = Sound.objects.filter(file__startswith=subdir).exclude(pk__in=sounds)
sounds = Sound.objects.filter(file__startswith=program.path).exclude(pk__in=sounds)
self.check_sounds(sounds, program=program)
def check_sounds(self, qs, **sync_kwargs):
"""Only check for the sound existence or update."""
# check files
for sound in qs:
if sound.check_on_file():
if sound.sync_fs(on_update=True):
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
_running = False
@ -267,15 +266,15 @@ class SoundMonitor:
"""Run in monitor mode."""
with futures.ThreadPoolExecutor() as pool:
archives_handler = MonitorHandler(
settings.SOUND_ARCHIVES_SUBDIR,
settings.SOUND_BROADCASTS_SUBDIR,
pool,
type=Sound.TYPE_ARCHIVE,
broadcast=True,
logger=logger,
)
excerpts_handler = MonitorHandler(
settings.SOUND_EXCERPTS_SUBDIR,
pool,
type=Sound.TYPE_EXCERPT,
broadcast=False,
logger=logger,
)

View File

@ -1,16 +1,29 @@
import django_filters as filters
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
import django_filters as filters
from .models import Episode, Page
from . import models
__all__ = (
"PageFilters",
"EpisodeFilters",
"ImageFilterSet",
"SoundFilterSet",
"TrackFilterSet",
"UserFilterSet",
"UserGroupFilterSet",
)
class PageFilters(filters.FilterSet):
q = filters.CharFilter(method="search_filter", label=_("Search"))
class Meta:
model = Page
model = models.Page
fields = {
"category__id": ["in"],
"category__id": ["in", "exact"],
"pub_date": ["exact", "gte", "lte"],
}
@ -22,10 +35,62 @@ class EpisodeFilters(PageFilters):
podcast = filters.BooleanFilter(method="podcast_filter", label=_("Podcast"))
class Meta:
model = Episode
model = models.Episode
fields = PageFilters.Meta.fields.copy()
def podcast_filter(self, queryset, name, value):
if value:
return queryset.filter(sound__is_public=True).distinct()
return queryset.filter(sound__isnull=True)
class ImageFilterSet(filters.FilterSet):
search = filters.CharFilter(field_name="search", method="search_filter")
def search_filter(self, queryset, name, value):
return queryset.filter(original_filename__icontains=value)
class SoundFilterSet(filters.FilterSet):
station = filters.NumberFilter(field_name="program__station__id")
program = filters.NumberFilter(field_name="program_id")
# episode = filters.NumberFilter(field_name="episode_id")
search = filters.CharFilter(field_name="search", method="search_filter")
class Meta:
model = models.Sound
fields = {
# "episode": ["in", "exact", "isnull"],
}
def search_filter(self, queryset, name, value):
return queryset.search(value)
class TrackFilterSet(filters.FilterSet):
artist = filters.CharFilter(field_name="artist", lookup_expr="icontains")
album = filters.CharFilter(field_name="album", lookup_expr="icontains")
title = filters.CharFilter(field_name="title", lookup_expr="icontains")
class UserFilterSet(filters.FilterSet):
search = filters.CharFilter(field_name="search", method="search_filter")
in_group = filters.NumberFilter(field_name="in_group", method="in_group_filter")
not_in_group = filters.NumberFilter(field_name="not_in_group", method="not_in_group_filter")
def in_group_filter(self, queryset, name, value):
return queryset.filter(groups__in=[value])
def not_in_group_filter(self, queryset, name, value):
return queryset.exclude(groups__in=[value])
def search_filter(self, queryset, name, value):
return queryset.filter(
Q(username__icontains=value) | Q(first_name__icontains=value) | Q(last_name__icontains=value)
)
class UserGroupFilterSet(filters.FilterSet):
class Meta:
model = User.groups.through
fields = ["group", "user"]

View File

@ -1,18 +0,0 @@
from django import forms
from django.forms import ModelForm
from .models import Comment
class CommentForm(ModelForm):
nickname = forms.CharField()
email = forms.EmailField(required=False)
content = forms.CharField(widget=forms.Textarea())
nickname.widget.attrs.update({"class": "input"})
email.widget.attrs.update({"class": "input"})
content.widget.attrs.update({"class": "textarea"})
class Meta:
model = Comment
fields = ["nickname", "email", "content"]

21
aircox/forms/__init__.py Normal file
View File

@ -0,0 +1,21 @@
from . import widgets
from .episode import EpisodeForm, EpisodeSoundFormSet
from .program import ProgramForm
from .page import CommentForm, ImageForm, PageForm, ChildPageForm
from .sound import SoundForm, SoundCreateForm
__all__ = (
widgets,
# ---- forms
EpisodeForm,
EpisodeSoundFormSet,
ProgramForm,
CommentForm,
ImageForm,
PageForm,
ChildPageForm,
SoundForm,
SoundCreateForm,
)

34
aircox/forms/episode.py Normal file
View File

@ -0,0 +1,34 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
from .page import ChildPageForm
__all__ = ("EpisodeForm", "EpisodeSoundFormSet")
class EpisodeForm(ChildPageForm):
class Meta:
model = models.Episode
fields = ChildPageForm.Meta.fields
EpisodeSoundFormSet = modelformset_factory(
models.EpisodeSound,
fields=(
"position",
"episode",
"sound",
"broadcast",
),
widgets={
"broadcast": forms.CheckboxInput(),
"episode": forms.HiddenInput(),
# "sound": forms.HiddenInput(),
"position": forms.HiddenInput(),
},
can_delete=True,
extra=0,
)
"""Formset used in EpisodeUpdateView."""

37
aircox/forms/page.py Normal file
View File

@ -0,0 +1,37 @@
from django import forms
from aircox import models
__all__ = ("CommentForm", "ImageForm", "PageForm", "ChildPageForm")
class CommentForm(forms.ModelForm):
nickname = forms.CharField()
email = forms.EmailField(required=False)
content = forms.CharField(widget=forms.Textarea())
nickname.widget.attrs.update({"class": "input"})
email.widget.attrs.update({"class": "input"})
content.widget.attrs.update({"class": "textarea"})
class Meta:
model = models.Comment
fields = ["nickname", "email", "content"]
class ImageForm(forms.Form):
file = forms.ImageField()
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

11
aircox/forms/program.py Normal file
View File

@ -0,0 +1,11 @@
from aircox import models
from .page import PageForm
__all__ = ("ProgramForm",)
class ProgramForm(PageForm):
class Meta:
fields = PageForm.Meta.fields
model = models.Program

26
aircox/forms/sound.py Normal file
View File

@ -0,0 +1,26 @@
from django import forms
from aircox import models
__all__ = (
"SoundForm",
"SoundCreateForm",
)
class SoundForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"]
class SoundCreateForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"]
widgets = {"program": forms.HiddenInput()}

23
aircox/forms/track.py Normal file
View File

@ -0,0 +1,23 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
__all__ = ("TrackFormSet",)
TrackFormSet = modelformset_factory(
models.Track,
fields=[
"position",
"episode",
"artist",
"title",
"tags",
],
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
can_delete=True,
extra=0,
)
"""Track formset used in EpisodeUpdateView."""

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@ class AircoxMiddleware(object):
"""Middleware used to get default info for the given website.
It provide following request attributes:
- ``mobile``: set to True if mobile device is detected
- ``station``: current Station
This middleware must be set after the middleware
@ -24,6 +25,11 @@ class AircoxMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
def is_mobile(self, request):
if agent := request.META.get("HTTP_USER_AGENT"):
return " Mobi" in agent
return False
def get_station(self, request):
"""Return station for the provided request."""
host = request.get_host()
@ -45,6 +51,7 @@ class AircoxMiddleware(object):
def __call__(self, request):
self.init_timezone(request)
request.station = self.get_station(request)
request.is_mobile = self.is_mobile(request)
try:
return self.get_response(request)
except Redirect:

View File

@ -0,0 +1,641 @@
# Generated by Django 4.2.1 on 2023-11-24 21:11
import aircox.models.schedule
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0014_alter_schedule_timezone"),
]
operations = [
migrations.AlterField(
model_name="schedule",
name="timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
("Africa/Accra", "Africa/Accra"),
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
("Africa/Algiers", "Africa/Algiers"),
("Africa/Asmara", "Africa/Asmara"),
("Africa/Asmera", "Africa/Asmera"),
("Africa/Bamako", "Africa/Bamako"),
("Africa/Bangui", "Africa/Bangui"),
("Africa/Banjul", "Africa/Banjul"),
("Africa/Bissau", "Africa/Bissau"),
("Africa/Blantyre", "Africa/Blantyre"),
("Africa/Brazzaville", "Africa/Brazzaville"),
("Africa/Bujumbura", "Africa/Bujumbura"),
("Africa/Cairo", "Africa/Cairo"),
("Africa/Casablanca", "Africa/Casablanca"),
("Africa/Ceuta", "Africa/Ceuta"),
("Africa/Conakry", "Africa/Conakry"),
("Africa/Dakar", "Africa/Dakar"),
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
("Africa/Djibouti", "Africa/Djibouti"),
("Africa/Douala", "Africa/Douala"),
("Africa/El_Aaiun", "Africa/El_Aaiun"),
("Africa/Freetown", "Africa/Freetown"),
("Africa/Gaborone", "Africa/Gaborone"),
("Africa/Harare", "Africa/Harare"),
("Africa/Johannesburg", "Africa/Johannesburg"),
("Africa/Juba", "Africa/Juba"),
("Africa/Kampala", "Africa/Kampala"),
("Africa/Khartoum", "Africa/Khartoum"),
("Africa/Kigali", "Africa/Kigali"),
("Africa/Kinshasa", "Africa/Kinshasa"),
("Africa/Lagos", "Africa/Lagos"),
("Africa/Libreville", "Africa/Libreville"),
("Africa/Lome", "Africa/Lome"),
("Africa/Luanda", "Africa/Luanda"),
("Africa/Lubumbashi", "Africa/Lubumbashi"),
("Africa/Lusaka", "Africa/Lusaka"),
("Africa/Malabo", "Africa/Malabo"),
("Africa/Maputo", "Africa/Maputo"),
("Africa/Maseru", "Africa/Maseru"),
("Africa/Mbabane", "Africa/Mbabane"),
("Africa/Mogadishu", "Africa/Mogadishu"),
("Africa/Monrovia", "Africa/Monrovia"),
("Africa/Nairobi", "Africa/Nairobi"),
("Africa/Ndjamena", "Africa/Ndjamena"),
("Africa/Niamey", "Africa/Niamey"),
("Africa/Nouakchott", "Africa/Nouakchott"),
("Africa/Ouagadougou", "Africa/Ouagadougou"),
("Africa/Porto-Novo", "Africa/Porto-Novo"),
("Africa/Sao_Tome", "Africa/Sao_Tome"),
("Africa/Timbuktu", "Africa/Timbuktu"),
("Africa/Tripoli", "Africa/Tripoli"),
("Africa/Tunis", "Africa/Tunis"),
("Africa/Windhoek", "Africa/Windhoek"),
("America/Adak", "America/Adak"),
("America/Anchorage", "America/Anchorage"),
("America/Anguilla", "America/Anguilla"),
("America/Antigua", "America/Antigua"),
("America/Araguaina", "America/Araguaina"),
("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"),
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"),
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"),
("America/Argentina/Salta", "America/Argentina/Salta"),
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
("America/Aruba", "America/Aruba"),
("America/Asuncion", "America/Asuncion"),
("America/Atikokan", "America/Atikokan"),
("America/Atka", "America/Atka"),
("America/Bahia", "America/Bahia"),
("America/Bahia_Banderas", "America/Bahia_Banderas"),
("America/Barbados", "America/Barbados"),
("America/Belem", "America/Belem"),
("America/Belize", "America/Belize"),
("America/Blanc-Sablon", "America/Blanc-Sablon"),
("America/Boa_Vista", "America/Boa_Vista"),
("America/Bogota", "America/Bogota"),
("America/Boise", "America/Boise"),
("America/Buenos_Aires", "America/Buenos_Aires"),
("America/Cambridge_Bay", "America/Cambridge_Bay"),
("America/Campo_Grande", "America/Campo_Grande"),
("America/Cancun", "America/Cancun"),
("America/Caracas", "America/Caracas"),
("America/Catamarca", "America/Catamarca"),
("America/Cayenne", "America/Cayenne"),
("America/Cayman", "America/Cayman"),
("America/Chicago", "America/Chicago"),
("America/Chihuahua", "America/Chihuahua"),
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
("America/Coral_Harbour", "America/Coral_Harbour"),
("America/Cordoba", "America/Cordoba"),
("America/Costa_Rica", "America/Costa_Rica"),
("America/Creston", "America/Creston"),
("America/Cuiaba", "America/Cuiaba"),
("America/Curacao", "America/Curacao"),
("America/Danmarkshavn", "America/Danmarkshavn"),
("America/Dawson", "America/Dawson"),
("America/Dawson_Creek", "America/Dawson_Creek"),
("America/Denver", "America/Denver"),
("America/Detroit", "America/Detroit"),
("America/Dominica", "America/Dominica"),
("America/Edmonton", "America/Edmonton"),
("America/Eirunepe", "America/Eirunepe"),
("America/El_Salvador", "America/El_Salvador"),
("America/Ensenada", "America/Ensenada"),
("America/Fort_Nelson", "America/Fort_Nelson"),
("America/Fort_Wayne", "America/Fort_Wayne"),
("America/Fortaleza", "America/Fortaleza"),
("America/Glace_Bay", "America/Glace_Bay"),
("America/Godthab", "America/Godthab"),
("America/Goose_Bay", "America/Goose_Bay"),
("America/Grand_Turk", "America/Grand_Turk"),
("America/Grenada", "America/Grenada"),
("America/Guadeloupe", "America/Guadeloupe"),
("America/Guatemala", "America/Guatemala"),
("America/Guayaquil", "America/Guayaquil"),
("America/Guyana", "America/Guyana"),
("America/Halifax", "America/Halifax"),
("America/Havana", "America/Havana"),
("America/Hermosillo", "America/Hermosillo"),
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
("America/Indiana/Knox", "America/Indiana/Knox"),
("America/Indiana/Marengo", "America/Indiana/Marengo"),
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
("America/Indiana/Vevay", "America/Indiana/Vevay"),
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
("America/Indiana/Winamac", "America/Indiana/Winamac"),
("America/Indianapolis", "America/Indianapolis"),
("America/Inuvik", "America/Inuvik"),
("America/Iqaluit", "America/Iqaluit"),
("America/Jamaica", "America/Jamaica"),
("America/Jujuy", "America/Jujuy"),
("America/Juneau", "America/Juneau"),
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
("America/Knox_IN", "America/Knox_IN"),
("America/Kralendijk", "America/Kralendijk"),
("America/La_Paz", "America/La_Paz"),
("America/Lima", "America/Lima"),
("America/Los_Angeles", "America/Los_Angeles"),
("America/Louisville", "America/Louisville"),
("America/Lower_Princes", "America/Lower_Princes"),
("America/Maceio", "America/Maceio"),
("America/Managua", "America/Managua"),
("America/Manaus", "America/Manaus"),
("America/Marigot", "America/Marigot"),
("America/Martinique", "America/Martinique"),
("America/Matamoros", "America/Matamoros"),
("America/Mazatlan", "America/Mazatlan"),
("America/Mendoza", "America/Mendoza"),
("America/Menominee", "America/Menominee"),
("America/Merida", "America/Merida"),
("America/Metlakatla", "America/Metlakatla"),
("America/Mexico_City", "America/Mexico_City"),
("America/Miquelon", "America/Miquelon"),
("America/Moncton", "America/Moncton"),
("America/Monterrey", "America/Monterrey"),
("America/Montevideo", "America/Montevideo"),
("America/Montreal", "America/Montreal"),
("America/Montserrat", "America/Montserrat"),
("America/Nassau", "America/Nassau"),
("America/New_York", "America/New_York"),
("America/Nipigon", "America/Nipigon"),
("America/Nome", "America/Nome"),
("America/Noronha", "America/Noronha"),
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
("America/North_Dakota/Center", "America/North_Dakota/Center"),
("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"),
("America/Nuuk", "America/Nuuk"),
("America/Ojinaga", "America/Ojinaga"),
("America/Panama", "America/Panama"),
("America/Pangnirtung", "America/Pangnirtung"),
("America/Paramaribo", "America/Paramaribo"),
("America/Phoenix", "America/Phoenix"),
("America/Port-au-Prince", "America/Port-au-Prince"),
("America/Port_of_Spain", "America/Port_of_Spain"),
("America/Porto_Acre", "America/Porto_Acre"),
("America/Porto_Velho", "America/Porto_Velho"),
("America/Puerto_Rico", "America/Puerto_Rico"),
("America/Punta_Arenas", "America/Punta_Arenas"),
("America/Rainy_River", "America/Rainy_River"),
("America/Rankin_Inlet", "America/Rankin_Inlet"),
("America/Recife", "America/Recife"),
("America/Regina", "America/Regina"),
("America/Resolute", "America/Resolute"),
("America/Rio_Branco", "America/Rio_Branco"),
("America/Rosario", "America/Rosario"),
("America/Santa_Isabel", "America/Santa_Isabel"),
("America/Santarem", "America/Santarem"),
("America/Santiago", "America/Santiago"),
("America/Santo_Domingo", "America/Santo_Domingo"),
("America/Sao_Paulo", "America/Sao_Paulo"),
("America/Scoresbysund", "America/Scoresbysund"),
("America/Shiprock", "America/Shiprock"),
("America/Sitka", "America/Sitka"),
("America/St_Barthelemy", "America/St_Barthelemy"),
("America/St_Johns", "America/St_Johns"),
("America/St_Kitts", "America/St_Kitts"),
("America/St_Lucia", "America/St_Lucia"),
("America/St_Thomas", "America/St_Thomas"),
("America/St_Vincent", "America/St_Vincent"),
("America/Swift_Current", "America/Swift_Current"),
("America/Tegucigalpa", "America/Tegucigalpa"),
("America/Thule", "America/Thule"),
("America/Thunder_Bay", "America/Thunder_Bay"),
("America/Tijuana", "America/Tijuana"),
("America/Toronto", "America/Toronto"),
("America/Tortola", "America/Tortola"),
("America/Vancouver", "America/Vancouver"),
("America/Virgin", "America/Virgin"),
("America/Whitehorse", "America/Whitehorse"),
("America/Winnipeg", "America/Winnipeg"),
("America/Yakutat", "America/Yakutat"),
("America/Yellowknife", "America/Yellowknife"),
("Antarctica/Casey", "Antarctica/Casey"),
("Antarctica/Davis", "Antarctica/Davis"),
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
("Antarctica/Macquarie", "Antarctica/Macquarie"),
("Antarctica/Mawson", "Antarctica/Mawson"),
("Antarctica/McMurdo", "Antarctica/McMurdo"),
("Antarctica/Palmer", "Antarctica/Palmer"),
("Antarctica/Rothera", "Antarctica/Rothera"),
("Antarctica/South_Pole", "Antarctica/South_Pole"),
("Antarctica/Syowa", "Antarctica/Syowa"),
("Antarctica/Troll", "Antarctica/Troll"),
("Antarctica/Vostok", "Antarctica/Vostok"),
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
("Asia/Aden", "Asia/Aden"),
("Asia/Almaty", "Asia/Almaty"),
("Asia/Amman", "Asia/Amman"),
("Asia/Anadyr", "Asia/Anadyr"),
("Asia/Aqtau", "Asia/Aqtau"),
("Asia/Aqtobe", "Asia/Aqtobe"),
("Asia/Ashgabat", "Asia/Ashgabat"),
("Asia/Ashkhabad", "Asia/Ashkhabad"),
("Asia/Atyrau", "Asia/Atyrau"),
("Asia/Baghdad", "Asia/Baghdad"),
("Asia/Bahrain", "Asia/Bahrain"),
("Asia/Baku", "Asia/Baku"),
("Asia/Bangkok", "Asia/Bangkok"),
("Asia/Barnaul", "Asia/Barnaul"),
("Asia/Beirut", "Asia/Beirut"),
("Asia/Bishkek", "Asia/Bishkek"),
("Asia/Brunei", "Asia/Brunei"),
("Asia/Calcutta", "Asia/Calcutta"),
("Asia/Chita", "Asia/Chita"),
("Asia/Choibalsan", "Asia/Choibalsan"),
("Asia/Chongqing", "Asia/Chongqing"),
("Asia/Chungking", "Asia/Chungking"),
("Asia/Colombo", "Asia/Colombo"),
("Asia/Dacca", "Asia/Dacca"),
("Asia/Damascus", "Asia/Damascus"),
("Asia/Dhaka", "Asia/Dhaka"),
("Asia/Dili", "Asia/Dili"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Dushanbe", "Asia/Dushanbe"),
("Asia/Famagusta", "Asia/Famagusta"),
("Asia/Gaza", "Asia/Gaza"),
("Asia/Harbin", "Asia/Harbin"),
("Asia/Hebron", "Asia/Hebron"),
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
("Asia/Hong_Kong", "Asia/Hong_Kong"),
("Asia/Hovd", "Asia/Hovd"),
("Asia/Irkutsk", "Asia/Irkutsk"),
("Asia/Istanbul", "Asia/Istanbul"),
("Asia/Jakarta", "Asia/Jakarta"),
("Asia/Jayapura", "Asia/Jayapura"),
("Asia/Jerusalem", "Asia/Jerusalem"),
("Asia/Kabul", "Asia/Kabul"),
("Asia/Kamchatka", "Asia/Kamchatka"),
("Asia/Karachi", "Asia/Karachi"),
("Asia/Kashgar", "Asia/Kashgar"),
("Asia/Kathmandu", "Asia/Kathmandu"),
("Asia/Katmandu", "Asia/Katmandu"),
("Asia/Khandyga", "Asia/Khandyga"),
("Asia/Kolkata", "Asia/Kolkata"),
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
("Asia/Kuching", "Asia/Kuching"),
("Asia/Kuwait", "Asia/Kuwait"),
("Asia/Macao", "Asia/Macao"),
("Asia/Macau", "Asia/Macau"),
("Asia/Magadan", "Asia/Magadan"),
("Asia/Makassar", "Asia/Makassar"),
("Asia/Manila", "Asia/Manila"),
("Asia/Muscat", "Asia/Muscat"),
("Asia/Nicosia", "Asia/Nicosia"),
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
("Asia/Novosibirsk", "Asia/Novosibirsk"),
("Asia/Omsk", "Asia/Omsk"),
("Asia/Oral", "Asia/Oral"),
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
("Asia/Pontianak", "Asia/Pontianak"),
("Asia/Pyongyang", "Asia/Pyongyang"),
("Asia/Qatar", "Asia/Qatar"),
("Asia/Qostanay", "Asia/Qostanay"),
("Asia/Qyzylorda", "Asia/Qyzylorda"),
("Asia/Rangoon", "Asia/Rangoon"),
("Asia/Riyadh", "Asia/Riyadh"),
("Asia/Saigon", "Asia/Saigon"),
("Asia/Sakhalin", "Asia/Sakhalin"),
("Asia/Samarkand", "Asia/Samarkand"),
("Asia/Seoul", "Asia/Seoul"),
("Asia/Shanghai", "Asia/Shanghai"),
("Asia/Singapore", "Asia/Singapore"),
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
("Asia/Taipei", "Asia/Taipei"),
("Asia/Tashkent", "Asia/Tashkent"),
("Asia/Tbilisi", "Asia/Tbilisi"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
("Asia/Thimbu", "Asia/Thimbu"),
("Asia/Thimphu", "Asia/Thimphu"),
("Asia/Tokyo", "Asia/Tokyo"),
("Asia/Tomsk", "Asia/Tomsk"),
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
("Asia/Urumqi", "Asia/Urumqi"),
("Asia/Ust-Nera", "Asia/Ust-Nera"),
("Asia/Vientiane", "Asia/Vientiane"),
("Asia/Vladivostok", "Asia/Vladivostok"),
("Asia/Yakutsk", "Asia/Yakutsk"),
("Asia/Yangon", "Asia/Yangon"),
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
("Asia/Yerevan", "Asia/Yerevan"),
("Atlantic/Azores", "Atlantic/Azores"),
("Atlantic/Bermuda", "Atlantic/Bermuda"),
("Atlantic/Canary", "Atlantic/Canary"),
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
("Atlantic/Faeroe", "Atlantic/Faeroe"),
("Atlantic/Faroe", "Atlantic/Faroe"),
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
("Atlantic/Madeira", "Atlantic/Madeira"),
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
("Atlantic/St_Helena", "Atlantic/St_Helena"),
("Atlantic/Stanley", "Atlantic/Stanley"),
("Australia/ACT", "Australia/ACT"),
("Australia/Adelaide", "Australia/Adelaide"),
("Australia/Brisbane", "Australia/Brisbane"),
("Australia/Broken_Hill", "Australia/Broken_Hill"),
("Australia/Canberra", "Australia/Canberra"),
("Australia/Currie", "Australia/Currie"),
("Australia/Darwin", "Australia/Darwin"),
("Australia/Eucla", "Australia/Eucla"),
("Australia/Hobart", "Australia/Hobart"),
("Australia/LHI", "Australia/LHI"),
("Australia/Lindeman", "Australia/Lindeman"),
("Australia/Lord_Howe", "Australia/Lord_Howe"),
("Australia/Melbourne", "Australia/Melbourne"),
("Australia/NSW", "Australia/NSW"),
("Australia/North", "Australia/North"),
("Australia/Perth", "Australia/Perth"),
("Australia/Queensland", "Australia/Queensland"),
("Australia/South", "Australia/South"),
("Australia/Sydney", "Australia/Sydney"),
("Australia/Tasmania", "Australia/Tasmania"),
("Australia/Victoria", "Australia/Victoria"),
("Australia/West", "Australia/West"),
("Australia/Yancowinna", "Australia/Yancowinna"),
("Brazil/Acre", "Brazil/Acre"),
("Brazil/DeNoronha", "Brazil/DeNoronha"),
("Brazil/East", "Brazil/East"),
("Brazil/West", "Brazil/West"),
("CET", "CET"),
("CST6CDT", "CST6CDT"),
("Canada/Atlantic", "Canada/Atlantic"),
("Canada/Central", "Canada/Central"),
("Canada/Eastern", "Canada/Eastern"),
("Canada/Mountain", "Canada/Mountain"),
("Canada/Newfoundland", "Canada/Newfoundland"),
("Canada/Pacific", "Canada/Pacific"),
("Canada/Saskatchewan", "Canada/Saskatchewan"),
("Canada/Yukon", "Canada/Yukon"),
("Chile/Continental", "Chile/Continental"),
("Chile/EasterIsland", "Chile/EasterIsland"),
("Cuba", "Cuba"),
("EET", "EET"),
("EST", "EST"),
("EST5EDT", "EST5EDT"),
("Egypt", "Egypt"),
("Eire", "Eire"),
("Etc/GMT", "Etc/GMT"),
("Etc/GMT+0", "Etc/GMT+0"),
("Etc/GMT+1", "Etc/GMT+1"),
("Etc/GMT+10", "Etc/GMT+10"),
("Etc/GMT+11", "Etc/GMT+11"),
("Etc/GMT+12", "Etc/GMT+12"),
("Etc/GMT+2", "Etc/GMT+2"),
("Etc/GMT+3", "Etc/GMT+3"),
("Etc/GMT+4", "Etc/GMT+4"),
("Etc/GMT+5", "Etc/GMT+5"),
("Etc/GMT+6", "Etc/GMT+6"),
("Etc/GMT+7", "Etc/GMT+7"),
("Etc/GMT+8", "Etc/GMT+8"),
("Etc/GMT+9", "Etc/GMT+9"),
("Etc/GMT-0", "Etc/GMT-0"),
("Etc/GMT-1", "Etc/GMT-1"),
("Etc/GMT-10", "Etc/GMT-10"),
("Etc/GMT-11", "Etc/GMT-11"),
("Etc/GMT-12", "Etc/GMT-12"),
("Etc/GMT-13", "Etc/GMT-13"),
("Etc/GMT-14", "Etc/GMT-14"),
("Etc/GMT-2", "Etc/GMT-2"),
("Etc/GMT-3", "Etc/GMT-3"),
("Etc/GMT-4", "Etc/GMT-4"),
("Etc/GMT-5", "Etc/GMT-5"),
("Etc/GMT-6", "Etc/GMT-6"),
("Etc/GMT-7", "Etc/GMT-7"),
("Etc/GMT-8", "Etc/GMT-8"),
("Etc/GMT-9", "Etc/GMT-9"),
("Etc/GMT0", "Etc/GMT0"),
("Etc/Greenwich", "Etc/Greenwich"),
("Etc/UCT", "Etc/UCT"),
("Etc/UTC", "Etc/UTC"),
("Etc/Universal", "Etc/Universal"),
("Etc/Zulu", "Etc/Zulu"),
("Europe/Amsterdam", "Europe/Amsterdam"),
("Europe/Andorra", "Europe/Andorra"),
("Europe/Astrakhan", "Europe/Astrakhan"),
("Europe/Athens", "Europe/Athens"),
("Europe/Belfast", "Europe/Belfast"),
("Europe/Belgrade", "Europe/Belgrade"),
("Europe/Berlin", "Europe/Berlin"),
("Europe/Bratislava", "Europe/Bratislava"),
("Europe/Brussels", "Europe/Brussels"),
("Europe/Bucharest", "Europe/Bucharest"),
("Europe/Budapest", "Europe/Budapest"),
("Europe/Busingen", "Europe/Busingen"),
("Europe/Chisinau", "Europe/Chisinau"),
("Europe/Copenhagen", "Europe/Copenhagen"),
("Europe/Dublin", "Europe/Dublin"),
("Europe/Gibraltar", "Europe/Gibraltar"),
("Europe/Guernsey", "Europe/Guernsey"),
("Europe/Helsinki", "Europe/Helsinki"),
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
("Europe/Istanbul", "Europe/Istanbul"),
("Europe/Jersey", "Europe/Jersey"),
("Europe/Kaliningrad", "Europe/Kaliningrad"),
("Europe/Kiev", "Europe/Kiev"),
("Europe/Kirov", "Europe/Kirov"),
("Europe/Kyiv", "Europe/Kyiv"),
("Europe/Lisbon", "Europe/Lisbon"),
("Europe/Ljubljana", "Europe/Ljubljana"),
("Europe/London", "Europe/London"),
("Europe/Luxembourg", "Europe/Luxembourg"),
("Europe/Madrid", "Europe/Madrid"),
("Europe/Malta", "Europe/Malta"),
("Europe/Mariehamn", "Europe/Mariehamn"),
("Europe/Minsk", "Europe/Minsk"),
("Europe/Monaco", "Europe/Monaco"),
("Europe/Moscow", "Europe/Moscow"),
("Europe/Nicosia", "Europe/Nicosia"),
("Europe/Oslo", "Europe/Oslo"),
("Europe/Paris", "Europe/Paris"),
("Europe/Podgorica", "Europe/Podgorica"),
("Europe/Prague", "Europe/Prague"),
("Europe/Riga", "Europe/Riga"),
("Europe/Rome", "Europe/Rome"),
("Europe/Samara", "Europe/Samara"),
("Europe/San_Marino", "Europe/San_Marino"),
("Europe/Sarajevo", "Europe/Sarajevo"),
("Europe/Saratov", "Europe/Saratov"),
("Europe/Simferopol", "Europe/Simferopol"),
("Europe/Skopje", "Europe/Skopje"),
("Europe/Sofia", "Europe/Sofia"),
("Europe/Stockholm", "Europe/Stockholm"),
("Europe/Tallinn", "Europe/Tallinn"),
("Europe/Tirane", "Europe/Tirane"),
("Europe/Tiraspol", "Europe/Tiraspol"),
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
("Europe/Uzhgorod", "Europe/Uzhgorod"),
("Europe/Vaduz", "Europe/Vaduz"),
("Europe/Vatican", "Europe/Vatican"),
("Europe/Vienna", "Europe/Vienna"),
("Europe/Vilnius", "Europe/Vilnius"),
("Europe/Volgograd", "Europe/Volgograd"),
("Europe/Warsaw", "Europe/Warsaw"),
("Europe/Zagreb", "Europe/Zagreb"),
("Europe/Zaporozhye", "Europe/Zaporozhye"),
("Europe/Zurich", "Europe/Zurich"),
("Factory", "Factory"),
("GB", "GB"),
("GB-Eire", "GB-Eire"),
("GMT", "GMT"),
("GMT+0", "GMT+0"),
("GMT-0", "GMT-0"),
("GMT0", "GMT0"),
("Greenwich", "Greenwich"),
("HST", "HST"),
("Hongkong", "Hongkong"),
("Iceland", "Iceland"),
("Indian/Antananarivo", "Indian/Antananarivo"),
("Indian/Chagos", "Indian/Chagos"),
("Indian/Christmas", "Indian/Christmas"),
("Indian/Cocos", "Indian/Cocos"),
("Indian/Comoro", "Indian/Comoro"),
("Indian/Kerguelen", "Indian/Kerguelen"),
("Indian/Mahe", "Indian/Mahe"),
("Indian/Maldives", "Indian/Maldives"),
("Indian/Mauritius", "Indian/Mauritius"),
("Indian/Mayotte", "Indian/Mayotte"),
("Indian/Reunion", "Indian/Reunion"),
("Iran", "Iran"),
("Israel", "Israel"),
("Jamaica", "Jamaica"),
("Japan", "Japan"),
("Kwajalein", "Kwajalein"),
("Libya", "Libya"),
("MET", "MET"),
("MST", "MST"),
("MST7MDT", "MST7MDT"),
("Mexico/BajaNorte", "Mexico/BajaNorte"),
("Mexico/BajaSur", "Mexico/BajaSur"),
("Mexico/General", "Mexico/General"),
("NZ", "NZ"),
("NZ-CHAT", "NZ-CHAT"),
("Navajo", "Navajo"),
("PRC", "PRC"),
("PST8PDT", "PST8PDT"),
("Pacific/Apia", "Pacific/Apia"),
("Pacific/Auckland", "Pacific/Auckland"),
("Pacific/Bougainville", "Pacific/Bougainville"),
("Pacific/Chatham", "Pacific/Chatham"),
("Pacific/Chuuk", "Pacific/Chuuk"),
("Pacific/Easter", "Pacific/Easter"),
("Pacific/Efate", "Pacific/Efate"),
("Pacific/Enderbury", "Pacific/Enderbury"),
("Pacific/Fakaofo", "Pacific/Fakaofo"),
("Pacific/Fiji", "Pacific/Fiji"),
("Pacific/Funafuti", "Pacific/Funafuti"),
("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Johnston", "Pacific/Johnston"),
("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"),
("Pacific/Kosrae", "Pacific/Kosrae"),
("Pacific/Kwajalein", "Pacific/Kwajalein"),
("Pacific/Majuro", "Pacific/Majuro"),
("Pacific/Marquesas", "Pacific/Marquesas"),
("Pacific/Midway", "Pacific/Midway"),
("Pacific/Nauru", "Pacific/Nauru"),
("Pacific/Niue", "Pacific/Niue"),
("Pacific/Norfolk", "Pacific/Norfolk"),
("Pacific/Noumea", "Pacific/Noumea"),
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
("Pacific/Palau", "Pacific/Palau"),
("Pacific/Pitcairn", "Pacific/Pitcairn"),
("Pacific/Pohnpei", "Pacific/Pohnpei"),
("Pacific/Ponape", "Pacific/Ponape"),
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
("Pacific/Rarotonga", "Pacific/Rarotonga"),
("Pacific/Saipan", "Pacific/Saipan"),
("Pacific/Samoa", "Pacific/Samoa"),
("Pacific/Tahiti", "Pacific/Tahiti"),
("Pacific/Tarawa", "Pacific/Tarawa"),
("Pacific/Tongatapu", "Pacific/Tongatapu"),
("Pacific/Truk", "Pacific/Truk"),
("Pacific/Wake", "Pacific/Wake"),
("Pacific/Wallis", "Pacific/Wallis"),
("Pacific/Yap", "Pacific/Yap"),
("Poland", "Poland"),
("Portugal", "Portugal"),
("ROC", "ROC"),
("ROK", "ROK"),
("Singapore", "Singapore"),
("Turkey", "Turkey"),
("UCT", "UCT"),
("US/Alaska", "US/Alaska"),
("US/Aleutian", "US/Aleutian"),
("US/Arizona", "US/Arizona"),
("US/Central", "US/Central"),
("US/East-Indiana", "US/East-Indiana"),
("US/Eastern", "US/Eastern"),
("US/Hawaii", "US/Hawaii"),
("US/Indiana-Starke", "US/Indiana-Starke"),
("US/Michigan", "US/Michigan"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Samoa", "US/Samoa"),
("UTC", "UTC"),
("Universal", "Universal"),
("W-SU", "W-SU"),
("WET", "WET"),
("Zulu", "Zulu"),
],
default=aircox.models.schedule.current_timezone_key,
help_text="timezone used for the date",
max_length=100,
verbose_name="timezone",
),
),
migrations.AlterField(
model_name="staticpage",
name="attach_to",
field=models.SmallIntegerField(
blank=True,
choices=[
(0, "Home page"),
(1, "Diffusions page"),
(2, "Logs page"),
(3, "Programs list"),
(4, "Episodes list"),
(5, "Articles list"),
(6, "Publications list"),
],
help_text="display this page content to related element",
null=True,
verbose_name="attach to",
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.5 on 2023-10-18 13:50
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
("aircox", "0014_alter_schedule_timezone"),
]
operations = [
migrations.AddField(
model_name="program",
name="editors_group",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="auth.group",
verbose_name="editors",
),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.1 on 2023-11-28 01:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0015_alter_schedule_timezone_alter_staticpage_attach_to"),
]
operations = [
migrations.AlterField(
model_name="staticpage",
name="attach_to",
field=models.SmallIntegerField(
blank=True,
choices=[
(0, "Home page"),
(1, "Diffusions page"),
(2, "Logs page"),
(3, "Programs list"),
(4, "Episodes list"),
(5, "Articles list"),
(6, "Publications list"),
(7, "Podcasts list"),
],
help_text="display this page content to related element",
null=True,
verbose_name="attach to",
),
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.1 on 2023-12-12 16:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0016_alter_staticpage_attach_to"),
]
operations = [
migrations.AlterField(
model_name="navitem",
name="text",
field=models.CharField(blank=True, max_length=64, null=True, verbose_name="title"),
),
migrations.AlterField(
model_name="staticpage",
name="attach_to",
field=models.SmallIntegerField(
blank=True,
choices=[
(0, "Home page"),
(1, "Diffusions page"),
(3, "Programs list"),
(4, "Episodes list"),
(5, "Articles list"),
(6, "Publications list"),
(7, "Podcasts list"),
],
help_text="display this page content to related element",
null=True,
verbose_name="attach to",
),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.1 on 2023-12-12 18:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0017_alter_navitem_text_alter_staticpage_attach_to"),
]
operations = [
migrations.AlterField(
model_name="staticpage",
name="attach_to",
field=models.CharField(
blank=True,
choices=[
("", "Home Page"),
("timetable-list", "Timetable"),
("program-list", "Programs list"),
("episode-list", "Episodes list"),
("article-list", "Articles list"),
("page-list", "Publications list"),
("podcast-list", "Podcasts list"),
],
help_text="display this page content to related element",
max_length=32,
null=True,
verbose_name="attach to",
),
),
]

View File

@ -0,0 +1,12 @@
# Generated by Django 4.2.7 on 2024-01-19 09:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("aircox", "0015_program_editors"),
("aircox", "0018_alter_staticpage_attach_to"),
]
operations = []

View File

@ -0,0 +1,42 @@
# Generated by Django 4.2.1 on 2024-02-01 18:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0018_alter_staticpage_attach_to"),
]
operations = [
migrations.AddField(
model_name="station",
name="music_stream_title",
field=models.CharField(
default="Music stream",
max_length=64,
verbose_name="Music stream's title",
),
),
migrations.AlterField(
model_name="staticpage",
name="attach_to",
field=models.CharField(
blank=True,
choices=[
("", "None"),
("home", "Home Page"),
("timetable-list", "Timetable"),
("program-list", "Programs list"),
("episode-list", "Episodes list"),
("article-list", "Articles list"),
("page-list", "Publications list"),
("podcast-list", "Podcasts list"),
],
help_text="display this page content to related element",
max_length=32,
null=True,
verbose_name="attach to",
),
),
]

View File

@ -0,0 +1,12 @@
# Generated by Django 4.2.7 on 2024-02-05 09:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("aircox", "0019_merge_20240119_1022"),
("aircox", "0019_station_program_streams_title_and_more"),
]
operations = []

View File

@ -0,0 +1,623 @@
# Generated by Django 4.2.7 on 2024-02-06 08:13
import aircox.models.schedule
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0020_merge_20240205_1027"),
]
operations = [
migrations.AlterField(
model_name="schedule",
name="timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
("Africa/Accra", "Africa/Accra"),
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
("Africa/Algiers", "Africa/Algiers"),
("Africa/Asmara", "Africa/Asmara"),
("Africa/Asmera", "Africa/Asmera"),
("Africa/Bamako", "Africa/Bamako"),
("Africa/Bangui", "Africa/Bangui"),
("Africa/Banjul", "Africa/Banjul"),
("Africa/Bissau", "Africa/Bissau"),
("Africa/Blantyre", "Africa/Blantyre"),
("Africa/Brazzaville", "Africa/Brazzaville"),
("Africa/Bujumbura", "Africa/Bujumbura"),
("Africa/Cairo", "Africa/Cairo"),
("Africa/Casablanca", "Africa/Casablanca"),
("Africa/Ceuta", "Africa/Ceuta"),
("Africa/Conakry", "Africa/Conakry"),
("Africa/Dakar", "Africa/Dakar"),
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
("Africa/Djibouti", "Africa/Djibouti"),
("Africa/Douala", "Africa/Douala"),
("Africa/El_Aaiun", "Africa/El_Aaiun"),
("Africa/Freetown", "Africa/Freetown"),
("Africa/Gaborone", "Africa/Gaborone"),
("Africa/Harare", "Africa/Harare"),
("Africa/Johannesburg", "Africa/Johannesburg"),
("Africa/Juba", "Africa/Juba"),
("Africa/Kampala", "Africa/Kampala"),
("Africa/Khartoum", "Africa/Khartoum"),
("Africa/Kigali", "Africa/Kigali"),
("Africa/Kinshasa", "Africa/Kinshasa"),
("Africa/Lagos", "Africa/Lagos"),
("Africa/Libreville", "Africa/Libreville"),
("Africa/Lome", "Africa/Lome"),
("Africa/Luanda", "Africa/Luanda"),
("Africa/Lubumbashi", "Africa/Lubumbashi"),
("Africa/Lusaka", "Africa/Lusaka"),
("Africa/Malabo", "Africa/Malabo"),
("Africa/Maputo", "Africa/Maputo"),
("Africa/Maseru", "Africa/Maseru"),
("Africa/Mbabane", "Africa/Mbabane"),
("Africa/Mogadishu", "Africa/Mogadishu"),
("Africa/Monrovia", "Africa/Monrovia"),
("Africa/Nairobi", "Africa/Nairobi"),
("Africa/Ndjamena", "Africa/Ndjamena"),
("Africa/Niamey", "Africa/Niamey"),
("Africa/Nouakchott", "Africa/Nouakchott"),
("Africa/Ouagadougou", "Africa/Ouagadougou"),
("Africa/Porto-Novo", "Africa/Porto-Novo"),
("Africa/Sao_Tome", "Africa/Sao_Tome"),
("Africa/Timbuktu", "Africa/Timbuktu"),
("Africa/Tripoli", "Africa/Tripoli"),
("Africa/Tunis", "Africa/Tunis"),
("Africa/Windhoek", "Africa/Windhoek"),
("America/Adak", "America/Adak"),
("America/Anchorage", "America/Anchorage"),
("America/Anguilla", "America/Anguilla"),
("America/Antigua", "America/Antigua"),
("America/Araguaina", "America/Araguaina"),
("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"),
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"),
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"),
("America/Argentina/Salta", "America/Argentina/Salta"),
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
("America/Aruba", "America/Aruba"),
("America/Asuncion", "America/Asuncion"),
("America/Atikokan", "America/Atikokan"),
("America/Atka", "America/Atka"),
("America/Bahia", "America/Bahia"),
("America/Bahia_Banderas", "America/Bahia_Banderas"),
("America/Barbados", "America/Barbados"),
("America/Belem", "America/Belem"),
("America/Belize", "America/Belize"),
("America/Blanc-Sablon", "America/Blanc-Sablon"),
("America/Boa_Vista", "America/Boa_Vista"),
("America/Bogota", "America/Bogota"),
("America/Boise", "America/Boise"),
("America/Buenos_Aires", "America/Buenos_Aires"),
("America/Cambridge_Bay", "America/Cambridge_Bay"),
("America/Campo_Grande", "America/Campo_Grande"),
("America/Cancun", "America/Cancun"),
("America/Caracas", "America/Caracas"),
("America/Catamarca", "America/Catamarca"),
("America/Cayenne", "America/Cayenne"),
("America/Cayman", "America/Cayman"),
("America/Chicago", "America/Chicago"),
("America/Chihuahua", "America/Chihuahua"),
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
("America/Coral_Harbour", "America/Coral_Harbour"),
("America/Cordoba", "America/Cordoba"),
("America/Costa_Rica", "America/Costa_Rica"),
("America/Creston", "America/Creston"),
("America/Cuiaba", "America/Cuiaba"),
("America/Curacao", "America/Curacao"),
("America/Danmarkshavn", "America/Danmarkshavn"),
("America/Dawson", "America/Dawson"),
("America/Dawson_Creek", "America/Dawson_Creek"),
("America/Denver", "America/Denver"),
("America/Detroit", "America/Detroit"),
("America/Dominica", "America/Dominica"),
("America/Edmonton", "America/Edmonton"),
("America/Eirunepe", "America/Eirunepe"),
("America/El_Salvador", "America/El_Salvador"),
("America/Ensenada", "America/Ensenada"),
("America/Fort_Nelson", "America/Fort_Nelson"),
("America/Fort_Wayne", "America/Fort_Wayne"),
("America/Fortaleza", "America/Fortaleza"),
("America/Glace_Bay", "America/Glace_Bay"),
("America/Godthab", "America/Godthab"),
("America/Goose_Bay", "America/Goose_Bay"),
("America/Grand_Turk", "America/Grand_Turk"),
("America/Grenada", "America/Grenada"),
("America/Guadeloupe", "America/Guadeloupe"),
("America/Guatemala", "America/Guatemala"),
("America/Guayaquil", "America/Guayaquil"),
("America/Guyana", "America/Guyana"),
("America/Halifax", "America/Halifax"),
("America/Havana", "America/Havana"),
("America/Hermosillo", "America/Hermosillo"),
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
("America/Indiana/Knox", "America/Indiana/Knox"),
("America/Indiana/Marengo", "America/Indiana/Marengo"),
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
("America/Indiana/Vevay", "America/Indiana/Vevay"),
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
("America/Indiana/Winamac", "America/Indiana/Winamac"),
("America/Indianapolis", "America/Indianapolis"),
("America/Inuvik", "America/Inuvik"),
("America/Iqaluit", "America/Iqaluit"),
("America/Jamaica", "America/Jamaica"),
("America/Jujuy", "America/Jujuy"),
("America/Juneau", "America/Juneau"),
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
("America/Knox_IN", "America/Knox_IN"),
("America/Kralendijk", "America/Kralendijk"),
("America/La_Paz", "America/La_Paz"),
("America/Lima", "America/Lima"),
("America/Los_Angeles", "America/Los_Angeles"),
("America/Louisville", "America/Louisville"),
("America/Lower_Princes", "America/Lower_Princes"),
("America/Maceio", "America/Maceio"),
("America/Managua", "America/Managua"),
("America/Manaus", "America/Manaus"),
("America/Marigot", "America/Marigot"),
("America/Martinique", "America/Martinique"),
("America/Matamoros", "America/Matamoros"),
("America/Mazatlan", "America/Mazatlan"),
("America/Mendoza", "America/Mendoza"),
("America/Menominee", "America/Menominee"),
("America/Merida", "America/Merida"),
("America/Metlakatla", "America/Metlakatla"),
("America/Mexico_City", "America/Mexico_City"),
("America/Miquelon", "America/Miquelon"),
("America/Moncton", "America/Moncton"),
("America/Monterrey", "America/Monterrey"),
("America/Montevideo", "America/Montevideo"),
("America/Montreal", "America/Montreal"),
("America/Montserrat", "America/Montserrat"),
("America/Nassau", "America/Nassau"),
("America/New_York", "America/New_York"),
("America/Nipigon", "America/Nipigon"),
("America/Nome", "America/Nome"),
("America/Noronha", "America/Noronha"),
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
("America/North_Dakota/Center", "America/North_Dakota/Center"),
("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"),
("America/Nuuk", "America/Nuuk"),
("America/Ojinaga", "America/Ojinaga"),
("America/Panama", "America/Panama"),
("America/Pangnirtung", "America/Pangnirtung"),
("America/Paramaribo", "America/Paramaribo"),
("America/Phoenix", "America/Phoenix"),
("America/Port-au-Prince", "America/Port-au-Prince"),
("America/Port_of_Spain", "America/Port_of_Spain"),
("America/Porto_Acre", "America/Porto_Acre"),
("America/Porto_Velho", "America/Porto_Velho"),
("America/Puerto_Rico", "America/Puerto_Rico"),
("America/Punta_Arenas", "America/Punta_Arenas"),
("America/Rainy_River", "America/Rainy_River"),
("America/Rankin_Inlet", "America/Rankin_Inlet"),
("America/Recife", "America/Recife"),
("America/Regina", "America/Regina"),
("America/Resolute", "America/Resolute"),
("America/Rio_Branco", "America/Rio_Branco"),
("America/Rosario", "America/Rosario"),
("America/Santa_Isabel", "America/Santa_Isabel"),
("America/Santarem", "America/Santarem"),
("America/Santiago", "America/Santiago"),
("America/Santo_Domingo", "America/Santo_Domingo"),
("America/Sao_Paulo", "America/Sao_Paulo"),
("America/Scoresbysund", "America/Scoresbysund"),
("America/Shiprock", "America/Shiprock"),
("America/Sitka", "America/Sitka"),
("America/St_Barthelemy", "America/St_Barthelemy"),
("America/St_Johns", "America/St_Johns"),
("America/St_Kitts", "America/St_Kitts"),
("America/St_Lucia", "America/St_Lucia"),
("America/St_Thomas", "America/St_Thomas"),
("America/St_Vincent", "America/St_Vincent"),
("America/Swift_Current", "America/Swift_Current"),
("America/Tegucigalpa", "America/Tegucigalpa"),
("America/Thule", "America/Thule"),
("America/Thunder_Bay", "America/Thunder_Bay"),
("America/Tijuana", "America/Tijuana"),
("America/Toronto", "America/Toronto"),
("America/Tortola", "America/Tortola"),
("America/Vancouver", "America/Vancouver"),
("America/Virgin", "America/Virgin"),
("America/Whitehorse", "America/Whitehorse"),
("America/Winnipeg", "America/Winnipeg"),
("America/Yakutat", "America/Yakutat"),
("America/Yellowknife", "America/Yellowknife"),
("Antarctica/Casey", "Antarctica/Casey"),
("Antarctica/Davis", "Antarctica/Davis"),
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
("Antarctica/Macquarie", "Antarctica/Macquarie"),
("Antarctica/Mawson", "Antarctica/Mawson"),
("Antarctica/McMurdo", "Antarctica/McMurdo"),
("Antarctica/Palmer", "Antarctica/Palmer"),
("Antarctica/Rothera", "Antarctica/Rothera"),
("Antarctica/South_Pole", "Antarctica/South_Pole"),
("Antarctica/Syowa", "Antarctica/Syowa"),
("Antarctica/Troll", "Antarctica/Troll"),
("Antarctica/Vostok", "Antarctica/Vostok"),
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
("Asia/Aden", "Asia/Aden"),
("Asia/Almaty", "Asia/Almaty"),
("Asia/Amman", "Asia/Amman"),
("Asia/Anadyr", "Asia/Anadyr"),
("Asia/Aqtau", "Asia/Aqtau"),
("Asia/Aqtobe", "Asia/Aqtobe"),
("Asia/Ashgabat", "Asia/Ashgabat"),
("Asia/Ashkhabad", "Asia/Ashkhabad"),
("Asia/Atyrau", "Asia/Atyrau"),
("Asia/Baghdad", "Asia/Baghdad"),
("Asia/Bahrain", "Asia/Bahrain"),
("Asia/Baku", "Asia/Baku"),
("Asia/Bangkok", "Asia/Bangkok"),
("Asia/Barnaul", "Asia/Barnaul"),
("Asia/Beirut", "Asia/Beirut"),
("Asia/Bishkek", "Asia/Bishkek"),
("Asia/Brunei", "Asia/Brunei"),
("Asia/Calcutta", "Asia/Calcutta"),
("Asia/Chita", "Asia/Chita"),
("Asia/Choibalsan", "Asia/Choibalsan"),
("Asia/Chongqing", "Asia/Chongqing"),
("Asia/Chungking", "Asia/Chungking"),
("Asia/Colombo", "Asia/Colombo"),
("Asia/Dacca", "Asia/Dacca"),
("Asia/Damascus", "Asia/Damascus"),
("Asia/Dhaka", "Asia/Dhaka"),
("Asia/Dili", "Asia/Dili"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Dushanbe", "Asia/Dushanbe"),
("Asia/Famagusta", "Asia/Famagusta"),
("Asia/Gaza", "Asia/Gaza"),
("Asia/Harbin", "Asia/Harbin"),
("Asia/Hebron", "Asia/Hebron"),
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
("Asia/Hong_Kong", "Asia/Hong_Kong"),
("Asia/Hovd", "Asia/Hovd"),
("Asia/Irkutsk", "Asia/Irkutsk"),
("Asia/Istanbul", "Asia/Istanbul"),
("Asia/Jakarta", "Asia/Jakarta"),
("Asia/Jayapura", "Asia/Jayapura"),
("Asia/Jerusalem", "Asia/Jerusalem"),
("Asia/Kabul", "Asia/Kabul"),
("Asia/Kamchatka", "Asia/Kamchatka"),
("Asia/Karachi", "Asia/Karachi"),
("Asia/Kashgar", "Asia/Kashgar"),
("Asia/Kathmandu", "Asia/Kathmandu"),
("Asia/Katmandu", "Asia/Katmandu"),
("Asia/Khandyga", "Asia/Khandyga"),
("Asia/Kolkata", "Asia/Kolkata"),
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
("Asia/Kuching", "Asia/Kuching"),
("Asia/Kuwait", "Asia/Kuwait"),
("Asia/Macao", "Asia/Macao"),
("Asia/Macau", "Asia/Macau"),
("Asia/Magadan", "Asia/Magadan"),
("Asia/Makassar", "Asia/Makassar"),
("Asia/Manila", "Asia/Manila"),
("Asia/Muscat", "Asia/Muscat"),
("Asia/Nicosia", "Asia/Nicosia"),
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
("Asia/Novosibirsk", "Asia/Novosibirsk"),
("Asia/Omsk", "Asia/Omsk"),
("Asia/Oral", "Asia/Oral"),
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
("Asia/Pontianak", "Asia/Pontianak"),
("Asia/Pyongyang", "Asia/Pyongyang"),
("Asia/Qatar", "Asia/Qatar"),
("Asia/Qostanay", "Asia/Qostanay"),
("Asia/Qyzylorda", "Asia/Qyzylorda"),
("Asia/Rangoon", "Asia/Rangoon"),
("Asia/Riyadh", "Asia/Riyadh"),
("Asia/Saigon", "Asia/Saigon"),
("Asia/Sakhalin", "Asia/Sakhalin"),
("Asia/Samarkand", "Asia/Samarkand"),
("Asia/Seoul", "Asia/Seoul"),
("Asia/Shanghai", "Asia/Shanghai"),
("Asia/Singapore", "Asia/Singapore"),
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
("Asia/Taipei", "Asia/Taipei"),
("Asia/Tashkent", "Asia/Tashkent"),
("Asia/Tbilisi", "Asia/Tbilisi"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
("Asia/Thimbu", "Asia/Thimbu"),
("Asia/Thimphu", "Asia/Thimphu"),
("Asia/Tokyo", "Asia/Tokyo"),
("Asia/Tomsk", "Asia/Tomsk"),
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
("Asia/Urumqi", "Asia/Urumqi"),
("Asia/Ust-Nera", "Asia/Ust-Nera"),
("Asia/Vientiane", "Asia/Vientiane"),
("Asia/Vladivostok", "Asia/Vladivostok"),
("Asia/Yakutsk", "Asia/Yakutsk"),
("Asia/Yangon", "Asia/Yangon"),
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
("Asia/Yerevan", "Asia/Yerevan"),
("Atlantic/Azores", "Atlantic/Azores"),
("Atlantic/Bermuda", "Atlantic/Bermuda"),
("Atlantic/Canary", "Atlantic/Canary"),
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
("Atlantic/Faeroe", "Atlantic/Faeroe"),
("Atlantic/Faroe", "Atlantic/Faroe"),
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
("Atlantic/Madeira", "Atlantic/Madeira"),
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
("Atlantic/St_Helena", "Atlantic/St_Helena"),
("Atlantic/Stanley", "Atlantic/Stanley"),
("Australia/ACT", "Australia/ACT"),
("Australia/Adelaide", "Australia/Adelaide"),
("Australia/Brisbane", "Australia/Brisbane"),
("Australia/Broken_Hill", "Australia/Broken_Hill"),
("Australia/Canberra", "Australia/Canberra"),
("Australia/Currie", "Australia/Currie"),
("Australia/Darwin", "Australia/Darwin"),
("Australia/Eucla", "Australia/Eucla"),
("Australia/Hobart", "Australia/Hobart"),
("Australia/LHI", "Australia/LHI"),
("Australia/Lindeman", "Australia/Lindeman"),
("Australia/Lord_Howe", "Australia/Lord_Howe"),
("Australia/Melbourne", "Australia/Melbourne"),
("Australia/NSW", "Australia/NSW"),
("Australia/North", "Australia/North"),
("Australia/Perth", "Australia/Perth"),
("Australia/Queensland", "Australia/Queensland"),
("Australia/South", "Australia/South"),
("Australia/Sydney", "Australia/Sydney"),
("Australia/Tasmania", "Australia/Tasmania"),
("Australia/Victoria", "Australia/Victoria"),
("Australia/West", "Australia/West"),
("Australia/Yancowinna", "Australia/Yancowinna"),
("Brazil/Acre", "Brazil/Acre"),
("Brazil/DeNoronha", "Brazil/DeNoronha"),
("Brazil/East", "Brazil/East"),
("Brazil/West", "Brazil/West"),
("CET", "CET"),
("CST6CDT", "CST6CDT"),
("Canada/Atlantic", "Canada/Atlantic"),
("Canada/Central", "Canada/Central"),
("Canada/Eastern", "Canada/Eastern"),
("Canada/Mountain", "Canada/Mountain"),
("Canada/Newfoundland", "Canada/Newfoundland"),
("Canada/Pacific", "Canada/Pacific"),
("Canada/Saskatchewan", "Canada/Saskatchewan"),
("Canada/Yukon", "Canada/Yukon"),
("Chile/Continental", "Chile/Continental"),
("Chile/EasterIsland", "Chile/EasterIsland"),
("Cuba", "Cuba"),
("EET", "EET"),
("EST", "EST"),
("EST5EDT", "EST5EDT"),
("Egypt", "Egypt"),
("Eire", "Eire"),
("Etc/GMT", "Etc/GMT"),
("Etc/GMT+0", "Etc/GMT+0"),
("Etc/GMT+1", "Etc/GMT+1"),
("Etc/GMT+10", "Etc/GMT+10"),
("Etc/GMT+11", "Etc/GMT+11"),
("Etc/GMT+12", "Etc/GMT+12"),
("Etc/GMT+2", "Etc/GMT+2"),
("Etc/GMT+3", "Etc/GMT+3"),
("Etc/GMT+4", "Etc/GMT+4"),
("Etc/GMT+5", "Etc/GMT+5"),
("Etc/GMT+6", "Etc/GMT+6"),
("Etc/GMT+7", "Etc/GMT+7"),
("Etc/GMT+8", "Etc/GMT+8"),
("Etc/GMT+9", "Etc/GMT+9"),
("Etc/GMT-0", "Etc/GMT-0"),
("Etc/GMT-1", "Etc/GMT-1"),
("Etc/GMT-10", "Etc/GMT-10"),
("Etc/GMT-11", "Etc/GMT-11"),
("Etc/GMT-12", "Etc/GMT-12"),
("Etc/GMT-13", "Etc/GMT-13"),
("Etc/GMT-14", "Etc/GMT-14"),
("Etc/GMT-2", "Etc/GMT-2"),
("Etc/GMT-3", "Etc/GMT-3"),
("Etc/GMT-4", "Etc/GMT-4"),
("Etc/GMT-5", "Etc/GMT-5"),
("Etc/GMT-6", "Etc/GMT-6"),
("Etc/GMT-7", "Etc/GMT-7"),
("Etc/GMT-8", "Etc/GMT-8"),
("Etc/GMT-9", "Etc/GMT-9"),
("Etc/GMT0", "Etc/GMT0"),
("Etc/Greenwich", "Etc/Greenwich"),
("Etc/UCT", "Etc/UCT"),
("Etc/UTC", "Etc/UTC"),
("Etc/Universal", "Etc/Universal"),
("Etc/Zulu", "Etc/Zulu"),
("Europe/Amsterdam", "Europe/Amsterdam"),
("Europe/Andorra", "Europe/Andorra"),
("Europe/Astrakhan", "Europe/Astrakhan"),
("Europe/Athens", "Europe/Athens"),
("Europe/Belfast", "Europe/Belfast"),
("Europe/Belgrade", "Europe/Belgrade"),
("Europe/Berlin", "Europe/Berlin"),
("Europe/Bratislava", "Europe/Bratislava"),
("Europe/Brussels", "Europe/Brussels"),
("Europe/Bucharest", "Europe/Bucharest"),
("Europe/Budapest", "Europe/Budapest"),
("Europe/Busingen", "Europe/Busingen"),
("Europe/Chisinau", "Europe/Chisinau"),
("Europe/Copenhagen", "Europe/Copenhagen"),
("Europe/Dublin", "Europe/Dublin"),
("Europe/Gibraltar", "Europe/Gibraltar"),
("Europe/Guernsey", "Europe/Guernsey"),
("Europe/Helsinki", "Europe/Helsinki"),
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
("Europe/Istanbul", "Europe/Istanbul"),
("Europe/Jersey", "Europe/Jersey"),
("Europe/Kaliningrad", "Europe/Kaliningrad"),
("Europe/Kiev", "Europe/Kiev"),
("Europe/Kirov", "Europe/Kirov"),
("Europe/Kyiv", "Europe/Kyiv"),
("Europe/Lisbon", "Europe/Lisbon"),
("Europe/Ljubljana", "Europe/Ljubljana"),
("Europe/London", "Europe/London"),
("Europe/Luxembourg", "Europe/Luxembourg"),
("Europe/Madrid", "Europe/Madrid"),
("Europe/Malta", "Europe/Malta"),
("Europe/Mariehamn", "Europe/Mariehamn"),
("Europe/Minsk", "Europe/Minsk"),
("Europe/Monaco", "Europe/Monaco"),
("Europe/Moscow", "Europe/Moscow"),
("Europe/Nicosia", "Europe/Nicosia"),
("Europe/Oslo", "Europe/Oslo"),
("Europe/Paris", "Europe/Paris"),
("Europe/Podgorica", "Europe/Podgorica"),
("Europe/Prague", "Europe/Prague"),
("Europe/Riga", "Europe/Riga"),
("Europe/Rome", "Europe/Rome"),
("Europe/Samara", "Europe/Samara"),
("Europe/San_Marino", "Europe/San_Marino"),
("Europe/Sarajevo", "Europe/Sarajevo"),
("Europe/Saratov", "Europe/Saratov"),
("Europe/Simferopol", "Europe/Simferopol"),
("Europe/Skopje", "Europe/Skopje"),
("Europe/Sofia", "Europe/Sofia"),
("Europe/Stockholm", "Europe/Stockholm"),
("Europe/Tallinn", "Europe/Tallinn"),
("Europe/Tirane", "Europe/Tirane"),
("Europe/Tiraspol", "Europe/Tiraspol"),
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
("Europe/Uzhgorod", "Europe/Uzhgorod"),
("Europe/Vaduz", "Europe/Vaduz"),
("Europe/Vatican", "Europe/Vatican"),
("Europe/Vienna", "Europe/Vienna"),
("Europe/Vilnius", "Europe/Vilnius"),
("Europe/Volgograd", "Europe/Volgograd"),
("Europe/Warsaw", "Europe/Warsaw"),
("Europe/Zagreb", "Europe/Zagreb"),
("Europe/Zaporozhye", "Europe/Zaporozhye"),
("Europe/Zurich", "Europe/Zurich"),
("Factory", "Factory"),
("GB", "GB"),
("GB-Eire", "GB-Eire"),
("GMT", "GMT"),
("GMT+0", "GMT+0"),
("GMT-0", "GMT-0"),
("GMT0", "GMT0"),
("Greenwich", "Greenwich"),
("HST", "HST"),
("Hongkong", "Hongkong"),
("Iceland", "Iceland"),
("Indian/Antananarivo", "Indian/Antananarivo"),
("Indian/Chagos", "Indian/Chagos"),
("Indian/Christmas", "Indian/Christmas"),
("Indian/Cocos", "Indian/Cocos"),
("Indian/Comoro", "Indian/Comoro"),
("Indian/Kerguelen", "Indian/Kerguelen"),
("Indian/Mahe", "Indian/Mahe"),
("Indian/Maldives", "Indian/Maldives"),
("Indian/Mauritius", "Indian/Mauritius"),
("Indian/Mayotte", "Indian/Mayotte"),
("Indian/Reunion", "Indian/Reunion"),
("Iran", "Iran"),
("Israel", "Israel"),
("Jamaica", "Jamaica"),
("Japan", "Japan"),
("Kwajalein", "Kwajalein"),
("Libya", "Libya"),
("MET", "MET"),
("MST", "MST"),
("MST7MDT", "MST7MDT"),
("Mexico/BajaNorte", "Mexico/BajaNorte"),
("Mexico/BajaSur", "Mexico/BajaSur"),
("Mexico/General", "Mexico/General"),
("NZ", "NZ"),
("NZ-CHAT", "NZ-CHAT"),
("Navajo", "Navajo"),
("PRC", "PRC"),
("PST8PDT", "PST8PDT"),
("Pacific/Apia", "Pacific/Apia"),
("Pacific/Auckland", "Pacific/Auckland"),
("Pacific/Bougainville", "Pacific/Bougainville"),
("Pacific/Chatham", "Pacific/Chatham"),
("Pacific/Chuuk", "Pacific/Chuuk"),
("Pacific/Easter", "Pacific/Easter"),
("Pacific/Efate", "Pacific/Efate"),
("Pacific/Enderbury", "Pacific/Enderbury"),
("Pacific/Fakaofo", "Pacific/Fakaofo"),
("Pacific/Fiji", "Pacific/Fiji"),
("Pacific/Funafuti", "Pacific/Funafuti"),
("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Johnston", "Pacific/Johnston"),
("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"),
("Pacific/Kosrae", "Pacific/Kosrae"),
("Pacific/Kwajalein", "Pacific/Kwajalein"),
("Pacific/Majuro", "Pacific/Majuro"),
("Pacific/Marquesas", "Pacific/Marquesas"),
("Pacific/Midway", "Pacific/Midway"),
("Pacific/Nauru", "Pacific/Nauru"),
("Pacific/Niue", "Pacific/Niue"),
("Pacific/Norfolk", "Pacific/Norfolk"),
("Pacific/Noumea", "Pacific/Noumea"),
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
("Pacific/Palau", "Pacific/Palau"),
("Pacific/Pitcairn", "Pacific/Pitcairn"),
("Pacific/Pohnpei", "Pacific/Pohnpei"),
("Pacific/Ponape", "Pacific/Ponape"),
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
("Pacific/Rarotonga", "Pacific/Rarotonga"),
("Pacific/Saipan", "Pacific/Saipan"),
("Pacific/Samoa", "Pacific/Samoa"),
("Pacific/Tahiti", "Pacific/Tahiti"),
("Pacific/Tarawa", "Pacific/Tarawa"),
("Pacific/Tongatapu", "Pacific/Tongatapu"),
("Pacific/Truk", "Pacific/Truk"),
("Pacific/Wake", "Pacific/Wake"),
("Pacific/Wallis", "Pacific/Wallis"),
("Pacific/Yap", "Pacific/Yap"),
("Poland", "Poland"),
("Portugal", "Portugal"),
("ROC", "ROC"),
("ROK", "ROK"),
("Singapore", "Singapore"),
("Turkey", "Turkey"),
("UCT", "UCT"),
("US/Alaska", "US/Alaska"),
("US/Aleutian", "US/Aleutian"),
("US/Arizona", "US/Arizona"),
("US/Central", "US/Central"),
("US/East-Indiana", "US/East-Indiana"),
("US/Eastern", "US/Eastern"),
("US/Hawaii", "US/Hawaii"),
("US/Indiana-Starke", "US/Indiana-Starke"),
("US/Michigan", "US/Michigan"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Samoa", "US/Samoa"),
("UTC", "UTC"),
("Universal", "Universal"),
("W-SU", "W-SU"),
("WET", "WET"),
("Zulu", "Zulu"),
("localtime", "localtime"),
],
default=aircox.models.schedule.current_timezone_key,
help_text="timezone used for the date",
max_length=100,
verbose_name="timezone",
),
),
]

View File

@ -0,0 +1,634 @@
# Generated by Django 4.2.9 on 2024-03-15 19:56
import aircox.models.schedule
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("filer", "0017_image__transparent"),
("aircox", "0022_set_group_ownership"),
]
operations = [
migrations.AddField(
model_name="station",
name="legal_label",
field=models.CharField(
blank=True,
default="",
help_text="Displayed at the bottom of pages.",
max_length=64,
verbose_name="Legal label",
),
),
migrations.AlterField(
model_name="schedule",
name="timezone",
field=models.CharField(
choices=[
("Africa/Abidjan", "Africa/Abidjan"),
("Africa/Accra", "Africa/Accra"),
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
("Africa/Algiers", "Africa/Algiers"),
("Africa/Asmara", "Africa/Asmara"),
("Africa/Asmera", "Africa/Asmera"),
("Africa/Bamako", "Africa/Bamako"),
("Africa/Bangui", "Africa/Bangui"),
("Africa/Banjul", "Africa/Banjul"),
("Africa/Bissau", "Africa/Bissau"),
("Africa/Blantyre", "Africa/Blantyre"),
("Africa/Brazzaville", "Africa/Brazzaville"),
("Africa/Bujumbura", "Africa/Bujumbura"),
("Africa/Cairo", "Africa/Cairo"),
("Africa/Casablanca", "Africa/Casablanca"),
("Africa/Ceuta", "Africa/Ceuta"),
("Africa/Conakry", "Africa/Conakry"),
("Africa/Dakar", "Africa/Dakar"),
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
("Africa/Djibouti", "Africa/Djibouti"),
("Africa/Douala", "Africa/Douala"),
("Africa/El_Aaiun", "Africa/El_Aaiun"),
("Africa/Freetown", "Africa/Freetown"),
("Africa/Gaborone", "Africa/Gaborone"),
("Africa/Harare", "Africa/Harare"),
("Africa/Johannesburg", "Africa/Johannesburg"),
("Africa/Juba", "Africa/Juba"),
("Africa/Kampala", "Africa/Kampala"),
("Africa/Khartoum", "Africa/Khartoum"),
("Africa/Kigali", "Africa/Kigali"),
("Africa/Kinshasa", "Africa/Kinshasa"),
("Africa/Lagos", "Africa/Lagos"),
("Africa/Libreville", "Africa/Libreville"),
("Africa/Lome", "Africa/Lome"),
("Africa/Luanda", "Africa/Luanda"),
("Africa/Lubumbashi", "Africa/Lubumbashi"),
("Africa/Lusaka", "Africa/Lusaka"),
("Africa/Malabo", "Africa/Malabo"),
("Africa/Maputo", "Africa/Maputo"),
("Africa/Maseru", "Africa/Maseru"),
("Africa/Mbabane", "Africa/Mbabane"),
("Africa/Mogadishu", "Africa/Mogadishu"),
("Africa/Monrovia", "Africa/Monrovia"),
("Africa/Nairobi", "Africa/Nairobi"),
("Africa/Ndjamena", "Africa/Ndjamena"),
("Africa/Niamey", "Africa/Niamey"),
("Africa/Nouakchott", "Africa/Nouakchott"),
("Africa/Ouagadougou", "Africa/Ouagadougou"),
("Africa/Porto-Novo", "Africa/Porto-Novo"),
("Africa/Sao_Tome", "Africa/Sao_Tome"),
("Africa/Timbuktu", "Africa/Timbuktu"),
("Africa/Tripoli", "Africa/Tripoli"),
("Africa/Tunis", "Africa/Tunis"),
("Africa/Windhoek", "Africa/Windhoek"),
("America/Adak", "America/Adak"),
("America/Anchorage", "America/Anchorage"),
("America/Anguilla", "America/Anguilla"),
("America/Antigua", "America/Antigua"),
("America/Araguaina", "America/Araguaina"),
("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"),
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
("America/Argentina/ComodRivadavia", "America/Argentina/ComodRivadavia"),
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
("America/Argentina/Rio_Gallegos", "America/Argentina/Rio_Gallegos"),
("America/Argentina/Salta", "America/Argentina/Salta"),
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
("America/Aruba", "America/Aruba"),
("America/Asuncion", "America/Asuncion"),
("America/Atikokan", "America/Atikokan"),
("America/Atka", "America/Atka"),
("America/Bahia", "America/Bahia"),
("America/Bahia_Banderas", "America/Bahia_Banderas"),
("America/Barbados", "America/Barbados"),
("America/Belem", "America/Belem"),
("America/Belize", "America/Belize"),
("America/Blanc-Sablon", "America/Blanc-Sablon"),
("America/Boa_Vista", "America/Boa_Vista"),
("America/Bogota", "America/Bogota"),
("America/Boise", "America/Boise"),
("America/Buenos_Aires", "America/Buenos_Aires"),
("America/Cambridge_Bay", "America/Cambridge_Bay"),
("America/Campo_Grande", "America/Campo_Grande"),
("America/Cancun", "America/Cancun"),
("America/Caracas", "America/Caracas"),
("America/Catamarca", "America/Catamarca"),
("America/Cayenne", "America/Cayenne"),
("America/Cayman", "America/Cayman"),
("America/Chicago", "America/Chicago"),
("America/Chihuahua", "America/Chihuahua"),
("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
("America/Coral_Harbour", "America/Coral_Harbour"),
("America/Cordoba", "America/Cordoba"),
("America/Costa_Rica", "America/Costa_Rica"),
("America/Creston", "America/Creston"),
("America/Cuiaba", "America/Cuiaba"),
("America/Curacao", "America/Curacao"),
("America/Danmarkshavn", "America/Danmarkshavn"),
("America/Dawson", "America/Dawson"),
("America/Dawson_Creek", "America/Dawson_Creek"),
("America/Denver", "America/Denver"),
("America/Detroit", "America/Detroit"),
("America/Dominica", "America/Dominica"),
("America/Edmonton", "America/Edmonton"),
("America/Eirunepe", "America/Eirunepe"),
("America/El_Salvador", "America/El_Salvador"),
("America/Ensenada", "America/Ensenada"),
("America/Fort_Nelson", "America/Fort_Nelson"),
("America/Fort_Wayne", "America/Fort_Wayne"),
("America/Fortaleza", "America/Fortaleza"),
("America/Glace_Bay", "America/Glace_Bay"),
("America/Godthab", "America/Godthab"),
("America/Goose_Bay", "America/Goose_Bay"),
("America/Grand_Turk", "America/Grand_Turk"),
("America/Grenada", "America/Grenada"),
("America/Guadeloupe", "America/Guadeloupe"),
("America/Guatemala", "America/Guatemala"),
("America/Guayaquil", "America/Guayaquil"),
("America/Guyana", "America/Guyana"),
("America/Halifax", "America/Halifax"),
("America/Havana", "America/Havana"),
("America/Hermosillo", "America/Hermosillo"),
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
("America/Indiana/Knox", "America/Indiana/Knox"),
("America/Indiana/Marengo", "America/Indiana/Marengo"),
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
("America/Indiana/Vevay", "America/Indiana/Vevay"),
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
("America/Indiana/Winamac", "America/Indiana/Winamac"),
("America/Indianapolis", "America/Indianapolis"),
("America/Inuvik", "America/Inuvik"),
("America/Iqaluit", "America/Iqaluit"),
("America/Jamaica", "America/Jamaica"),
("America/Jujuy", "America/Jujuy"),
("America/Juneau", "America/Juneau"),
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
("America/Knox_IN", "America/Knox_IN"),
("America/Kralendijk", "America/Kralendijk"),
("America/La_Paz", "America/La_Paz"),
("America/Lima", "America/Lima"),
("America/Los_Angeles", "America/Los_Angeles"),
("America/Louisville", "America/Louisville"),
("America/Lower_Princes", "America/Lower_Princes"),
("America/Maceio", "America/Maceio"),
("America/Managua", "America/Managua"),
("America/Manaus", "America/Manaus"),
("America/Marigot", "America/Marigot"),
("America/Martinique", "America/Martinique"),
("America/Matamoros", "America/Matamoros"),
("America/Mazatlan", "America/Mazatlan"),
("America/Mendoza", "America/Mendoza"),
("America/Menominee", "America/Menominee"),
("America/Merida", "America/Merida"),
("America/Metlakatla", "America/Metlakatla"),
("America/Mexico_City", "America/Mexico_City"),
("America/Miquelon", "America/Miquelon"),
("America/Moncton", "America/Moncton"),
("America/Monterrey", "America/Monterrey"),
("America/Montevideo", "America/Montevideo"),
("America/Montreal", "America/Montreal"),
("America/Montserrat", "America/Montserrat"),
("America/Nassau", "America/Nassau"),
("America/New_York", "America/New_York"),
("America/Nipigon", "America/Nipigon"),
("America/Nome", "America/Nome"),
("America/Noronha", "America/Noronha"),
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
("America/North_Dakota/Center", "America/North_Dakota/Center"),
("America/North_Dakota/New_Salem", "America/North_Dakota/New_Salem"),
("America/Nuuk", "America/Nuuk"),
("America/Ojinaga", "America/Ojinaga"),
("America/Panama", "America/Panama"),
("America/Pangnirtung", "America/Pangnirtung"),
("America/Paramaribo", "America/Paramaribo"),
("America/Phoenix", "America/Phoenix"),
("America/Port-au-Prince", "America/Port-au-Prince"),
("America/Port_of_Spain", "America/Port_of_Spain"),
("America/Porto_Acre", "America/Porto_Acre"),
("America/Porto_Velho", "America/Porto_Velho"),
("America/Puerto_Rico", "America/Puerto_Rico"),
("America/Punta_Arenas", "America/Punta_Arenas"),
("America/Rainy_River", "America/Rainy_River"),
("America/Rankin_Inlet", "America/Rankin_Inlet"),
("America/Recife", "America/Recife"),
("America/Regina", "America/Regina"),
("America/Resolute", "America/Resolute"),
("America/Rio_Branco", "America/Rio_Branco"),
("America/Rosario", "America/Rosario"),
("America/Santa_Isabel", "America/Santa_Isabel"),
("America/Santarem", "America/Santarem"),
("America/Santiago", "America/Santiago"),
("America/Santo_Domingo", "America/Santo_Domingo"),
("America/Sao_Paulo", "America/Sao_Paulo"),
("America/Scoresbysund", "America/Scoresbysund"),
("America/Shiprock", "America/Shiprock"),
("America/Sitka", "America/Sitka"),
("America/St_Barthelemy", "America/St_Barthelemy"),
("America/St_Johns", "America/St_Johns"),
("America/St_Kitts", "America/St_Kitts"),
("America/St_Lucia", "America/St_Lucia"),
("America/St_Thomas", "America/St_Thomas"),
("America/St_Vincent", "America/St_Vincent"),
("America/Swift_Current", "America/Swift_Current"),
("America/Tegucigalpa", "America/Tegucigalpa"),
("America/Thule", "America/Thule"),
("America/Thunder_Bay", "America/Thunder_Bay"),
("America/Tijuana", "America/Tijuana"),
("America/Toronto", "America/Toronto"),
("America/Tortola", "America/Tortola"),
("America/Vancouver", "America/Vancouver"),
("America/Virgin", "America/Virgin"),
("America/Whitehorse", "America/Whitehorse"),
("America/Winnipeg", "America/Winnipeg"),
("America/Yakutat", "America/Yakutat"),
("America/Yellowknife", "America/Yellowknife"),
("Antarctica/Casey", "Antarctica/Casey"),
("Antarctica/Davis", "Antarctica/Davis"),
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
("Antarctica/Macquarie", "Antarctica/Macquarie"),
("Antarctica/Mawson", "Antarctica/Mawson"),
("Antarctica/McMurdo", "Antarctica/McMurdo"),
("Antarctica/Palmer", "Antarctica/Palmer"),
("Antarctica/Rothera", "Antarctica/Rothera"),
("Antarctica/South_Pole", "Antarctica/South_Pole"),
("Antarctica/Syowa", "Antarctica/Syowa"),
("Antarctica/Troll", "Antarctica/Troll"),
("Antarctica/Vostok", "Antarctica/Vostok"),
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
("Asia/Aden", "Asia/Aden"),
("Asia/Almaty", "Asia/Almaty"),
("Asia/Amman", "Asia/Amman"),
("Asia/Anadyr", "Asia/Anadyr"),
("Asia/Aqtau", "Asia/Aqtau"),
("Asia/Aqtobe", "Asia/Aqtobe"),
("Asia/Ashgabat", "Asia/Ashgabat"),
("Asia/Ashkhabad", "Asia/Ashkhabad"),
("Asia/Atyrau", "Asia/Atyrau"),
("Asia/Baghdad", "Asia/Baghdad"),
("Asia/Bahrain", "Asia/Bahrain"),
("Asia/Baku", "Asia/Baku"),
("Asia/Bangkok", "Asia/Bangkok"),
("Asia/Barnaul", "Asia/Barnaul"),
("Asia/Beirut", "Asia/Beirut"),
("Asia/Bishkek", "Asia/Bishkek"),
("Asia/Brunei", "Asia/Brunei"),
("Asia/Calcutta", "Asia/Calcutta"),
("Asia/Chita", "Asia/Chita"),
("Asia/Choibalsan", "Asia/Choibalsan"),
("Asia/Chongqing", "Asia/Chongqing"),
("Asia/Chungking", "Asia/Chungking"),
("Asia/Colombo", "Asia/Colombo"),
("Asia/Dacca", "Asia/Dacca"),
("Asia/Damascus", "Asia/Damascus"),
("Asia/Dhaka", "Asia/Dhaka"),
("Asia/Dili", "Asia/Dili"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Dushanbe", "Asia/Dushanbe"),
("Asia/Famagusta", "Asia/Famagusta"),
("Asia/Gaza", "Asia/Gaza"),
("Asia/Harbin", "Asia/Harbin"),
("Asia/Hebron", "Asia/Hebron"),
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
("Asia/Hong_Kong", "Asia/Hong_Kong"),
("Asia/Hovd", "Asia/Hovd"),
("Asia/Irkutsk", "Asia/Irkutsk"),
("Asia/Istanbul", "Asia/Istanbul"),
("Asia/Jakarta", "Asia/Jakarta"),
("Asia/Jayapura", "Asia/Jayapura"),
("Asia/Jerusalem", "Asia/Jerusalem"),
("Asia/Kabul", "Asia/Kabul"),
("Asia/Kamchatka", "Asia/Kamchatka"),
("Asia/Karachi", "Asia/Karachi"),
("Asia/Kashgar", "Asia/Kashgar"),
("Asia/Kathmandu", "Asia/Kathmandu"),
("Asia/Katmandu", "Asia/Katmandu"),
("Asia/Khandyga", "Asia/Khandyga"),
("Asia/Kolkata", "Asia/Kolkata"),
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
("Asia/Kuching", "Asia/Kuching"),
("Asia/Kuwait", "Asia/Kuwait"),
("Asia/Macao", "Asia/Macao"),
("Asia/Macau", "Asia/Macau"),
("Asia/Magadan", "Asia/Magadan"),
("Asia/Makassar", "Asia/Makassar"),
("Asia/Manila", "Asia/Manila"),
("Asia/Muscat", "Asia/Muscat"),
("Asia/Nicosia", "Asia/Nicosia"),
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
("Asia/Novosibirsk", "Asia/Novosibirsk"),
("Asia/Omsk", "Asia/Omsk"),
("Asia/Oral", "Asia/Oral"),
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
("Asia/Pontianak", "Asia/Pontianak"),
("Asia/Pyongyang", "Asia/Pyongyang"),
("Asia/Qatar", "Asia/Qatar"),
("Asia/Qostanay", "Asia/Qostanay"),
("Asia/Qyzylorda", "Asia/Qyzylorda"),
("Asia/Rangoon", "Asia/Rangoon"),
("Asia/Riyadh", "Asia/Riyadh"),
("Asia/Saigon", "Asia/Saigon"),
("Asia/Sakhalin", "Asia/Sakhalin"),
("Asia/Samarkand", "Asia/Samarkand"),
("Asia/Seoul", "Asia/Seoul"),
("Asia/Shanghai", "Asia/Shanghai"),
("Asia/Singapore", "Asia/Singapore"),
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
("Asia/Taipei", "Asia/Taipei"),
("Asia/Tashkent", "Asia/Tashkent"),
("Asia/Tbilisi", "Asia/Tbilisi"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
("Asia/Thimbu", "Asia/Thimbu"),
("Asia/Thimphu", "Asia/Thimphu"),
("Asia/Tokyo", "Asia/Tokyo"),
("Asia/Tomsk", "Asia/Tomsk"),
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
("Asia/Urumqi", "Asia/Urumqi"),
("Asia/Ust-Nera", "Asia/Ust-Nera"),
("Asia/Vientiane", "Asia/Vientiane"),
("Asia/Vladivostok", "Asia/Vladivostok"),
("Asia/Yakutsk", "Asia/Yakutsk"),
("Asia/Yangon", "Asia/Yangon"),
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
("Asia/Yerevan", "Asia/Yerevan"),
("Atlantic/Azores", "Atlantic/Azores"),
("Atlantic/Bermuda", "Atlantic/Bermuda"),
("Atlantic/Canary", "Atlantic/Canary"),
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
("Atlantic/Faeroe", "Atlantic/Faeroe"),
("Atlantic/Faroe", "Atlantic/Faroe"),
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
("Atlantic/Madeira", "Atlantic/Madeira"),
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
("Atlantic/St_Helena", "Atlantic/St_Helena"),
("Atlantic/Stanley", "Atlantic/Stanley"),
("Australia/ACT", "Australia/ACT"),
("Australia/Adelaide", "Australia/Adelaide"),
("Australia/Brisbane", "Australia/Brisbane"),
("Australia/Broken_Hill", "Australia/Broken_Hill"),
("Australia/Canberra", "Australia/Canberra"),
("Australia/Currie", "Australia/Currie"),
("Australia/Darwin", "Australia/Darwin"),
("Australia/Eucla", "Australia/Eucla"),
("Australia/Hobart", "Australia/Hobart"),
("Australia/LHI", "Australia/LHI"),
("Australia/Lindeman", "Australia/Lindeman"),
("Australia/Lord_Howe", "Australia/Lord_Howe"),
("Australia/Melbourne", "Australia/Melbourne"),
("Australia/NSW", "Australia/NSW"),
("Australia/North", "Australia/North"),
("Australia/Perth", "Australia/Perth"),
("Australia/Queensland", "Australia/Queensland"),
("Australia/South", "Australia/South"),
("Australia/Sydney", "Australia/Sydney"),
("Australia/Tasmania", "Australia/Tasmania"),
("Australia/Victoria", "Australia/Victoria"),
("Australia/West", "Australia/West"),
("Australia/Yancowinna", "Australia/Yancowinna"),
("Brazil/Acre", "Brazil/Acre"),
("Brazil/DeNoronha", "Brazil/DeNoronha"),
("Brazil/East", "Brazil/East"),
("Brazil/West", "Brazil/West"),
("CET", "CET"),
("CST6CDT", "CST6CDT"),
("Canada/Atlantic", "Canada/Atlantic"),
("Canada/Central", "Canada/Central"),
("Canada/Eastern", "Canada/Eastern"),
("Canada/Mountain", "Canada/Mountain"),
("Canada/Newfoundland", "Canada/Newfoundland"),
("Canada/Pacific", "Canada/Pacific"),
("Canada/Saskatchewan", "Canada/Saskatchewan"),
("Canada/Yukon", "Canada/Yukon"),
("Chile/Continental", "Chile/Continental"),
("Chile/EasterIsland", "Chile/EasterIsland"),
("Cuba", "Cuba"),
("EET", "EET"),
("EST", "EST"),
("EST5EDT", "EST5EDT"),
("Egypt", "Egypt"),
("Eire", "Eire"),
("Etc/GMT", "Etc/GMT"),
("Etc/GMT+0", "Etc/GMT+0"),
("Etc/GMT+1", "Etc/GMT+1"),
("Etc/GMT+10", "Etc/GMT+10"),
("Etc/GMT+11", "Etc/GMT+11"),
("Etc/GMT+12", "Etc/GMT+12"),
("Etc/GMT+2", "Etc/GMT+2"),
("Etc/GMT+3", "Etc/GMT+3"),
("Etc/GMT+4", "Etc/GMT+4"),
("Etc/GMT+5", "Etc/GMT+5"),
("Etc/GMT+6", "Etc/GMT+6"),
("Etc/GMT+7", "Etc/GMT+7"),
("Etc/GMT+8", "Etc/GMT+8"),
("Etc/GMT+9", "Etc/GMT+9"),
("Etc/GMT-0", "Etc/GMT-0"),
("Etc/GMT-1", "Etc/GMT-1"),
("Etc/GMT-10", "Etc/GMT-10"),
("Etc/GMT-11", "Etc/GMT-11"),
("Etc/GMT-12", "Etc/GMT-12"),
("Etc/GMT-13", "Etc/GMT-13"),
("Etc/GMT-14", "Etc/GMT-14"),
("Etc/GMT-2", "Etc/GMT-2"),
("Etc/GMT-3", "Etc/GMT-3"),
("Etc/GMT-4", "Etc/GMT-4"),
("Etc/GMT-5", "Etc/GMT-5"),
("Etc/GMT-6", "Etc/GMT-6"),
("Etc/GMT-7", "Etc/GMT-7"),
("Etc/GMT-8", "Etc/GMT-8"),
("Etc/GMT-9", "Etc/GMT-9"),
("Etc/GMT0", "Etc/GMT0"),
("Etc/Greenwich", "Etc/Greenwich"),
("Etc/UCT", "Etc/UCT"),
("Etc/UTC", "Etc/UTC"),
("Etc/Universal", "Etc/Universal"),
("Etc/Zulu", "Etc/Zulu"),
("Europe/Amsterdam", "Europe/Amsterdam"),
("Europe/Andorra", "Europe/Andorra"),
("Europe/Astrakhan", "Europe/Astrakhan"),
("Europe/Athens", "Europe/Athens"),
("Europe/Belfast", "Europe/Belfast"),
("Europe/Belgrade", "Europe/Belgrade"),
("Europe/Berlin", "Europe/Berlin"),
("Europe/Bratislava", "Europe/Bratislava"),
("Europe/Brussels", "Europe/Brussels"),
("Europe/Bucharest", "Europe/Bucharest"),
("Europe/Budapest", "Europe/Budapest"),
("Europe/Busingen", "Europe/Busingen"),
("Europe/Chisinau", "Europe/Chisinau"),
("Europe/Copenhagen", "Europe/Copenhagen"),
("Europe/Dublin", "Europe/Dublin"),
("Europe/Gibraltar", "Europe/Gibraltar"),
("Europe/Guernsey", "Europe/Guernsey"),
("Europe/Helsinki", "Europe/Helsinki"),
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
("Europe/Istanbul", "Europe/Istanbul"),
("Europe/Jersey", "Europe/Jersey"),
("Europe/Kaliningrad", "Europe/Kaliningrad"),
("Europe/Kiev", "Europe/Kiev"),
("Europe/Kirov", "Europe/Kirov"),
("Europe/Kyiv", "Europe/Kyiv"),
("Europe/Lisbon", "Europe/Lisbon"),
("Europe/Ljubljana", "Europe/Ljubljana"),
("Europe/London", "Europe/London"),
("Europe/Luxembourg", "Europe/Luxembourg"),
("Europe/Madrid", "Europe/Madrid"),
("Europe/Malta", "Europe/Malta"),
("Europe/Mariehamn", "Europe/Mariehamn"),
("Europe/Minsk", "Europe/Minsk"),
("Europe/Monaco", "Europe/Monaco"),
("Europe/Moscow", "Europe/Moscow"),
("Europe/Nicosia", "Europe/Nicosia"),
("Europe/Oslo", "Europe/Oslo"),
("Europe/Paris", "Europe/Paris"),
("Europe/Podgorica", "Europe/Podgorica"),
("Europe/Prague", "Europe/Prague"),
("Europe/Riga", "Europe/Riga"),
("Europe/Rome", "Europe/Rome"),
("Europe/Samara", "Europe/Samara"),
("Europe/San_Marino", "Europe/San_Marino"),
("Europe/Sarajevo", "Europe/Sarajevo"),
("Europe/Saratov", "Europe/Saratov"),
("Europe/Simferopol", "Europe/Simferopol"),
("Europe/Skopje", "Europe/Skopje"),
("Europe/Sofia", "Europe/Sofia"),
("Europe/Stockholm", "Europe/Stockholm"),
("Europe/Tallinn", "Europe/Tallinn"),
("Europe/Tirane", "Europe/Tirane"),
("Europe/Tiraspol", "Europe/Tiraspol"),
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
("Europe/Uzhgorod", "Europe/Uzhgorod"),
("Europe/Vaduz", "Europe/Vaduz"),
("Europe/Vatican", "Europe/Vatican"),
("Europe/Vienna", "Europe/Vienna"),
("Europe/Vilnius", "Europe/Vilnius"),
("Europe/Volgograd", "Europe/Volgograd"),
("Europe/Warsaw", "Europe/Warsaw"),
("Europe/Zagreb", "Europe/Zagreb"),
("Europe/Zaporozhye", "Europe/Zaporozhye"),
("Europe/Zurich", "Europe/Zurich"),
("Factory", "Factory"),
("GB", "GB"),
("GB-Eire", "GB-Eire"),
("GMT", "GMT"),
("GMT+0", "GMT+0"),
("GMT-0", "GMT-0"),
("GMT0", "GMT0"),
("Greenwich", "Greenwich"),
("HST", "HST"),
("Hongkong", "Hongkong"),
("Iceland", "Iceland"),
("Indian/Antananarivo", "Indian/Antananarivo"),
("Indian/Chagos", "Indian/Chagos"),
("Indian/Christmas", "Indian/Christmas"),
("Indian/Cocos", "Indian/Cocos"),
("Indian/Comoro", "Indian/Comoro"),
("Indian/Kerguelen", "Indian/Kerguelen"),
("Indian/Mahe", "Indian/Mahe"),
("Indian/Maldives", "Indian/Maldives"),
("Indian/Mauritius", "Indian/Mauritius"),
("Indian/Mayotte", "Indian/Mayotte"),
("Indian/Reunion", "Indian/Reunion"),
("Iran", "Iran"),
("Israel", "Israel"),
("Jamaica", "Jamaica"),
("Japan", "Japan"),
("Kwajalein", "Kwajalein"),
("Libya", "Libya"),
("MET", "MET"),
("MST", "MST"),
("MST7MDT", "MST7MDT"),
("Mexico/BajaNorte", "Mexico/BajaNorte"),
("Mexico/BajaSur", "Mexico/BajaSur"),
("Mexico/General", "Mexico/General"),
("NZ", "NZ"),
("NZ-CHAT", "NZ-CHAT"),
("Navajo", "Navajo"),
("PRC", "PRC"),
("PST8PDT", "PST8PDT"),
("Pacific/Apia", "Pacific/Apia"),
("Pacific/Auckland", "Pacific/Auckland"),
("Pacific/Bougainville", "Pacific/Bougainville"),
("Pacific/Chatham", "Pacific/Chatham"),
("Pacific/Chuuk", "Pacific/Chuuk"),
("Pacific/Easter", "Pacific/Easter"),
("Pacific/Efate", "Pacific/Efate"),
("Pacific/Enderbury", "Pacific/Enderbury"),
("Pacific/Fakaofo", "Pacific/Fakaofo"),
("Pacific/Fiji", "Pacific/Fiji"),
("Pacific/Funafuti", "Pacific/Funafuti"),
("Pacific/Galapagos", "Pacific/Galapagos"),
("Pacific/Gambier", "Pacific/Gambier"),
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
("Pacific/Guam", "Pacific/Guam"),
("Pacific/Honolulu", "Pacific/Honolulu"),
("Pacific/Johnston", "Pacific/Johnston"),
("Pacific/Kanton", "Pacific/Kanton"),
("Pacific/Kiritimati", "Pacific/Kiritimati"),
("Pacific/Kosrae", "Pacific/Kosrae"),
("Pacific/Kwajalein", "Pacific/Kwajalein"),
("Pacific/Majuro", "Pacific/Majuro"),
("Pacific/Marquesas", "Pacific/Marquesas"),
("Pacific/Midway", "Pacific/Midway"),
("Pacific/Nauru", "Pacific/Nauru"),
("Pacific/Niue", "Pacific/Niue"),
("Pacific/Norfolk", "Pacific/Norfolk"),
("Pacific/Noumea", "Pacific/Noumea"),
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
("Pacific/Palau", "Pacific/Palau"),
("Pacific/Pitcairn", "Pacific/Pitcairn"),
("Pacific/Pohnpei", "Pacific/Pohnpei"),
("Pacific/Ponape", "Pacific/Ponape"),
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
("Pacific/Rarotonga", "Pacific/Rarotonga"),
("Pacific/Saipan", "Pacific/Saipan"),
("Pacific/Samoa", "Pacific/Samoa"),
("Pacific/Tahiti", "Pacific/Tahiti"),
("Pacific/Tarawa", "Pacific/Tarawa"),
("Pacific/Tongatapu", "Pacific/Tongatapu"),
("Pacific/Truk", "Pacific/Truk"),
("Pacific/Wake", "Pacific/Wake"),
("Pacific/Wallis", "Pacific/Wallis"),
("Pacific/Yap", "Pacific/Yap"),
("Poland", "Poland"),
("Portugal", "Portugal"),
("ROC", "ROC"),
("ROK", "ROK"),
("Singapore", "Singapore"),
("Turkey", "Turkey"),
("UCT", "UCT"),
("US/Alaska", "US/Alaska"),
("US/Aleutian", "US/Aleutian"),
("US/Arizona", "US/Arizona"),
("US/Central", "US/Central"),
("US/East-Indiana", "US/East-Indiana"),
("US/Eastern", "US/Eastern"),
("US/Hawaii", "US/Hawaii"),
("US/Indiana-Starke", "US/Indiana-Starke"),
("US/Michigan", "US/Michigan"),
("US/Mountain", "US/Mountain"),
("US/Pacific", "US/Pacific"),
("US/Samoa", "US/Samoa"),
("UTC", "UTC"),
("Universal", "Universal"),
("W-SU", "W-SU"),
("WET", "WET"),
("Zulu", "Zulu"),
],
default=aircox.models.schedule.current_timezone_key,
help_text="timezone used for the date",
max_length=100,
verbose_name="timezone",
),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.9 on 2024-03-19 22:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("aircox", "0023_station_legal_label_alter_schedule_timezone"),
]
operations = [
migrations.RenameField(
model_name="usersettings",
old_name="playlist_editor_columns",
new_name="tracklist_editor_columns",
),
migrations.RenameField(
model_name="usersettings",
old_name="playlist_editor_sep",
new_name="tracklist_editor_sep",
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.9 on 2024-03-25 20:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0024_rename_playlist_editor_columns_usersettings_tracklist_editor_columns_and_more"),
]
operations = [
migrations.AddField(
model_name="sound",
name="is_removed",
field=models.BooleanField(default=False, help_text="file has been removed", verbose_name="removed"),
),
migrations.AlterField(
model_name="sound",
name="is_downloadable",
field=models.BooleanField(
default=False,
help_text="sound can be downloaded by visitors (sound must be public)",
verbose_name="downloadable",
),
),
migrations.AlterField(
model_name="sound",
name="is_public",
field=models.BooleanField(default=False, help_text="sound is available as podcast", verbose_name="public"),
),
migrations.AlterField(
model_name="sound",
name="type",
field=models.SmallIntegerField(choices=[(0, "other"), (1, "archive"), (2, "excerpt")], verbose_name="type"),
),
]

View File

@ -0,0 +1,162 @@
# Generated by Django 4.2.9 on 2024-03-26 02:53
import aircox.models.file
from django.db import migrations, models
import django.db.models.deletion
sounds_info = {}
def get_sounds_info(apps, schema_editor):
Sound = apps.get_model("aircox", "Sound")
objs = Sound.objects.filter().values(
"pk",
"episode_id",
"position",
"type",
)
sounds_info.update({obj["pk"]: obj for obj in objs})
def restore_sounds_info(apps, schema_editor):
try:
Sound = apps.get_model("aircox", "Sound")
EpisodeSound = apps.get_model("aircox", "EpisodeSound")
TYPE_ARCHIVE = 0x01
TYPE_REMOVED = 0x03
episode_sounds = []
sounds = []
for sound in Sound.objects.all():
info = sounds_info.get(sound.pk)
if not info:
continue
sound.broadcast = info["type"] == TYPE_ARCHIVE
sound.is_removed = info["type"] == TYPE_REMOVED
sounds.append(sound)
if not sound.is_removed and info["episode_id"]:
obj = EpisodeSound(
sound=sound,
episode_id=info["episode_id"],
position=info["position"],
broadcast=sound.broadcast,
)
episode_sounds.append(obj)
Sound.objects.bulk_update(sounds, ("broadcast", "is_removed"))
EpisodeSound.objects.bulk_create(episode_sounds)
print(f"\n>>> {len(sounds)} Sound have been updated.")
print(f">>> {len(episode_sounds)} EpisodeSound have been created.")
except Exception as err:
print(err)
import traceback
traceback.print_exc()
class Migration(migrations.Migration):
dependencies = [
("aircox", "0025_sound_is_removed_alter_sound_is_downloadable_and_more"),
]
operations = [
migrations.RunPython(get_sounds_info),
migrations.AlterModelOptions(
name="sound",
options={"verbose_name": "Sound file", "verbose_name_plural": "Sound files"},
),
migrations.RemoveField(
model_name="sound",
name="episode",
),
migrations.RemoveField(
model_name="sound",
name="position",
),
migrations.RemoveField(
model_name="sound",
name="type",
),
migrations.AddField(
model_name="sound",
name="broadcast",
field=models.BooleanField(
default=False, help_text="The sound is broadcasted on air", verbose_name="Broadcast"
),
),
migrations.AddField(
model_name="sound",
name="description",
field=models.TextField(blank=True, default="", max_length=256, verbose_name="description"),
),
migrations.AlterField(
model_name="sound",
name="file",
field=models.FileField(
db_index=True, max_length=256, upload_to=aircox.models.file.File._upload_to, verbose_name="file"
),
),
migrations.AlterField(
model_name="sound",
name="is_downloadable",
field=models.BooleanField(
default=False, help_text="sound can be downloaded by visitors", verbose_name="downloadable"
),
),
migrations.AlterField(
model_name="sound",
name="is_public",
field=models.BooleanField(default=False, help_text="file is publicly accessible", verbose_name="public"),
),
migrations.AlterField(
model_name="sound",
name="is_removed",
field=models.BooleanField(
db_index=True, default=False, help_text="file has been removed from server", verbose_name="removed"
),
),
migrations.AlterField(
model_name="sound",
name="name",
field=models.CharField(db_index=True, max_length=64, verbose_name="name"),
),
migrations.AlterField(
model_name="sound",
name="program",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="aircox.program",
verbose_name="Program",
),
),
migrations.CreateModel(
name="EpisodeSound",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"position",
models.PositiveSmallIntegerField(
default=0, help_text="position in the playlist", verbose_name="order"
),
),
(
"broadcast",
models.BooleanField(
blank=None, help_text="The sound is broadcasted on air", verbose_name="Broadcast"
),
),
("episode", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.episode")),
("sound", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="aircox.sound")),
],
options={
"verbose_name": "Episode Sound",
"verbose_name_plural": "Episode Sounds",
},
),
migrations.RunPython(restore_sounds_info),
]

View File

@ -0,0 +1,78 @@
# 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
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,28 +1,30 @@
from . import signals
from .article import Article
from .diffusion import Diffusion, DiffusionQuerySet
from .episode import Episode
from .episode import Episode, EpisodeSound
from .log import Log, LogQuerySet
from .page import Category, Comment, NavItem, Page, PageQuerySet, StaticPage
from .program import Program, ProgramChildQuerySet, ProgramQuerySet, Stream
from .schedule import Schedule
from .sound import Sound, SoundQuerySet, Track
from .sound import Sound, SoundQuerySet
from .station import Port, Station, StationQuerySet
from .track import Track
from .user_settings import UserSettings
__all__ = (
"signals",
"Article",
"Episode",
"Category",
"Comment",
"Diffusion",
"DiffusionQuerySet",
"Episode",
"EpisodeSound",
"Log",
"LogQuerySet",
"Category",
"PageQuerySet",
"Page",
"StaticPage",
"Comment",
"NavItem",
"Program",
"ProgramQuerySet",

View File

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

View File

@ -17,6 +17,10 @@ __all__ = ("Diffusion", "DiffusionQuerySet")
class DiffusionQuerySet(RerunQuerySet):
def editor(self, user):
episodes = Episode.objects.editor(user)
return self.filter(episode__in=episodes)
def episode(self, episode=None, id=None):
"""Diffusions for this episode."""
return self.filter(episode=episode) if id is None else self.filter(episode__id=id)
@ -89,6 +93,8 @@ class Diffusion(Rerun):
- stop: the diffusion has been manually stopped
"""
list_url_name = "timetable-list"
objects = DiffusionQuerySet.as_manager()
TYPE_ON_AIR = 0x00
@ -127,8 +133,6 @@ class Diffusion(Rerun):
# help_text = _('use this input port'),
# )
item_template_name = "aircox/widgets/diffusion_item.html"
class Meta:
verbose_name = _("Diffusion")
verbose_name_plural = _("Diffusions")
@ -192,34 +196,15 @@ class Diffusion(Rerun):
now = tz.now()
return self.type == self.TYPE_ON_AIR and self.start <= now and self.end >= now
@property
def is_today(self):
"""True if diffusion is currently today."""
return self.start.date() == datetime.date.today()
@property
def is_live(self):
"""True if Diffusion is live (False if there are sounds files)."""
return self.type == self.TYPE_ON_AIR and not self.episode.sound_set.archive().count()
def get_playlist(self, **types):
"""Returns sounds as a playlist (list of *local* archive file path).
The given arguments are passed to ``get_sounds``.
"""
from .sound import Sound
return list(
self.get_sounds(**types).filter(path__isnull=False, type=Sound.TYPE_ARCHIVE).values_list("path", flat=True)
)
def get_sounds(self, **types):
"""Return a queryset of sounds related to this diffusion, ordered by
type then path.
**types: filter on the given sound types name, as `archive=True`
"""
from .sound import Sound
sounds = (self.initial or self).sound_set.order_by("type", "path")
_in = [getattr(Sound.Type, name) for name, value in types.items() if value]
return sounds.filter(type__in=_in)
return self.type == self.TYPE_ON_AIR and not self.episode.episodesound_set.all().broadcast()
def is_date_in_range(self, date=None):
"""Return true if the given date is in the diffusion's start-end

View File

@ -1,55 +1,65 @@
import os
from django.conf import settings as d_settings
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from easy_thumbnails.files import get_thumbnailer
from aircox.conf import settings
from .page import Page
from .page import ChildPage
from .program import ProgramChildQuerySet
from .sound import Sound
__all__ = ("Episode",)
class Episode(Page):
objects = ProgramChildQuerySet.as_manager()
class EpisodeQuerySet(ProgramChildQuerySet):
def with_podcasts(self):
return self.filter(episodesound__sound__is_public=True).distinct()
class Episode(ChildPage):
objects = EpisodeQuerySet.as_manager()
detail_url_name = "episode-detail"
item_template_name = "aircox/widgets/episode_item.html"
list_url_name = "episode-list"
edit_url_name = "episode-edit"
template_prefix = "episode"
@property
def program(self):
return getattr(self.parent, "program", None)
@cached_property
def podcasts(self):
"""Return serialized data about podcasts."""
from ..serializers import PodcastSerializer
podcasts = [PodcastSerializer(s).data for s in self.sound_set.public().order_by("type")]
if self.cover:
options = {"size": (128, 128), "crop": "scale"}
cover = get_thumbnailer(self.cover).get_thumbnail(options).url
else:
cover = None
for index, podcast in enumerate(podcasts):
podcasts[index]["cover"] = cover
podcasts[index]["page_url"] = self.get_absolute_url()
podcasts[index]["page_title"] = self.title
return podcasts
return self.parent_subclass
@program.setter
def program(self, value):
self.parent = value
@cached_property
def podcasts(self):
"""Return serialized data about podcasts."""
query = self.episodesound_set.all().public().order_by("-broadcast", "position")
return self._to_podcasts(query)
@cached_property
def sounds(self):
"""Return serialized data about all related sounds."""
query = self.episodesound_set.all().order_by("-broadcast", "position")
return self._to_podcasts(query)
def _to_podcasts(self, query):
from ..serializers import EpisodeSoundSerializer as serializer_class
query = query.select_related("sound")
podcasts = [serializer_class(s).data for s in query]
for index, podcast in enumerate(podcasts):
podcasts[index]["page_url"] = self.get_absolute_url()
podcasts[index]["page_title"] = self.title
return podcasts
class Meta:
verbose_name = _("Episode")
verbose_name_plural = _("Episodes")
def get_absolute_url(self):
if not self.is_published:
return self.program.get_absolute_url()
return super().get_absolute_url()
def save(self, *args, **kwargs):
if self.parent is None:
raise ValueError("missing parent program")
@ -74,3 +84,54 @@ class Episode(Page):
else title
)
return super().get_init_kwargs_from(page, title=title, program=page, **kwargs)
class EpisodeSoundQuerySet(models.QuerySet):
def episode(self, episode):
if isinstance(episode, int):
return self.filter(episode_id=episode)
return self.filter(episode=episode)
def available(self):
return self.filter(sound__is_removed=False)
def public(self):
return self.filter(sound__is_public=True)
def broadcast(self):
return self.available().filter(broadcast=True)
def playlist(self, order="position"):
# TODO: subquery expression
if order:
self = self.order_by(order)
query = self.filter(sound__file__isnull=False, sound__is_removed=False).values_list("sound__file", flat=True)
return [os.path.join(d_settings.MEDIA_ROOT, file) for file in query]
class EpisodeSound(models.Model):
"""Element of an episode playlist."""
episode = models.ForeignKey(Episode, on_delete=models.CASCADE)
sound = models.ForeignKey(Sound, on_delete=models.CASCADE)
position = models.PositiveSmallIntegerField(
_("order"),
default=0,
help_text=_("position in the playlist"),
)
broadcast = models.BooleanField(
_("Broadcast"),
blank=None,
help_text=_("The sound is broadcasted on air"),
)
objects = EpisodeSoundQuerySet.as_manager()
class Meta:
verbose_name = _("Episode Sound")
verbose_name_plural = _("Episode Sounds")
def save(self, *args, **kwargs):
if self.broadcast is None:
self.broadcast = self.sound.broadcast
super().save(*args, **kwargs)

153
aircox/models/file.py Normal file
View File

@ -0,0 +1,153 @@
import os
from pathlib import Path
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django.utils import timezone as tz
from .program import Program
class FileQuerySet(models.QuerySet):
def station(self, station=None, id=None):
id = station.pk if id is None else id
return self.filter(program__station__id=id)
def available(self):
return self.exclude(is_removed=False)
def public(self):
"""Return sounds available as podcasts."""
return self.filter(is_public=True)
def path(self, paths):
if isinstance(paths, str):
return self.filter(file=paths.replace(settings.MEDIA_ROOT + "/", ""))
return self.filter(file__in=(p.replace(settings.MEDIA_ROOT + "/", "") for p in paths))
def search(self, query):
return self.filter(Q(name__icontains=query) | Q(file__icontains=query) | Q(program__title__icontains=query))
class File(models.Model):
def _upload_to(self, filename):
dir = self.program and self.program.path or self.default_upload_path
subdir = self.get_upload_dir()
if subdir:
return os.path.join(dir, subdir, filename)
return os.path.join(dir, filename)
program = models.ForeignKey(
Program,
models.SET_NULL,
verbose_name=_("Program"),
null=True,
blank=True,
)
file = models.FileField(
_("file"),
upload_to=_upload_to,
max_length=256,
db_index=True,
)
name = models.CharField(
_("name"),
max_length=64,
db_index=True,
)
description = models.TextField(
_("description"),
max_length=256,
blank=True,
default="",
)
mtime = models.DateTimeField(
_("modification time"),
blank=True,
null=True,
help_text=_("last modification date and time"),
)
is_public = models.BooleanField(
_("public"),
help_text=_("file is publicly accessible"),
default=False,
)
is_removed = models.BooleanField(
_("removed"),
help_text=_("file has been removed from server"),
default=False,
db_index=True,
)
class Meta:
abstract = True
objects = FileQuerySet.as_manager()
default_upload_path = Path(settings.MEDIA_ROOT)
"""Default upload directory when no program is provided."""
upload_dir = "uploads"
"""Upload sub-directory."""
@property
def url(self):
return self.file and self.file.url
def get_upload_dir(self):
return self.upload_dir
def get_mtime(self):
"""Get the last modification date from file."""
mtime = os.stat(self.file.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
def file_updated(self):
"""Return True when file has been updated on filesystem."""
exists = self.file_exists()
if self.is_removed != (not exists):
return True
return exists and self.mtime != self.get_mtime()
def file_exists(self):
"""Return true if the file still exists."""
return os.path.exists(self.file.path)
def sync_fs(self, on_update=False):
"""Sync model to file on the filesystem.
:param bool on_update: only check if `file_updated`.
:return True wether a change happened.
"""
if on_update and not self.file_updated():
return
# check on name/remove/modification time
name = self.name
if not self.name and self.file and self.file.name:
name = os.path.basename(self.file.name)
name = os.path.splitext(name)[0]
name = name.replace("_", " ").strip()
is_removed = not self.file_exists()
mtime = (not is_removed and self.get_mtime()) or None
changed = is_removed != self.is_removed or mtime != self.mtime or name != self.name
self.name, self.is_removed, self.mtime = name, is_removed, mtime
# read metadata
if changed and not self.is_removed:
metadata = self.read_metadata()
metadata and self.__dict__.update(metadata)
return changed
def read_metadata(self):
return {}
def save(self, sync=True, *args, **kwargs):
if sync and self.file_exists():
self.sync_fs(on_update=True)
super().save(*args, **kwargs)

View File

@ -1,5 +1,6 @@
import datetime
import logging
import operator
from collections import deque
from django.db import models
@ -7,8 +8,10 @@ from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from .diffusion import Diffusion
from .sound import Sound, Track
from .sound import Sound
from .station import Station
from .track import Track
from .page import Renderable
logger = logging.getLogger("aircox")
@ -30,6 +33,9 @@ class LogQuerySet(models.QuerySet):
def after(self, date):
return self.filter(date__gte=date) if isinstance(date, tz.datetime) else self.filter(date__date__gte=date)
def before(self, date):
return self.filter(date__lte=date) if isinstance(date, tz.datetime) else self.filter(date__date__lte=date)
def on_air(self):
return self.filter(type=Log.TYPE_ON_AIR)
@ -46,13 +52,15 @@ class LogQuerySet(models.QuerySet):
return self.filter(track__isnull=not with_it)
class Log(models.Model):
class Log(Renderable, models.Model):
"""Log sounds and diffusions that are played on the station.
This only remember what has been played on the outputs, not on each
source; Source designate here which source is responsible of that.
"""
template_prefix = "log"
TYPE_STOP = 0x00
"""Source has been stopped, e.g. manually."""
# Rule: \/ diffusion != null \/ sound != null
@ -160,21 +168,22 @@ class Log(models.Model):
object_list += [cls(obj) for obj in items]
@classmethod
def merge_diffusions(cls, logs, diffs, count=None):
def merge_diffusions(cls, logs, diffs, count=None, diff_count=None, group_logs=False):
"""Merge logs and diffusions together.
`logs` can either be a queryset or a list ordered by `Log.date`.
"""
# TODO: limit count
# FIXME: log may be iterable (in stats view)
if isinstance(logs, models.QuerySet):
logs = list(logs.order_by("-date"))
diffs = deque(diffs.on_air().before().order_by("-start"))
diffs = diffs.on_air().order_by("-start")
if diff_count:
diffs = diffs[:diff_count]
diffs = deque(diffs)
object_list = []
while True:
if not len(diffs):
object_list += logs
cls._append_logs(object_list, logs, len(logs), group=group_logs)
break
if not len(logs):
@ -184,13 +193,8 @@ class Log(models.Model):
diff = diffs.popleft()
# - takes all logs after diff start
index = next(
(i for i, v in enumerate(logs) if v.date <= diff.end),
len(logs),
)
if index is not None and index > 0:
object_list += logs[:index]
logs = logs[index:]
index = cls._next_index(logs, diff.end, len(logs), pred=operator.le)
cls._append_logs(object_list, logs, index, group=group_logs)
if len(logs):
# FIXME
@ -199,10 +203,7 @@ class Log(models.Model):
# object_list.append(logs[0])
# - skips logs while diff is running
index = next(
(i for i, v in enumerate(logs) if v.date < diff.start),
len(logs),
)
index = cls._next_index(logs, diff.start, len(logs))
if index is not None and index > 0:
logs = logs[index:]
@ -211,6 +212,40 @@ class Log(models.Model):
return object_list if count is None else object_list[:count]
@classmethod
def _next_index(cls, items, date, default, pred=operator.lt):
iter = (i for i, v in enumerate(items) if pred(v.date, date))
return next(iter, default)
@classmethod
def _append_logs(cls, object_list, logs, count, group=False):
logs = logs[:count]
if not logs:
return object_list
if group:
grouped = cls._group_logs_by_time(logs)
object_list.extend(grouped)
else:
object_list += logs
return object_list
@classmethod
def _group_logs_by_time(cls, logs):
last_time = -1
cum = []
for log in logs:
hour = log.date.time().hour
if hour != last_time:
if cum:
yield cum
cum = []
last_time = hour
# reverse from lowest to highest date
cum.insert(0, log)
if cum:
yield cum
def print(self):
r = []
if self.diffusion:

View File

@ -16,6 +16,7 @@ from model_utils.managers import InheritanceQuerySet
from .station import Station
__all__ = (
"Renderable",
"Category",
"PageQuerySet",
"Page",
@ -25,7 +26,17 @@ __all__ = (
)
headline_re = re.compile(r"(<p>)?" r"(?P<headline>[^\n]{1,140}(\n|[^\.]*?\.))" r"(</p>)?")
headline_clean_re = re.compile(r"\n(\s|&nbsp;)+", re.MULTILINE)
headline_re = re.compile(r"(?P<headline>([\S+]|\s+){1,240}\S+)", re.MULTILINE)
class Renderable:
template_prefix = "page"
template_name = "aircox/widgets/{prefix}.html"
def get_template_name(self, widget):
"""Return template name for the provided widget."""
return self.template_name.format(prefix=self.template_prefix, widget=widget)
class Category(models.Model):
@ -50,6 +61,9 @@ class BasePageQuerySet(InheritanceQuerySet):
def trash(self):
return self.filter(status=Page.STATUS_TRASH)
def by_last(self):
return self.order_by("-pub_date")
def parent(self, parent=None, id=None):
"""Return pages having this parent."""
return self.filter(parent=parent) if id is None else self.filter(parent__id=id)
@ -60,7 +74,7 @@ class BasePageQuerySet(InheritanceQuerySet):
return self.filter(title__icontains=q)
class BasePage(models.Model):
class BasePage(Renderable, models.Model):
"""Base class for publishable content."""
STATUS_DRAFT = 0x00
@ -72,14 +86,6 @@ class BasePage(models.Model):
(STATUS_TRASH, _("trash")),
)
parent = models.ForeignKey(
"self",
models.CASCADE,
blank=True,
null=True,
db_index=True,
related_name="child_set",
)
title = models.CharField(max_length=100)
slug = models.SlugField(_("slug"), max_length=120, blank=True, unique=True, db_index=True)
status = models.PositiveSmallIntegerField(
@ -102,11 +108,14 @@ class BasePage(models.Model):
objects = BasePageQuerySet.as_manager()
detail_url_name = None
item_template_name = "aircox/widgets/page_item.html"
class Meta:
abstract = True
@property
def cover_url(self):
return self.cover_id and self.cover.url
def __str__(self):
return "{}".format(self.title or self.pk)
@ -116,13 +125,12 @@ class BasePage(models.Model):
count = Page.objects.filter(slug__startswith=self.slug).count()
if count:
self.slug += "-" + str(count)
if self.parent and not self.cover:
self.cover = self.parent.cover
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse(self.detail_url_name, kwargs={"slug": self.slug}) if self.is_published else "#"
if self.is_published:
return reverse(self.detail_url_name, kwargs={"slug": self.slug})
return ""
@property
def is_draft(self):
@ -138,17 +146,35 @@ class BasePage(models.Model):
@property
def display_title(self):
if self.is_published():
return self.title
return self.parent.display_title()
return self.is_published and self.title or ""
@cached_property
def headline(self):
if not self.content:
return ""
def display_headline(self):
content = bleach.clean(self.content, tags=[], strip=True)
content = headline_clean_re.sub("\n", content)
if content.startswith("\n"):
content = content[1:]
headline = headline_re.search(content)
return mark_safe(headline.groupdict()["headline"]) if headline else ""
if not headline:
return ""
headline = headline.groupdict()["headline"]
suffix = "<b>...</b>" if len(headline) < len(content) else ""
headline = headline.split("\n")[:3]
headline[-1] += suffix
return mark_safe(" ".join(headline))
_url_re = re.compile(
"((http|https)\:\/\/)?[a-zA-Z0-9\.\/\?\:@\-_=#]+\.([a-zA-Z]){2,6}([a-zA-Z0-9\.\&\/\?\:@\-_=#])*"
)
@cached_property
def display_content(self):
if "<p>" in self.content:
return self.content
content = self._url_re.sub(r'<a href="\1" target="new">\1</a>', self.content)
return content.replace("\n\n", "\n").replace("\n", "<br>")
@classmethod
def get_init_kwargs_from(cls, page, **kwargs):
@ -161,6 +187,7 @@ class BasePage(models.Model):
return cls(**cls.get_init_kwargs_from(page, **kwargs))
# FIXME: rename
class PageQuerySet(BasePageQuerySet):
def published(self):
return self.filter(status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now())
@ -189,18 +216,67 @@ class Page(BasePage):
objects = PageQuerySet.as_manager()
detail_url_name = ""
list_url_name = "page-list"
edit_url_name = ""
@classmethod
def get_list_url(cls, kwargs={}):
return reverse(cls.list_url_name, kwargs=kwargs)
class Meta:
verbose_name = _("Publication")
verbose_name_plural = _("Publications")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__initial_cover = self.cover
def save(self, *args, **kwargs):
if self.is_published and self.pub_date is None:
self.pub_date = tz.now()
elif not self.is_published:
self.pub_date = None
super().save(*args, **kwargs)
if self.parent and not self.category:
self.category = self.parent.category
class ChildPage(Page):
parent = models.ForeignKey(Page, models.CASCADE, blank=True, null=True, db_index=True, related_name="%(class)s_set")
class Meta:
abstract = True
@property
def display_title(self):
if self.is_published:
return self.title
return self.parent and self.parent.title or ""
@property
def display_headline(self):
if not self.content or not self.is_published:
return self.parent and self.parent.display_headline or ""
return super().display_headline
@cached_property
def parent_subclass(self):
if self.parent_id:
return Page.objects.get_subclass(id=self.parent_id)
return None
def get_absolute_url(self):
if not self.is_published and self.parent_subclass:
return self.parent_subclass.get_absolute_url()
return super().get_absolute_url()
def save(self, *args, **kwargs):
if self.parent:
if self.parent == self:
self.parent = None
if not self.cover:
self.cover = self.parent.cover
if not self.category:
self.category = self.parent.category
super().save(*args, **kwargs)
@ -209,45 +285,37 @@ class StaticPage(BasePage):
detail_url_name = "static-page-detail"
ATTACH_TO_HOME = 0x00
ATTACH_TO_DIFFUSIONS = 0x01
ATTACH_TO_LOGS = 0x02
ATTACH_TO_PROGRAMS = 0x03
ATTACH_TO_EPISODES = 0x04
ATTACH_TO_ARTICLES = 0x05
class Target(models.TextChoices):
NONE = "", _("None")
HOME = "home", _("Home Page")
TIMETABLE = "timetable-list", _("Timetable")
PROGRAMS = "program-list", _("Programs list")
EPISODES = "episode-list", _("Episodes list")
ARTICLES = "article-list", _("Articles list")
PAGES = "page-list", _("Publications list")
PODCASTS = "podcast-list", _("Podcasts list")
ATTACH_TO_CHOICES = (
(ATTACH_TO_HOME, _("Home page")),
(ATTACH_TO_DIFFUSIONS, _("Diffusions page")),
(ATTACH_TO_LOGS, _("Logs page")),
(ATTACH_TO_PROGRAMS, _("Programs list")),
(ATTACH_TO_EPISODES, _("Episodes list")),
(ATTACH_TO_ARTICLES, _("Articles list")),
)
VIEWS = {
ATTACH_TO_HOME: "home",
ATTACH_TO_DIFFUSIONS: "diffusion-list",
ATTACH_TO_LOGS: "log-list",
ATTACH_TO_PROGRAMS: "program-list",
ATTACH_TO_EPISODES: "episode-list",
ATTACH_TO_ARTICLES: "article-list",
}
attach_to = models.SmallIntegerField(
attach_to = models.CharField(
_("attach to"),
choices=ATTACH_TO_CHOICES,
choices=Target.choices,
max_length=32,
blank=True,
null=True,
help_text=_("display this page content to related element"),
)
def get_related_view(self):
from ..views import attached
return self.attach_to and attached.get(self.attach_to) or None
def get_absolute_url(self):
if self.attach_to:
return reverse(self.VIEWS[self.attach_to])
return reverse(self.attach_to)
return super().get_absolute_url()
class Comment(models.Model):
class Comment(Renderable, models.Model):
page = models.ForeignKey(
Page,
models.CASCADE,
@ -260,7 +328,7 @@ class Comment(models.Model):
date = models.DateTimeField(auto_now_add=True)
content = models.TextField(_("content"), max_length=1024)
item_template_name = "aircox/widgets/comment_item.html"
template_prefix = "comment"
@cached_property
def parent(self):
@ -268,7 +336,7 @@ class Comment(models.Model):
return Page.objects.select_subclasses().filter(id=self.page_id).first()
def get_absolute_url(self):
return self.parent.get_absolute_url()
return self.parent.get_absolute_url() + f"#{self._meta.label_lower}-{self.pk}"
class Meta:
verbose_name = _("Comment")
@ -281,7 +349,7 @@ class NavItem(models.Model):
station = models.ForeignKey(Station, models.CASCADE, verbose_name=_("station"))
menu = models.SlugField(_("menu"), max_length=24)
order = models.PositiveSmallIntegerField(_("order"))
text = models.CharField(_("title"), max_length=64)
text = models.CharField(_("title"), max_length=64, blank=True, null=True)
url = models.CharField(_("url"), max_length=256, blank=True, null=True)
page = models.ForeignKey(
StaticPage,
@ -300,14 +368,21 @@ class NavItem(models.Model):
def get_url(self):
return self.url if self.url else self.page.get_absolute_url() if self.page else None
def get_label(self):
if self.text:
return self.text
elif self.page:
return self.page.title
def render(self, request, css_class="", active_class=""):
url = self.get_url()
label = self.get_label()
if active_class and request.path.startswith(url):
css_class += " " + active_class
if not url:
return self.text
return label
elif not css_class:
return format_html('<a href="{}">{}</a>', url, self.text)
return format_html('<a href="{}">{}</a>', url, label)
else:
return format_html('<a href="{}" class="{}">{}</a>', url, css_class, self.text)
return format_html('<a href="{}" class="{}">{}</a>', url, css_class, label)

View File

@ -1,11 +1,8 @@
import logging
import os
import shutil
from django.conf import settings as conf
from django.contrib.auth.models import Group
from django.db import models
from django.db.models import F
from django.db.models.functions import Concat, Substr
from django.utils.translation import gettext_lazy as _
from aircox.conf import settings
@ -13,13 +10,11 @@ from aircox.conf import settings
from .page import Page, PageQuerySet
from .station import Station
logger = logging.getLogger("aircox")
__all__ = (
"ProgramQuerySet",
"Program",
"ProgramChildQuerySet",
"ProgramQuerySet",
"Stream",
)
@ -32,6 +27,16 @@ class ProgramQuerySet(PageQuerySet):
def active(self):
return self.filter(active=True)
def editor(self, user):
"""Return programs for which user is an editor.
Superuser is considered as editor of all groups.
"""
if user.is_superuser:
return self
groups = self.request.user.groups.all()
return self.filter(editors_group__in=groups)
class Program(Page):
"""A Program can either be a Streamed or a Scheduled program.
@ -58,9 +63,12 @@ class Program(Page):
default=True,
help_text=_("update later diffusions according to schedule changes"),
)
editors_group = models.ForeignKey(Group, models.CASCADE, verbose_name=_("editors"))
objects = ProgramQuerySet.as_manager()
detail_url_name = "program-detail"
list_url_name = "program-list"
edit_url_name = "program-edit"
@property
def path(self):
@ -80,11 +88,10 @@ class Program(Page):
def excerpts_path(self):
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.slug:
self.__initial_path = self.path
self.__initial_cover = self.cover
@classmethod
def get_from_path(cl, path):
@ -116,27 +123,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_))))
class ProgramChildQuerySet(PageQuerySet):
def station(self, station=None, id=None):
# lookup `__program` is due to parent being a page subclass (page is
# concrete).
return (
self.filter(parent__program__station=station)
if id is None
@ -146,6 +137,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

@ -42,6 +42,7 @@ class Schedule(Rerun):
second_and_fourth = 0b001010, _("2nd and 4th {day} of the month")
every = 0b011111, _("{day}")
one_on_two = 0b100000, _("one {day} on two")
# every_weekday = 0b10000000 _("from Monday to Friday")
date = models.DateField(
_("date"),
@ -71,6 +72,10 @@ class Schedule(Rerun):
verbose_name = _("Schedule")
verbose_name_plural = _("Schedules")
def __init__(self, *args, **kwargs):
self._initial = kwargs
super().__init__(*args, **kwargs)
def __str__(self):
return "{} - {}, {}".format(
self.program.title,
@ -110,16 +115,28 @@ class Schedule(Rerun):
date = tz.datetime.combine(date, self.time)
return date.replace(tzinfo=self.tz)
def dates_of_month(self, date):
"""Return normalized diffusion dates of provided date's month."""
if self.frequency == Schedule.Frequency.ponctual:
def dates_of_month(self, date, frequency=None, sched_date=None):
"""Return normalized diffusion dates of provided date's month.
:param Date date: date of the month to get dates from;
:param Schedule.Frequency frequency: frequency (defaults to ``self.frequency``)
:param Date sched_date: schedule start date (defaults to ``self.date``)
:return list of diffusion dates
"""
if frequency is None:
frequency = self.frequency
if sched_date is None:
sched_date = self.date
if frequency == Schedule.Frequency.ponctual:
return []
sched_wday, freq = self.date.weekday(), self.frequency
sched_wday = sched_date.weekday()
date = date.replace(day=1)
# last of the month
if freq == Schedule.Frequency.last:
if frequency == Schedule.Frequency.last:
date = date.replace(day=calendar.monthrange(date.year, date.month)[1])
date_wday = date.weekday()
@ -134,33 +151,42 @@ class Schedule(Rerun):
date_wday, month = date.weekday(), date.month
date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) - date_wday + sched_wday)
if freq == Schedule.Frequency.one_on_two:
if frequency == Schedule.Frequency.one_on_two:
# - adjust date with modulo 14 (= 2 weeks in days)
# - there are max 3 "weeks on two" per month
if (date - self.date).days % 14:
if (date - sched_date).days % 14:
date += tz.timedelta(days=7)
dates = (date + tz.timedelta(days=14 * i) for i in range(0, 3))
else:
dates = (date + tz.timedelta(days=7 * week) for week in range(0, 5) if freq & (0b1 << week))
dates = (date + tz.timedelta(days=7 * week) for week in range(0, 5) if frequency & (0b1 << week))
return [self.normalize(date) for date in dates if date.month == month]
def diffusions_of_month(self, date):
def diffusions_of_month(self, date, frequency=None, sched_date=None):
"""Get episodes and diffusions for month of provided date, including
reruns.
:param Date date: date of the month to get diffusions from;
:param Schedule.Frequency frequency: frequency (defaults to ``self.frequency``)
:param Date sched_date: schedule start date (defaults to ``self.date``)
:returns: tuple([Episode], [Diffusion])
"""
from .diffusion import Diffusion
from .episode import Episode
if self.initial is not None or self.frequency == Schedule.Frequency.ponctual:
if frequency is None:
frequency = self.frequency
if sched_date is None:
sched_date = self.date
if self.initial is not None or frequency == Schedule.Frequency.ponctual:
return [], []
# dates for self and reruns as (date, initial)
reruns = [(rerun, rerun.date - self.date) for rerun in self.rerun_set.all()]
reruns = [(rerun, rerun.date - sched_date) for rerun in self.rerun_set.all()]
dates = {date: None for date in self.dates_of_month(date)}
dates = {date: None for date in self.dates_of_month(date, frequency, sched_date)}
dates.update(
(rerun.normalize(date.date() + delta), date) for date in list(dates.keys()) for rerun, delta in reruns
)

View File

@ -1,16 +1,27 @@
import logging
import os
import shutil
from django.conf import settings as conf
from django.contrib.auth.models import Group, Permission, User
from django.db import transaction
from django.db.models import signals
from django.db.models import signals, F
from django.db.models.functions import Concat, Substr
from django.dispatch import receiver
from django.utils import timezone as tz
from aircox import utils
from aircox.conf import settings
from .article import Article
from .diffusion import Diffusion
from .episode import Episode
from .page import Page
from .program import Program
from .schedule import Schedule
from .sound import Sound
logger = logging.getLogger("aircox")
# Add a default group to a user when it is created. It also assigns a list
@ -39,27 +50,43 @@ def user_default_groups(sender, instance, created, *args, **kwargs):
instance.groups.add(group)
# ---- page
@receiver(signals.post_save, sender=Page)
def page_post_save(sender, instance, created, *args, **kwargs):
if not created and instance.cover:
Page.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover)
def page_post_save__child_page_defaults(sender, instance, created, *args, **kwargs):
initial_cover = getattr(instance, "__initial_cover", None)
if initial_cover is None and instance.cover is not None:
Episode.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover)
Article.objects.filter(parent=instance, cover__isnull=True).update(cover=instance.cover)
# ---- program
@receiver(signals.post_save, sender=Program)
def program_post_save(sender, instance, created, *args, **kwargs):
"""Clean-up later diffusions when a program becomes inactive."""
def program_post_save__clean_later_episodes(sender, instance, created, *args, **kwargs):
if not instance.active:
Diffusion.objects.program(instance).after(tz.now()).delete()
Episode.objects.parent(instance).filter(diffusion__isnull=True).delete()
cover = getattr(instance, "__initial_cover", None)
if cover is None and instance.cover is not None:
Episode.objects.parent(instance).filter(cover__isnull=True).update(cover=instance.cover)
@receiver(signals.post_save, sender=Program)
def program_post_save__mv_sounds(sender, instance, created, *args, **kwargs):
path_ = getattr(instance, "__initial_path", None)
if path_ in (None, instance.path):
return
abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_)
if os.path.exists(abspath) and not os.path.exists(instance.abspath):
logger.info(
f"program #{instance.pk}'s dir changed to {instance.title} - update it.", instance.id, instance.title
)
shutil.move(abspath, instance.abspath)
Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_))))
# ---- schedule
@receiver(signals.pre_save, sender=Schedule)
def schedule_pre_save(sender, instance, *args, **kwargs):
if getattr(instance, "pk") is not None:
if getattr(instance, "pk") is not None and "raw" not in kwargs:
instance._initial = Schedule.objects.get(pk=instance.pk)
@ -88,9 +115,23 @@ def schedule_post_save(sender, instance, created, *args, **kwargs):
def schedule_pre_delete(sender, instance, *args, **kwargs):
"""Delete later corresponding diffusion to a changed schedule."""
Diffusion.objects.filter(schedule=instance).after(tz.now()).delete()
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete()
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
# ---- diffusion
@receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, sound__isnull=True).delete()
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
# ---- files
@receiver(signals.post_delete, sender=Sound)
def delete_file(sender, instance, *args, **kwargs):
"""Deletes file on `post_delete`"""
if not instance.file:
return
path = instance.file.path
qs = sender.objects.filter(file=path)
if not qs.exists() and os.path.exists(path):
os.remove(path)

View File

@ -1,304 +1,187 @@
import logging
from datetime import date
import os
import re
from django.conf import settings as conf
from django.db import models
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
from aircox import utils
from aircox.conf import settings
from .episode import Episode
from .program import Program
logger = logging.getLogger("aircox")
from .file import File, FileQuerySet
__all__ = ("Sound", "SoundQuerySet", "Track")
__all__ = ("Sound", "SoundQuerySet")
class SoundQuerySet(models.QuerySet):
def station(self, station=None, id=None):
id = station.pk if id is None else id
return self.filter(program__station__id=id)
def episode(self, episode=None, id=None):
id = episode.pk if id is None else id
return self.filter(episode__id=id)
def diffusion(self, diffusion=None, id=None):
id = diffusion.pk if id is None else id
return self.filter(episode__diffusion__id=id)
def available(self):
return self.exclude(type=Sound.TYPE_REMOVED)
def public(self):
"""Return sounds available as podcasts."""
return self.filter(is_public=True)
class SoundQuerySet(FileQuerySet):
def downloadable(self):
"""Return sounds available as podcasts."""
return self.filter(is_downloadable=True)
def archive(self):
def broadcast(self):
"""Return sounds that are archives."""
return self.filter(type=Sound.TYPE_ARCHIVE)
return self.filter(broadcast=True, is_removed=False)
def path(self, paths):
if isinstance(paths, str):
return self.filter(file=paths.replace(conf.MEDIA_ROOT + "/", ""))
return self.filter(file__in=(p.replace(conf.MEDIA_ROOT + "/", "") for p in paths))
def playlist(self, archive=True, order_by=True):
def playlist(self, order_by="file"):
"""Return files absolute paths as a flat list (exclude sound without
path).
If `order_by` is True, order by path.
"""
if archive:
self = self.archive()
path)."""
if order_by:
self = self.order_by("file")
self = self.order_by(order_by)
return [
os.path.join(conf.MEDIA_ROOT, file)
for file in self.filter(file__isnull=False).values_list("file", flat=True)
]
def search(self, query):
return self.filter(
Q(name__icontains=query)
| Q(file__icontains=query)
| Q(program__title__icontains=query)
| Q(episode__title__icontains=query)
)
# TODO:
# - provide a default name based on program and episode
class Sound(models.Model):
"""A Sound is the representation of a sound file that can be either an
excerpt or a complete archive of the related diffusion."""
TYPE_OTHER = 0x00
TYPE_ARCHIVE = 0x01
TYPE_EXCERPT = 0x02
TYPE_REMOVED = 0x03
TYPE_CHOICES = (
(TYPE_OTHER, _("other")),
(TYPE_ARCHIVE, _("archive")),
(TYPE_EXCERPT, _("excerpt")),
(TYPE_REMOVED, _("removed")),
)
name = models.CharField(_("name"), max_length=64)
program = models.ForeignKey(
Program,
models.CASCADE,
blank=True, # NOT NULL
verbose_name=_("program"),
help_text=_("program related to it"),
db_index=True,
)
episode = models.ForeignKey(
Episode,
models.SET_NULL,
blank=True,
null=True,
verbose_name=_("episode"),
db_index=True,
)
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
position = models.PositiveSmallIntegerField(
_("order"),
default=0,
help_text=_("position in the playlist"),
)
def _upload_to(self, filename):
subdir = settings.SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE else settings.SOUND_EXCERPTS_SUBDIR
return os.path.join(self.program.path, subdir, filename)
file = models.FileField(
_("file"),
upload_to=_upload_to,
max_length=256,
db_index=True,
unique=True,
)
class Sound(File):
duration = models.TimeField(
_("duration"),
blank=True,
null=True,
help_text=_("duration of the sound"),
)
mtime = models.DateTimeField(
_("modification time"),
blank=True,
null=True,
help_text=_("last modification date and time"),
)
is_good_quality = models.BooleanField(
_("good quality"),
help_text=_("sound meets quality requirements"),
blank=True,
null=True,
)
is_public = models.BooleanField(
_("public"),
help_text=_("whether it is publicly available as podcast"),
default=False,
)
is_downloadable = models.BooleanField(
_("downloadable"),
help_text=_("whether it can be publicly downloaded by visitors (sound must be " "public)"),
help_text=_("sound can be downloaded by visitors"),
default=False,
)
broadcast = models.BooleanField(
_("Broadcast"),
default=False,
help_text=_("The sound is broadcasted on air"),
)
objects = SoundQuerySet.as_manager()
class Meta:
verbose_name = _("Sound")
verbose_name_plural = _("Sounds")
verbose_name = _("Sound file")
verbose_name_plural = _("Sound files")
@property
def url(self):
return self.file and self.file.url
_path_re = re.compile(
"^(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})"
"(_(?P<hour>[0-9]{2})h(?P<minute>[0-9]{2}))?"
"(_(?P<n>[0-9]+))?"
"_?[ -]*(?P<name>.*)$"
)
def __str__(self):
return "/".join(self.file.path.split("/")[-3:])
@classmethod
def read_path(cls, path):
"""Parse path name returning dictionary of extracted info. It can
contain:
def save(self, check=True, *args, **kwargs):
if self.episode is not None and self.program is None:
self.program = self.episode.program
if check:
self.check_on_file()
if not self.is_public:
self.is_downloadable = False
self.__check_name()
super().save(*args, **kwargs)
# TODO: rename get_file_mtime(self)
def get_mtime(self):
"""Get the last modification date from file."""
mtime = os.stat(self.file.path).st_mtime
mtime = tz.datetime.fromtimestamp(mtime)
mtime = mtime.replace(microsecond=0)
return tz.make_aware(mtime, tz.get_current_timezone())
def file_exists(self):
"""Return true if the file still exists."""
return os.path.exists(self.file.path)
# TODO: rename to sync_fs()
def check_on_file(self):
"""Check sound file info again'st self, and update informations if
needed (do not save).
Return True if there was changes.
- `year`, `month`, `day`: diffusion date
- `hour`, `minute`: diffusion time
- `n`: sound arbitrary number (used for sound ordering)
- `name`: cleaned name extracted or file name (without extension)
"""
if not self.file_exists():
if self.type == self.TYPE_REMOVED:
return
logger.debug("sound %s: has been removed", self.file.name)
self.type = self.TYPE_REMOVED
return True
basename = os.path.basename(path)
basename = os.path.splitext(basename)[0]
reg_match = cls._path_re.search(basename)
if reg_match:
info = reg_match.groupdict()
for k in ("year", "month", "day", "hour", "minute", "n"):
if info.get(k) is not None:
info[k] = int(info[k])
# not anymore removed
changed = False
name = info.get("name")
info["name"] = name and cls._as_name(name) or basename
else:
info = {"name": basename}
return info
if self.type == self.TYPE_REMOVED and self.program:
changed = True
self.type = (
self.TYPE_ARCHIVE if self.file.name.startswith(self.program.archives_path) else self.TYPE_EXCERPT
@classmethod
def _as_name(cls, name):
name = name.replace("_", " ")
return " ".join(r.capitalize() for r in name.split(" "))
def find_episode(self, path_info=None):
"""Base on self's file name, match date to an initial diffusion and
return corresponding episode or ``None``."""
pi = path_info or self.read_path(self.file.path)
if "year" not in pi:
return None
year, month, day = pi.get("year"), pi.get("month"), pi.get("day")
if pi.get("hour") is not None:
at = tz.datetime(year, month, day, pi.get("hour", 0), pi.get("minute", 0))
at = tz.make_aware(at)
else:
at = date(year, month, day)
diffusion = self.program.diffusion_set.at(at).first()
return diffusion and diffusion.episode or None
def find_playlist(self, meta=None):
"""Find a playlist file corresponding to the sound path, such as:
my_sound.ogg => my_sound.csv.
Use provided sound's metadata if any and no csv file has been
found.
"""
from aircox.controllers.playlist_import import PlaylistImport
from .track import Track
if self.track_set.count() > 1:
return
# import playlist
path_noext, ext = os.path.splitext(self.file.path)
path = path_noext + ".csv"
if os.path.exists(path):
PlaylistImport(path, sound=self).run()
# use metadata
elif meta and meta.tags:
title, artist, album, year = tuple(
t and ", ".join(t) for t in (meta.tags.get(k) for k in ("title", "artist", "album", "year"))
)
# check mtime -> reset quality if changed (assume file changed)
mtime = self.get_mtime()
if self.mtime != mtime:
self.mtime = mtime
self.is_good_quality = None
logger.debug(
"sound %s: m_time has changed. Reset quality info",
self.file.name,
title = title or path_noext
info = "{} ({})".format(album, year) if album and year else album or year or ""
track = Track(
sound=self,
position=int(meta.tags.get("tracknumber", 0)),
title=title,
artist=artist or _("unknown"),
info=info,
)
return True
track.save()
def get_upload_dir(self):
if self.broadcast:
return settings.SOUND_BROADCASTS_SUBDIR
return settings.SOUND_EXCERPTS_SUBDIR
meta = None
"""Provided by read_metadata: Mutagen's metadata."""
def sync_fs(self, *args, find_playlist=False, **kwargs):
changed = super().sync_fs(*args, **kwargs)
if changed and not self.is_removed:
if not self.program:
self.program = Program.get_from_path(self.file.path)
changed = True
if find_playlist and self.meta:
not self.pk and self.save(sync=False)
self.find_playlist(self.meta)
return changed
def __check_name(self):
if not self.name and self.file and self.file.name:
# FIXME: later, remove date?
name = os.path.basename(self.file.name)
name = os.path.splitext(name)[0]
self.name = name.replace("_", " ").strip()
def read_metadata(self):
import mutagen
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__check_name()
meta = mutagen.File(self.file.path)
metadata = {"duration": utils.seconds_to_time(meta.info.length), "meta": meta}
class Track(models.Model):
"""Track of a playlist of an object.
The position can either be expressed as the position in the playlist
or as the moment in seconds it started.
"""
episode = models.ForeignKey(
Episode,
models.CASCADE,
blank=True,
null=True,
verbose_name=_("episode"),
)
sound = models.ForeignKey(
Sound,
models.CASCADE,
blank=True,
null=True,
verbose_name=_("sound"),
)
position = models.PositiveSmallIntegerField(
_("order"),
default=0,
help_text=_("position in the playlist"),
)
timestamp = models.PositiveSmallIntegerField(
_("timestamp"),
blank=True,
null=True,
help_text=_("position (in seconds)"),
)
title = models.CharField(_("title"), max_length=128)
artist = models.CharField(_("artist"), max_length=128)
album = models.CharField(_("album"), max_length=128, null=True, blank=True)
tags = TaggableManager(verbose_name=_("tags"), blank=True)
year = models.IntegerField(_("year"), blank=True, null=True)
# FIXME: remove?
info = models.CharField(
_("information"),
max_length=128,
blank=True,
null=True,
help_text=_(
"additional informations about this track, such as " "the version, if is it a remix, features, etc."
),
)
class Meta:
verbose_name = _("Track")
verbose_name_plural = _("Tracks")
ordering = ("position",)
def __str__(self):
return "{self.artist} -- {self.title} -- {self.position}".format(self=self)
def save(self, *args, **kwargs):
if (self.sound is None and self.episode is None) or (self.sound is not None and self.episode is not None):
raise ValueError("sound XOR episode is required")
super().save(*args, **kwargs)
path_info = self.read_path(self.file.path)
if name := path_info.get("name"):
metadata["name"] = name
return metadata

View File

@ -1,11 +1,8 @@
import os
from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from filer.fields.image import FilerImageField
from aircox.conf import settings
__all__ = ("Station", "StationQuerySet", "Port")
@ -32,13 +29,6 @@ class Station(models.Model):
name = models.CharField(_("name"), max_length=64)
slug = models.SlugField(_("slug"), max_length=64, unique=True)
# FIXME: remove - should be decided only by Streamer controller + settings
path = models.CharField(
_("path"),
help_text=_("path to the working directory"),
max_length=256,
blank=True,
)
default = models.BooleanField(
_("default station"),
default=False,
@ -67,7 +57,7 @@ class Station(models.Model):
max_length=2048,
null=True,
blank=True,
help_text=_("Audio streams urls used by station's player. One url " "a line."),
help_text=_("Audio streams urls used by station's player. One url a line."),
)
default_cover = FilerImageField(
on_delete=models.SET_NULL,
@ -76,6 +66,14 @@ class Station(models.Model):
blank=True,
related_name="+",
)
music_stream_title = models.CharField(
_("Music stream's title"),
max_length=64,
default=_("Music stream"),
)
legal_label = models.CharField(
_("Legal label"), max_length=64, blank=True, default="", help_text=_("Displayed at the bottom of pages.")
)
objects = StationQuerySet.as_manager()
@ -88,12 +86,6 @@ class Station(models.Model):
return self.name
def save(self, make_sources=True, *args, **kwargs):
if not self.path:
self.path = os.path.join(
settings.CONTROLLERS_WORKING_DIR,
self.slug.replace("-", "_"),
)
if self.default:
qs = Station.objects.filter(default=True)
if self.pk is not None:

72
aircox/models/track.py Normal file
View File

@ -0,0 +1,72 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from taggit.managers import TaggableManager
from .episode import Episode
from .sound import Sound
__all__ = ("Track",)
class Track(models.Model):
"""Track of a playlist of an object.
The position can either be expressed as the position in the playlist
or as the moment in seconds it started.
"""
episode = models.ForeignKey(
Episode,
models.CASCADE,
blank=True,
null=True,
verbose_name=_("episode"),
)
sound = models.ForeignKey(
Sound,
models.CASCADE,
blank=True,
null=True,
verbose_name=_("sound"),
)
position = models.PositiveSmallIntegerField(
_("order"),
default=0,
help_text=_("position in the playlist"),
)
timestamp = models.PositiveSmallIntegerField(
_("timestamp"),
blank=True,
null=True,
help_text=_("position (in seconds)"),
)
title = models.CharField(_("title"), max_length=128)
artist = models.CharField(_("artist"), max_length=128)
album = models.CharField(_("album"), max_length=128, null=True, blank=True)
tags = TaggableManager(verbose_name=_("tags"), blank=True)
year = models.IntegerField(_("year"), blank=True, null=True)
# FIXME: remove?
info = models.CharField(
_("information"),
max_length=128,
blank=True,
null=True,
help_text=_(
"additional informations about this track, such as " "the version, if is it a remix, features, etc."
),
)
class Meta:
verbose_name = _("Track")
verbose_name_plural = _("Tracks")
ordering = ("position",)
def __str__(self):
return "{self.artist} -- {self.title} -- {self.position}".format(self=self)
def save(self, *args, **kwargs):
if (self.sound is None and self.episode is None) or (self.sound is not None and self.episode is not None):
raise ValueError("sound XOR episode is required")
super().save(*args, **kwargs)

View File

@ -14,5 +14,5 @@ class UserSettings(models.Model):
verbose_name=_("User"),
related_name="aircox_settings",
)
playlist_editor_columns = models.JSONField(_("Playlist Editor Columns"))
playlist_editor_sep = models.CharField(_("Playlist Editor Separator"), max_length=16)
tracklist_editor_columns = models.JSONField(_("Playlist Editor Columns"))
tracklist_editor_sep = models.CharField(_("Playlist Editor Separator"), max_length=16)

84
aircox/permissions.py Normal file
View File

@ -0,0 +1,84 @@
# Provide permissions handling
# we don't import models at module level in order to avoid migration problems
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from .models import Program
__all__ = ("PagePermissions", "program")
class PagePermissions:
"""Handles obj permissions initialization of page subclass."""
model = None
groups = ({"label": _("editors"), "field": "editors_group_id", "perms": ["update"]},)
"""Groups informations initialized."""
groups_name_format = "{obj.title}: {group_label}"
"""Format used for groups name."""
perms_name_format = "{obj.title}: can {perm}"
"""Format used for permission name (displayed to humans)."""
perms_codename_format = "{obj._meta.label_lower}_{obj.pk}_{perm}"
"""Format used for permissions codename."""
def __init__(self, model):
self.model = model
def can(self, user, perm, obj):
"""Return True wether if user can edit Program or its children."""
from .models.page import ChildPage
if isinstance(obj, ChildPage):
obj = obj.parent_subclass
if not isinstance(obj, self.model):
return False
if user.is_superuser:
return True
perm = self.perms_codename_format.format(self=self, perm=perm)
return user.has_perm(perm)
def init(self, obj, model=None):
"""Initialize permissions for the provided obj."""
updated = False
created_groups = []
# init groups
for infos in self.groups:
group = getattr(obj, infos["field"])
if obj.pk == 12417:
breakpoint()
if not group:
group, created = self.init_group(obj, infos)
setattr(obj, infos["field"], group.pk)
updated = True
created and created_groups.append((group, infos))
if updated:
obj.save()
# init perms
for group, infos in created_groups:
self.init_perms(obj, group, infos)
def init_group(self, obj, infos):
name = self.groups_name_format.format(obj=obj, group_label=infos["label"])
return Group.objects.get_or_create(name=name)
def init_perms(self, obj, group, infos):
# TODO: avoid multiple database hits
for name in infos["perms"]:
perm, _ = Permission.objects.get_or_create(
codename=self.perms_codename_format.format(obj=obj, perm=name),
content_type=ContentType.objects.get_for_model(obj),
defaults={"name": self.perms_name_format.format(obj=obj, perm=name)},
)
if perm not in group.permissions.all():
group.permissions.add(perm)
program = PagePermissions(Program)

View File

@ -1,12 +1,18 @@
from . import auth
from .admin import TrackSerializer, UserSettingsSerializer
from .episode import EpisodeSoundSerializer, EpisodeSerializer
from .log import LogInfo, LogInfoSerializer
from .sound import PodcastSerializer, SoundSerializer
from .page import CommentSerializer
from .sound import SoundSerializer
__all__ = (
"TrackSerializer",
"UserSettingsSerializer",
"auth",
"CommentSerializer",
"LogInfo",
"LogInfoSerializer",
"EpisodeSoundSerializer",
"EpisodeSerializer",
"SoundSerializer",
"PodcastSerializer",
"TrackSerializer",
"UserSettingsSerializer",
)

View File

@ -1,9 +1,17 @@
from rest_framework import serializers
from filer.models.imagemodels import Image
from taggit.serializers import TaggitSerializer, TagListSerializerField
from ..models import Track, UserSettings
__all__ = ("TrackSerializer", "UserSettingsSerializer")
__all__ = ("ImageSerializer", "TrackSerializer", "UserSettingsSerializer")
class ImageSerializer(serializers.ModelSerializer):
class Meta:
model = Image
fields = "__all__"
class TrackSerializer(TaggitSerializer, serializers.ModelSerializer):
@ -27,10 +35,10 @@ class TrackSerializer(TaggitSerializer, serializers.ModelSerializer):
class UserSettingsSerializer(serializers.ModelSerializer):
# TODO: validate fields values (playlist_editor_columns at least)
# TODO: validate fields values (tracklist_editor_columns at least)
class Meta:
model = UserSettings
fields = ("playlist_editor_columns", "playlist_editor_sep")
fields = ("tracklist_editor_columns", "tracklist_editor_sep")
def create(self, validated_data):
user = self.context.get("user")

View File

@ -0,0 +1,36 @@
from rest_framework import serializers
from .. import models
from .sound import SoundSerializer
from .admin import TrackSerializer
class EpisodeSoundSerializer(serializers.ModelSerializer):
sound = SoundSerializer(read_only=True)
class Meta:
model = models.EpisodeSound
fields = [
"id",
"position",
"episode",
"broadcast",
"sound",
"sound_id",
]
class EpisodeSerializer(serializers.ModelSerializer):
playlist = EpisodeSoundSerializer(source="episodesound_set", many=True, read_only=True)
tracks = TrackSerializer(source="track_set", many=True, read_only=True)
class Meta:
model = models.Episode
fields = [
"id",
"title",
"content",
"pub_date",
"playlist",
"tracks",
]

View File

@ -0,0 +1,12 @@
from rest_framework import serializers
from aircox import models
__all__ = ("CommentSerializer",)
class CommentSerializer(serializers.ModelSerializer):
class Meta:
model = models.Comment
fields = ["page", "nickname", "email", "date", "content"]

View File

@ -1,43 +1,24 @@
from rest_framework import serializers
from ..models import Sound
from .. import models
__all__ = ("SoundSerializer", "PodcastSerializer")
__all__ = ("SoundSerializer",)
class SoundSerializer(serializers.ModelSerializer):
file = serializers.FileField(use_url=False)
class Meta:
model = Sound
model = models.Sound
fields = [
"pk",
"id",
"name",
"program",
"episode",
"type",
"file",
"duration",
"mtime",
"is_good_quality",
"is_public",
"url",
]
class PodcastSerializer(serializers.ModelSerializer):
# serializers.HyperlinkedIdentityField(view_name='sound', format='html')
class Meta:
model = Sound
fields = [
"pk",
"name",
"program",
"episode",
"type",
"duration",
"mtime",
"url",
"is_downloadable",
"url",
]

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vue App</title>
<script defer src="js/chunk-vendors.js"></script><script defer src="js/chunk-common.js"></script><script defer src="js/admin.js"></script><link href="css/chunk-vendors.css" rel="stylesheet"><link href="css/chunk-common.css" rel="stylesheet"><link href="css/admin.css" rel="stylesheet"></head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vue App</title>
<script defer src="js/chunk-vendors.js"></script><script defer src="js/chunk-common.js"></script><script defer src="js/core.js"></script><link href="css/chunk-vendors.css" rel="stylesheet"><link href="css/chunk-common.css" rel="stylesheet"></head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -1,24 +0,0 @@
/*!*************************************************************************************************************************************************************************************************************************************!*\
!*** css ./node_modules/css-loader/dist/cjs.js??clonedRuleSet-24.use[1]!./node_modules/postcss-loader/dist/cjs.js??clonedRuleSet-24.use[2]!./node_modules/sass-loader/dist/cjs.js??clonedRuleSet-24.use[3]!./src/assets/admin.scss ***!
\*************************************************************************************************************************************************************************************************************************************/
.admin .navbar .navbar-brand {
padding-right: 1em;
}
.admin .navbar .navbar-brand img {
margin: 0em 0.4em;
margin-top: 0.3em;
max-height: 3em;
}
.admin .breadcrumbs {
margin-bottom: 1em;
}
.admin .results > #result_list {
width: 100%;
margin: 1em 0em;
}
.admin ul.menu-list li {
list-style-type: none;
}
.admin .submit-row a.deletelink {
height: 35px;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -16,17 +16,7 @@
\**********************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _assets_styles_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./assets/styles.scss */ \"./src/assets/styles.scss\");\n/* harmony import */ var _assets_admin_scss__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./assets/admin.scss */ \"./src/assets/admin.scss\");\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n/* harmony import */ var _track__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(/*! ./track */ \"./src/track.js\");\n\n\n\n\n\n\nconst AdminApp = {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"],\n components: {\n ..._app__WEBPACK_IMPORTED_MODULE_3__[\"default\"].components,\n ..._components__WEBPACK_IMPORTED_MODULE_4__.admin\n },\n data() {\n return {\n ...super.data,\n Track: _track__WEBPACK_IMPORTED_MODULE_5__[\"default\"]\n };\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (AdminApp);\nwindow.App = AdminApp;\n\n//# sourceURL=webpack://aircox-assets/./src/admin.js?");
/***/ }),
/***/ "./src/assets/admin.scss":
/*!*******************************!*\
!*** ./src/assets/admin.scss ***!
\*******************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n// extracted by mini-css-extract-plugin\n\n\n//# sourceURL=webpack://aircox-assets/./src/assets/admin.scss?");
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _styles_admin_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./styles/admin.scss */ \"./src/styles/admin.scss\");\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./app */ \"./src/app.js\");\n/* harmony import */ var _components__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! ./components */ \"./src/components/index.js\");\n\n\n\n\nconst AdminApp = {\n ..._app__WEBPACK_IMPORTED_MODULE_2__[\"default\"],\n components: {\n ..._app__WEBPACK_IMPORTED_MODULE_2__[\"default\"].components,\n ..._components__WEBPACK_IMPORTED_MODULE_3__.admin\n },\n data() {\n return {\n ...super.data\n };\n }\n};\n/* harmony default export */ __webpack_exports__[\"default\"] = (AdminApp);\nwindow.App = AdminApp;\n\n//# sourceURL=webpack://aircox-assets/./src/admin.js?");
/***/ })

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -10,13 +10,23 @@
/******/ "use strict";
/******/ var __webpack_modules__ = ({
/***/ "./src/core.js":
/*!*********************!*\
!*** ./src/core.js ***!
\*********************/
/***/ "./src/public.js":
/*!***********************!*\
!*** ./src/public.js ***!
\***********************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./app.js */ \"./src/app.js\");\n\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (_app_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"]);\nwindow.App = _app_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"];\n\n//# sourceURL=webpack://aircox-assets/./src/core.js?");
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _styles_public_scss__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./styles/public.scss */ \"./src/styles/public.scss\");\n/* harmony import */ var _index_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./index.js */ \"./src/index.js\");\n/* harmony import */ var _app_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./app.js */ \"./src/app.js\");\n\n\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (_app_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"]);\nwindow.App = _app_js__WEBPACK_IMPORTED_MODULE_2__[\"default\"];\n\n//# sourceURL=webpack://aircox-assets/./src/public.js?");
/***/ }),
/***/ "./src/styles/public.scss":
/*!********************************!*\
!*** ./src/styles/public.scss ***!
\********************************/
/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
eval("__webpack_require__.r(__webpack_exports__);\n// extracted by mini-css-extract-plugin\n\n\n//# sourceURL=webpack://aircox-assets/./src/styles/public.scss?");
/***/ })
@ -156,7 +166,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _ind
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ "core": 0
/******/ "public": 0
/******/ };
/******/
/******/ // no chunk on demand loading
@ -208,7 +218,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _ind
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module depends on other loaded chunks and execution need to be delayed
/******/ var __webpack_exports__ = __webpack_require__.O(undefined, ["chunk-vendors","chunk-common"], function() { return __webpack_require__("./src/core.js"); })
/******/ var __webpack_exports__ = __webpack_require__.O(undefined, ["chunk-vendors","chunk-common"], function() { return __webpack_require__("./src/public.js"); })
/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__);
/******/
/******/ })()

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,34 @@
{% extends "aircox/base.html" %}
{% load i18n aircox %}
{% block head_title %}
{% block title %}{{ user.username }}{% endblock %}
{% endblock %}
{% block content-container %}
<div class="container content page-content">
<h2 class="subtitle">Mon Profil</h2>
{% translate "Username" %} : {{ user.username|title }}<br/>
<!-- Connexion: {{ user.last_login }} -->
<h2 class="subtitle is-1">Mes émissions</h2>
{% if programs|length %}
<ul>
{% for p in programs %}
<li>{{ p.title }} :
&nbsp;
<a href="{% url 'program-detail' slug=p.slug %}">
<span title="{% translate 'View' %} {{ page }}">{% translate 'View' %}</span>
</a>
&nbsp;
<a href="{% url 'program-edit' pk=p.pk %}">
<span title="{% translate 'Edit' %} {{ page }}">{% translate 'Edit' %} </span>
</a>
</li>
{% endfor %}
</ul>
{% else %}
{% trans 'You are not listed as a program editor yet' %}
{% endif %}
</div>
{% endblock %}

View File

@ -7,7 +7,7 @@
<li>
{% if choice.type %}
<form method="GET" action="?{{ choice.query_string }}"
onsubmit="return this.{{ choice.name }}.value ? true : false"">
onsubmit="return this.{{ choice.name }}.value ? true : false">
<label for="filter-{{ choice.name }}">{{ choice.label }}: </label>
<input id="filter-{{ choice.name }}" type="{{ choice.type }}" name="{{ choice.name }}"
value="{{ choice.value }}" {{ choice.extra }}/>

View File

@ -7,15 +7,15 @@
<div id="inline-tracks" class="box mb-5">
{{ admin_formset.non_form_errors }}
<a-playlist-editor
:labels="{% track_inline_labels %}"
:init-data="{% track_inline_data formset=formset %}"
<a-tracklist-editor
:labels="{% inline_labels %}"
:init-data="{% formset_inline_data formset=formset %}"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-">
<template #title>
<h5 class="title is-4">{% trans "Playlist" %}</h5>
</template>
<template v-slot:top="{items}">
<template #top="{items}">
<input type="hidden" name="{{ formset.prefix }}-TOTAL_FORMS"
:value="items.length || 0"/>
<input type="hidden" name="{{ formset.prefix }}-INITIAL_FORMS"
@ -79,7 +79,7 @@
</template>
{% endif %}
{% endfor %}
</a-playlist-editor>
</a-tracklist-editor>
</div>
{% endwith %}
{% endwith %}

View File

@ -4,7 +4,6 @@
<head>
<title>{% block title %}{% endblock %}</title>
<!-- <link rel="stylesheet" type="text/css" href="{% static "aircox/vendor.css" %}"> -->
<link rel="stylesheet" type="text/css" href="{% static "admin/css/base.css" %}">
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-common.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-vendors.css" %}"/>
@ -33,23 +32,26 @@
function vuePre(selector) {
const elms = document.querySelectorAll(selector)
for(const elm of elms) {
elm.setAttribute('v-pre', true)
// elm.setAttribute('v-pre', "")
elm.parentNode.setAttribute('v-pre', '')
}
}
window.addEventListener('load', function() {
{% block init-scripts %}
vuePre(".django-ckeditor-widget")
vuePre("fieldset")
window.source_ = document.body.innerHTML
aircox.init(null, {
hotReload: false,
{% if not init_app %}
initBuilder: false,
initApp: false,
{% endif %}
{% if init_el %}
el: "{{ init_el }}",
{% endif %}
})
{% endblock %}
})
}, true)
</script>
<!-- Container -->
@ -71,7 +73,7 @@
</span>
<div class="navbar-dropdown is-boxed">
{% for diffusion in diffusions %}
<a class="navbar-item {% if diffusion.is_now %}has-background-primary{% endif %}" href="{% url "admin:aircox_episode_change" diffusion.episode.pk %}">
<a class="navbar-item {% if diffusion.is_now %}active{% endif %}" href="{% url "admin:aircox_episode_change" diffusion.episode.pk %}">
{{ diffusion.start|time }} |
{{ diffusion.episode.title }}
</a>

View File

@ -1,5 +1,5 @@
{% extends "admin/index.html" %}
{% load i18n thumbnail %}
{% load i18n thumbnail aircox %}
{% block app %}
@ -12,43 +12,11 @@
<span>{% translate "Today" %}</span>
</h1>
{% if diffusions %}
<table class="table is-fullwidth is-striped">
<tbody>
{% for diffusion in diffusions %}
{% with episode=diffusion.episode %}
<tr {% if diffusion.is_now %}class="is-selected"{% endif %}>
<td>{{ diffusion.start|time }} - {{ diffusion.end|time }}</td>
<td><img src="{% thumbnail episode.cover 64x64 crop %}"/></td>
<td>
<a href="{% url "admin:aircox_episode_change" episode.pk %}">{{ episode.title }}</a>
&nbsp;
{% 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 %}
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
<div class="grid-3">
{% for obj in diffusions %}
{% page_widget "card" obj.episode diffusion=obj timetable=True admin=True tag_class="" %}
{% endfor %}
</div>
{% else %}
<div class="block has-text-centered">
{% trans "No diffusion is scheduled for today." %}
@ -62,10 +30,12 @@
<span>{% translate "Latest comments" %}</span>
</h1>
{% if comments %}
{% include "aircox/widgets/page_list.html" with object_list=comments with_title=True %}
<div class="has-text-centered">
<a href="{% url "admin:aircox_comment_changelist" %}" class="float-center">{% translate "All comments" %}</a>
</div>
{% for object in comments|slice:":5" %}
{% page_widget "item" object with_title=True %}
{% endfor %}
<div class="has-text-centered">
<a href="{% url "admin:aircox_comment_changelist" %}" class="float-center">{% translate "All comments" %}</a>
</div>
{% else %}
<p class="block has-text-centered">{% trans "No comment posted yet" %}</p>
{% endif %}

View File

@ -1,30 +1,2 @@
{% extends "aircox/page_detail.html" %}
{% comment %}Detail page for regular articles{% endcomment %}
{% load i18n %}
{% block sidebar %}
{{ block.super }}
{% if sidebar_object_list %}
<section>
{% comment %}Translators: in page detail sidebar{% endcomment %}
<h4 class="title is-4">{% translate "Latest news" %}</h4>
{% for object in sidebar_object_list %}
{% include "aircox/widgets/page_item.html" %}
{% endfor %}
<br>
<nav class="pagination is-centered">
<ul class="pagination-list">
<li>
<a href="{% url "article-list" %}" class="pagination-link"
aria-label="{% translate "Show all news" %}">
{% translate "More news" %}
</a>
</li>
</ul>
</nav>
</section>
{% endif %}
{% endblock %}

View File

@ -1,3 +1,4 @@
{% load static i18n thumbnail aircox %}<!doctype html>
{% comment %}
Base website template. It displays various elements depending on context
variables.
@ -6,14 +7,10 @@ Usefull context:
- cover: image cover
- site: current website
- model: view model or displayed `object`'s
- sidebar_object_list: item to display in sidebar
- sidebar_url_name: url name sidebar item complete list
- sidebar_url_parent: parent page for sidebar items complete list
{% endcomment %}
{% load static i18n thumbnail aircox %}
<html>
<head>
{% block head %}
<meta charset="utf-8" />
<meta name="application-name" content="aircox" />
<meta name="description" content="{{ site.description }}" />
@ -22,24 +19,35 @@ Usefull context:
<link rel="icon" href="{% thumbnail site.favicon 32x32 crop %}" />
{% block assets %}
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-common.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/css/chunk-vendors.css" %}"/>
<script src="{% static "aircox/js/chunk-common.js" %}"></script>
<script src="{% static "aircox/js/chunk-vendors.js" %}"></script>
<script src="{% static "aircox/js/core.js" %}"></script>
{% static "vue/vue.esm-browser.js" as vue_url %}
<script type="importmap">
{
"imports": {
"vue": "{{vue_url}}"
}
}
</script>
<script type="module" src="{{vue_url}}"></script>
<link rel="stylesheet" type="text/css" href="{% static "fontawesome-free/css/all.min.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/index.css" %}"/>
<link rel="stylesheet" type="text/css" href="{% static "aircox/public.css" %}"/>
<script type="module" src="{% if app_js_url %}{{ app_js_url }}{% else %}{% static "aircox/public.js" %}{% endif %}"></script>
{% endblock %}
<title>
{% block head_title %}
{% if page and page.title %}{{ page.title }} &mdash; {{ station.name }}
{% else %}{{ station.name }}
{% block head-title %}
{% if page and page.title %}{{ page.title }} &mdash;
{% endif %}
{{ station.name }}
{% endblock %}
</title>
{% block head_extra %}{% endblock %}
{% endblock %}
</head>
<body>
<body {% if request.is_mobile %}class="mobile"{% endif %}>
{% block body %}
<script id="init-script">
window.addEventListener('load', function() {
{% block init-scripts %}
@ -48,123 +56,119 @@ Usefull context:
})
</script>
<div id="app">
{% block top-nav-container %}
<nav class="navbar has-shadow" role="navigation" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<a href="/" title="{% translate "Home" %}" class="navbar-item">
<img src="{{ station.logo.url }}" class="logo"/>
</a>
</div>
<div class="navbar-menu">
<div class="navbar-start">
{% block top-nav %}
{% nav_items "top" css_class="navbar-item" active_class="is-active" as items %}
{% for item, render in items %}
{{ render }}
{% endfor %}
{% endblock %}
</div>
<div class="navbar-end">
{% block top-nav-tools %}
{% endblock %}
{% block top-nav-end %}
<div class="navbar-item">
<form action="{% url 'page-list' %}" method="GET">
<div class="control has-icons-left">
<span class="icon is-small is-left">
<i class="fa fa-search"></i>
</span>
<input type="text" name="q" class="input"
placeholder="{% translate "Search" %}" />
</div>
</form>
</div>
{% endblock %}
</div>
</div>
</div>
</nav>
{% endblock %}
<div class="container">
<div class="columns is-desktop">
<main class="column page">
<header class="header">
{% block header %}
<h1 class="title is-1">
{% block title %}
{% if page and page.title %}
{{ page.title }}
{% endif %}
{% endblock %}
</h1>
<h3 class="subtitle is-3">
{% block subtitle %}{% endblock %}
</h3>
<div class="columns is-size-4">
{% block header_nav %}
<span class="column">
{% block header_crumbs %}
{% if parent %}
<a href="{{ parent.get_absolute_url }}">
{{ parent.title }}</a></li>
{% endif %}
{% endblock %}
</span>
{% endblock %}
</div>
{% endblock %}
</header>
{% block main %}
{% block content %}
{% if page and page.content %}
<section class="page-content mb-2">{{ page.content|safe }}</section>
{% endif %}
{% endblock %}
{% endblock main %}
</main>
{% if has_sidebar %}
{% comment %}Translators: main sidebar {% endcomment %}
<aside class="column is-one-third-desktop">
{# FIXME: block cover into sidebar one #}
{% block cover %}
{% if page and page.cover %}
<img class="cover mb-4" src="{{ page.cover.url }}" class="cover"/>
{% endif %}
{% block app %}
<div class="navs">
{% block nav %}
<nav class="nav primary" role="navigation" aria-label="main navigation">
{% block primary-nav %}
<a class="nav-brand" href="{% url "home" %}">
<img src="{{ station.logo.url }}">
</a>
<a-switch class="button burger"
el=".nav.primary .nav-menu" group="nav"
aria-label="{% translate "Main menu" %}">
</a-switch>
<div class="nav-menu">
{% block primary-nav-menu %}
{% nav_items "top" css_class="nav-item" active_class="active" as items %}
{% for item, render in items %}
{{ render }}
{% endfor %}
{% endblock %}
{% with is_thin=True %}
{% block sidebar %}
{% if sidebar_object_list %}
{% with object_list=sidebar_object_list %}
{% with list_url=sidebar_list_url %}
{% with has_headline=False %}
<section>
<h4 class="title is-4">
{% block sidebar_title %}{% translate "Recently" %}{% endblock %}
</h4>
{% include "aircox/widgets/page_list.html" %}
{% endwith %}
{% endwith %}
{% endwith %}
{% if user.is_authenticated %}
{% include "./widgets/nav.html" %}
{% endif %}
</section>
{% endblock %}
{% endwith %}
</aside>
{% endif %}
</div>
</div>
{% endblock %}
</nav>
{% block secondary-nav %}{% endblock %}
{% endblock %}
</div>
<hr>
{% block main-container %}
<main class="page">
{% block main %}
{% spaceless %}
{% block breadcrumbs-container %}
<div class="breadcrumbs container">
{% block breadcrumbs %}{% endblock %}
</div>
{% endblock %}
{% endspaceless %}
{% block header-container %}
<header class="container header preview preview-header {% if cover %}has-cover{% endif %}">
{% block header %}
{% spaceless %}
<figure class="header-cover">
{% block header-cover %}
{% if cover %}
<img src="{{ cover }}" ref="cover" class="cover">
{% endif %}
{% endblock %}
</figure>
{% endspaceless %}
<div class="headings preview-card-headings">
{% block headings %}
<div>
{% block title-container %}
<h1 class="title is-1 {% block title-class %}{% endblock %}">{% block title %}{{ title|default:"" }}{% endblock %}</h1>
{% endblock %}
</div>
<div>
{% spaceless %}
<span class="subtitle is-2">
{% block subtitle %}
{% if subtitle %}
{{ subtitle }}
{% endif %}
{% endblock %}
</span>
{% endspaceless %}
</div>
{% endblock %}
</div>
{% endblock %}
</header>
{% endblock %}
{% block content-container %}
{% if page and page.content %}
<section class="container content page-content">
{% block content %}
{{ page.display_content|safe }}
{% endblock %}
</section>
{% endif %}
{% endblock %}
{% endblock %}
</main>
{% endblock %}
{% block footer-container %}
<footer class="page-footer">
{% block footer %}
{% comment %}
{% nav_items "footer" css_class="nav-item" active_class="active" as items %}
{% for item, render in items %}
{{ render }}
{% endfor %}
{% endcomment %}
{% endblock %}
{% if request.station and request.station.legal_label %}
{{ request.station.legal_label }} &mdash;
{% endif %}
</footer>
{% endblock %}
</div>
{% block player-container %}
<div id="player">{% include "aircox/widgets/player.html" %}</div>
{% endblock %}
{% endblock %}
{% endblock %}
</body>
</html>

View File

@ -1,8 +0,0 @@
{% extends "aircox/base.html" %}
{% comment %}Display detail of a BasePage{% endcomment %}
{% block head_title %}
{% block title %}{{ page.title }}{% endblock %}
&mdash;
{{ station.name }}
{% endblock %}

View File

@ -1,86 +1,64 @@
{% extends "aircox/base.html" %}
{% extends "./public.html" %}
{% comment %}Display a list of BasePages{% endcomment %}
{% load i18n aircox %}
{% block head_title %}
{% block title %}
{% if not page or not page.title %}
{% if not parent %}{{ view.model|verbose_name:True|title }}
{% else %}
{% with parent.title as title %}
{% with model|default:"Publications"|verbose_name:True|capfirst as model %}
{% comment %}Translators: title when pages are filtered for a specific parent page, e.g.: Articles of My Incredible Show{% endcomment %}
{% blocktranslate %}{{ model }} of {{ title }}{% endblocktranslate %}
{% endwith %}
{% endwith %}
{% endif %}
{% else %}{{ block.super }}
{% endif %}
{% endblock %}
&mdash;
{{ station.name }}
{% endblock %}
{% block main %}
{{ block.super }}
{% block main %}{{ block.super }}
{% block before_list %}{% endblock %}
<section role="list">
{% block pages_list %}
{% block list-container %}
<section class="container clear-both list grid {{ list_class|default:"" }}" role="list">
{% block list %}
{% with has_headline=True %}
{% for object in object_list %}
{% block list_object %}
{% include object.item_template_name|default:item_template_name %}
{% endblock %}
{% empty %}
{% blocktranslate %}There is nothing published here...{% endblocktranslate %}
{% endfor %}
{% for object in object_list %}
{% block list_object %}
{% page_widget item_widget|default:"item" object %}
{% endblock %}
{% empty %}
{% blocktranslate %}There is nothing published here...{% endblocktranslate %}
{% endfor %}
{% endwith %}
{% endblock %}
</section>
{% block list-pagination %}
{% if is_paginated %}
<hr/>
{% update_query request.GET.copy page=None as GET %}
{% with GET.urlencode as GET %}
<nav class="pagination is-centered" role="pagination" aria-label="{% translate "pagination" %}">
{% block pagination %}
{% if page_obj.has_previous %}
<a href="?{{ GET }}&page={{ page_obj.previous_page_number }}" class="pagination-previous">
{% else %}
<a class="pagination-previous" disabled>
{% endif %}
<nav class="nav-urls is-centered" role="pagination" aria-label="{% translate "pagination" %}">
<ul class="urls">
{% if page_obj.has_previous %}
{% comment %}Translators: Bottom of the list, "previous page"{% endcomment %}
{% translate "Previous" %}</a>
<a href="?{{ GET }}&page={{ page_obj.previous_page_number }}" class="left"
title="{% translate "Previous" %}"
aria-label="{% translate "Previous" %}">
<span class="icon"><i class="fa fa-chevron-left"></i></span>
</a>
{% endif %}
{% if page_obj.has_next %}
<a href="?{{ GET }}&page={{ page_obj.next_page_number }}" class="pagination-next">
{% else %}
<a class="pagination-next" disabled>
{% endif %}
<span>
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
{% comment %}Translators: Bottom of the list, "Nextpage"{% endcomment %}
{% translate "Next" %}</a>
<ul class="pagination-list">
{% for i in paginator.page_range %}
<li>
{% comment %}
<form action="?{{ GET }}">
{% for get in GET %}
<input type="hidden" name="{{ get.0 }}" value="{{ get.1 }}" />
{% endfor %}
<input type="number" name="page" value="{{ page_obj.number }}" />
</form>
{% endcomment %}
<a class="pagination-link {% if page_obj.number == i %}is-current{% endif %}"
href="?{{ GET }}&page={{ i }}">{{ i }}</a>
</li>
{% endfor %}
<a href="?{{ GET }}&page={{ page_obj.next_page_number }}" class="right"
title="{% translate "Next" %}"
aria-label="{% translate "Next" %}">
<span class="icon"><i class="fa fa-chevron-right"></i></span>
</a>
{% endif %}
</ul>
{% endblock %}
</nav>
{% endwith %}
{% endif %}
{% endblock %}
{% endblock %}
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "../base.html" %}
{% load static i18n %}
{% block assets %}
{% static "aircox/admin.js" as app_js_url %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "aircox/admin.css" %}"/>
{% endblock %}
{% block head-title %}
{% block title %}
{% if page and page.title %}{{ page.title }} &mdash;{% endif %}
{% endblock %}
{{ station.name }}
{% endblock %}

View File

@ -0,0 +1,51 @@
{% 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 mb-3">{% 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 display" %}</div>
{% endfor %}
</div>
</div>
{% if comments %}
<div>
<h2 class="title is-2 mb-3">{% 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 %}
{% endfor %}
</div>
</div>
{% endif %}
<div>
<h2 class="title is-2 mb-3">{% 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 program to display" %}</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,104 @@
{% extends "./base.html" %}
{% load i18n aircox %}
{% block title %}{% translate "Statistics" %}{% endblock %}
{% block content-container %}
<div class="">
<form method="GET" class="box mt-3 mb-3">
<h3 class="title is-3">{% translate "Filter by date" %}</h3>
<div class="flex-row gap-3" style="align-items: flex-end">
<div class="field mb-0">
<label class="label">{% translate "from" %}</label>
<input type="date" class="input" name="min_date" value="{{ min_date|date:"Y-m-d" }}"/>
</div>
<div class="field mb-0">
<label class="label">{% translate "... to" %}</label>
<input type="date" class="input" name="max_date" value="{{ max_date|date:"Y-m-d" }}"/>
</div>
<button class="button">{% translate "Apply" %}</button>
</div>
</form>
<a-statistics class="column">
<template v-slot="{counts}">
<table class="table is-hoverable is-fullwidth">
<thead>
<tr>
<th>{% translate "Time" %}</th>
<th>{% translate "Episode" %} / {% translate "Track" %}</th>
<th>{% translate "Tags" %}</th>
</tr>
</thead>
<tbody>
{% regroup object_list by date.date as by_date %}
{% for date, objects in by_date %}
<tr>
<th colspan="3">
{{ date|date:"l - d F Y" }}
</th>
</tr>
{% for object in objects %}
{% with object|is_diffusion as is_diff %}
{% if is_diff %}
<tr class="bg-main">
<td>{{ object.start|time:"H:i" }} - {{ object.end|time:"H:i" }}</td>
<td colspan="2">
<a href="{% url "episode-detail" slug=object.episode.slug %}" target="new">{{ object.episode|default:"" }}</a>
</td>
</tr>
{% endif %}
{% with object|get_tracks as tracks %}
{% for track in tracks %}
<tr {% if is_diff %}class="bg-main-light"{% endif %}>
{% if forloop.first %}
<td rowspan="{{ tracks|length }}">{{ object.start|time:"H:i" }} {% if object|is_diffusion %} - {{ object.end|time:"H:i" }}{% endif %}</td>
{% endif %}
<td>
{% if object.source %}{{ object.source }} / {% endif %}
{% include "aircox/widgets/track_item.html" with object=track %}
</td>
{% with track.tags.all|join:', ' as tags %}
<td>
{% if tags and tags.strip %}
<label class="checkbox">
<input type="checkbox" checked value="{{ tags|escape }}" name="data">
{{ tags }}
</label>
{% endif %}
</td>
{% endwith %}
</tr>
{% empty %}
{% if is_diff %}
<tr class="bg-main-light">
<td colspan="3">{% translate "No tracks" %}</td>
</tr>
{% endif %}
{% endfor %}
{% endwith %}
{% endwith %}
{% endfor %}
{% endfor %}
</tbody>
<tfoot>
<tr>
<td class="is-size-6">{% translate "Totals" %}</td>
<td colspan="2">
<span v-for="(count, tag) in counts" class="mr-4">
<b>[[ tag ]]</b>
[[ count ]]
</span>
</td>
</tr>
</tfoot>
</table>
</template>
</a-statistics>
</div>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":value" attribute
- vbind: if True, use ":value" instead of "value"
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.widget.is_hidden or hidden %}
<input type="hidden" name="{{ name }}" value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}>
{% elif field|is_select %}
<select name="{{ name }}" class="select" value="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" name="{{ name }}" value="{{ value|default:"" }}">
{% endif %}

View File

@ -0,0 +1,18 @@
{% load i18n %}
<a-modal ref="group-users-modal">
<template #title="{item}">[[ item?.name ]]</template>
<template #default="{item}">
<a-group-users v-if="item" ref="group-users"
:url="'{% url 'api:usergroup-list' %}?group=' + item.id"
commit-url="{% url 'api:usergroup-commit' %}"
:search-url="'{% url 'api:user-autocomplete' %}?search=${query}&group=' + item.id"
:initials="{group_id: item.id }"
/>
</template>
<template #footer="{item, close}">
<button type="button" class="button" @click="$refs['group-users'].save(); close()">
Save
</button>
</template>
</a-modal>

View File

@ -0,0 +1,54 @@
{% comment %}
Base template for list editor based on formsets (tracklist_editor, playlist_editor).
Context:
- tag_id: id of parent component
- tag: vue component tag (a-playlist-editor, etc.)
- related_field: field name that target object
- object: related object
- formset: formset used to render the list editor
- formset_data: formset data
{% endcomment %}
{% load aircox aircox_admin static i18n %}
{% with formset.form.base_fields as fields %}
{% block outer %}
<div id="{{ tag_id }}">
{{ formset.non_form_errors }}
<!-- formset.management_form -->
<{{ tag }}
{% block tag-attrs %}
:form-data="{{ formset_data|json }}"
:labels="window.aircox.labels"
:init-data="{% formset_inline_data formset=formset %}"
:columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-"
{% endblock %}>
{% block inner %}
<template #rows-header-head>
{% block rows-header-head %}
<th style="max-width:2em" title="{{ fields.position.help_text }}"
aria-description="{{ fields.position.help_text }}">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
{% endblock %}
</template>
{% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:control-{{ name }}="{item,cell,value,attr,emit,inputName}">
{% block row-control %}
{% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
{% endblock %}
</template>
{% endif %}
{% endfor %}
{% endblock %}
</{{ tag }}>
</div>
{% endblock %}
{% endwith %}

View File

@ -0,0 +1,46 @@
{% extends "./list_editor.html" %}
{% comment %}
Context:
- object: episode
{% endcomment %}
{% block outer %}
{% with tag_id="inline-sounds" %}
{% with tag="a-sound-list-editor" %}
{% with related_field="episode" %}
{{ block.super }}
{% endwith %}
{% endwith %}
{% endwith %}
{% endblock %}
{% block tag-attrs %}
{{ block.super }}
sound-list-url="{% url "api:sound-list" %}?program={{ object.parent_id }}"
sound-upload-url="{% url "api:sound-list" %}"
sound-delete-url="{% url "api:sound-detail" pk=123 %}"
{% endblock %}
{% block inner %}
{{ block.super }}
<template #upload-form>
{% for field in sound_form %}
{% with field.name as name %}
{% if name in "program" %}
{% include "aircox/forms/form_field.html" with value=field.initial field=field.field hidden=True %}
{% elif name != "file" %}
<div class="field is-horizontal">
<label class="label mr-3">{{ field.label }}</label>
{% include "aircox/forms/form_field.html" with value=field.initial field=field.field %}
</div>
{% endif %}
{% endwith %}
{% endfor %}
</template>
<template #row-delete="{cell}">
<input type="checkbox" :name="'{{ formset.prefix }}-' + cell.row + '-DELETE'">
</template>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends "./list_editor.html" %}
{% load i18n %}
{% block outer %}
{% with tag_id="inline-tracks" %}
{% with tag="a-track-list-editor" %}
{% with related_field="episode" %}
{{ block.super }}
{% endwith %}
{% endwith %}
{% endwith %}
{% endblock %}
{% block inner %}
<template #title><h3 class="title">{% translate "Track list" %}</h3></template>
{{ block.super }}
{% endblock %}
{% block row-control %}
{% if name == "tags" %}
<input type="text" class="input"
:name="inputName"
v-model="item.data[attr]"
@change="emit('change', cell.col)"
>
{% else %}
<a-autocomplete
:input-class="['input', item.error(attr) ? 'is-danger' : 'half-field']"
url="{% url 'api:track-autocomplete' %}?{{ name }}=${query}&field={{ name }}"
:name="inputName"
v-model="item.data[attr]"
@change="emit('change', cell.col)"/>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,24 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":v-model" attribute
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.widget.is_hidden or hidden %}
<input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}">
{% elif field|is_select %}
<select :name="{{ name }}" class="select" v-model="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" :name="{{ name }}" v-model="{{ value|default:"" }}">
{% endif %}

View File

@ -14,16 +14,18 @@
{% block subtitle %}{{ date|date:"l d F Y" }}{% endblock %}
{% block before_list %}
{% with "diffusion-list" as url_name %}
{% include "aircox/widgets/dates_menu.html" %}
{% endwith %}
{% block secondary-nav %}
<nav class="nav secondary">
{% include "./widgets/dates_menu.html" with url_name="diffusion-list" %}
</nav>
{% endblock %}
{% block pages_list %}
{% with hide_schedule=True %}
<section role="list">
{% include 'aircox/widgets/diffusion_list.html' %}
<section role="list" class="list">
{% for object in object_list %}
{% page_widget "item" object.episode diffusion=object timetable=True %}
{% endfor %}
</section>
{% endwith %}
{% endblock %}

View File

@ -2,79 +2,51 @@
{% comment %}List of a show's episodes for a specific{% endcomment %}
{% load i18n aircox %}
{% include "aircox/program_sidebar.html" %}
{% block content-container %}
{% block content %}
<a-episode :page="{title: &quot;{{ page.title }}&quot;, podcasts: {{ object.podcasts|json }}}">
<template v-slot="{podcasts,page}">
{{ block.super }}
{{ block.super }}
{% if object.podcasts %}
<section>
<a-playlist v-if="page" :set="podcasts"
name="{{ page.title }}"
list-class="menu-list" item-class="menu-item"
:player="player" :actions="['play']"
@select="player.playItems('queue', $event.item)">
<template v-slot:header>
<h4 class="title is-4">{% translate "Podcasts" %}</h4>
</template>
</a-playlist>
{% comment %}
{% for object in podcasts %}
{% include "aircox/widgets/podcast_item.html" %}
{% endfor %}
{% endcomment %}
</section>
{% endif %}
{% if object.podcasts %}
{% spaceless %}
<section class="container no-border">
<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"
:player="player" :actions="['play', 'pin']"
@select="player.playItems('queue', $event.item)">
</a-playlist>
</section>
{% endspaceless %}
{% endif %}
{% if tracks %}
<section>
<h4 class="title is-4">{% translate "Playlist" %}</h4>
<ol>
{% for track in tracks %}
<li><span>{{ track.title }}</span>
<span class="has-text-grey-dark has-text-weight-light">
&mdash; {{ track.artist }}
{% if track.info %}(<i>{{ track.info }}</i>){% endif %}
</span>
</li>
{% endfor %}
</ol>
</section>
{% endif %}
</template></a-episode>
{% endblock %}
{% block sidebar %}
<section>
<h4 class="title is-4">{% translate "Diffusions" %}</h4>
<ul>
{% for diffusion in object.diffusion_set.all %}
<li>
{% with diffusion.start as start %}
{% with diffusion.end as end %}
<time datetime="{{ start }}">{{ start|date:"D. d F Y, H:i" }}</time>
&mdash;
<time datetime="{{ end }}">{{ end|date:"H:i" }}</time>
{% endwith %}
{% endwith %}
<small>
{% if diffusion.initial %}
{% with diffusion.initial.date as date %}
<span title="{% blocktranslate %}Rerun of {{ date }}{% endblocktranslate %}">
({% translate "rerun" %})
</span>
{% endwith %}
{% endif %}
</small>
<br>
</li>
{% endfor %}
</ul>
</section>
{{ block.super }}
{% if tracks %}
<section class="container">
<h2 class="title is-2">{% translate "Playlist" %}</h2>
<table class="table is-hoverable is-fullwidth">
<thead>
<tr>
<th></th>
<th>{% translate "Artist" %}</th>
<th>{% translate "Title" %}</th>
<th></th>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ track.artist }}</td>
<td>{{ track.title }}</td>
<td>{{ track.info|default:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
</template>
</a-episode>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "./page_form.html" %}
{% load static i18n humanize honeypot aircox %}
{% block page-form %}
<a-episode :page="{title: &quot;{{ object.title }}&quot;, podcasts: {{ object.sounds|json }}}">
<template v-slot="{podcasts,page}">
{{ block.super }}
<hr/>
{% include "./dashboard/widgets/tracklist_editor.html" with formset=tracklist_formset formset_data=tracklist_formset_data %}
<hr/>
<h2 class="title is-2">{% translate "Podcasts" %}</h2>
{% include "./dashboard/widgets/soundlist_editor.html" with formset=soundlist_formset formset_data=soundlist_formset_data %}
</template>
</a-episode>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":value" attribute
- vbind: if True, use ":value" instead of "value"
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.widget.is_hidden or hidden %}
<input type="hidden" name="{{ name }}" value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}>
{% elif field|is_select %}
<select name="{{ name }}" class="select" value="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" name="{{ name }}" value="{{ value|default:"" }}">
{% endif %}

View File

@ -0,0 +1,53 @@
{% comment %}
Base template for list editor based on formsets (tracklist_editor, playlist_editor).
Context:
- tag_id: id of parent component
- tag: vue component tag (a-playlist-editor, etc.)
- related_field: field name that target object
- object: related object
- formset: formset used to render the list editor
- formset_data: formset data
{% endcomment %}
{% load aircox aircox_admin static i18n %}
{% with formset.form.base_fields as fields %}
{% block outer %}
<div id="{{ tag_id }}">
{{ formset.non_form_errors }}
<!-- formset.management_form -->
<{{ tag|default:"a-form-set" }} ref="formset"
{% block tag-attrs %}
:form-data="{{ formset_data|json }}"
:labels="window.aircox.labels"
:columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-"
{% endblock %}>
{% block inner %}
<template #rows-header-head>
{% block rows-header-head %}
<th style="max-width:2em" title="{{ fields.position.help_text }}"
aria-description="{{ fields.position.help_text }}">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
{% endblock %}
</template>
{% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:control-{{ name }}="{context,item,cell,value,attr,emit,inputName}">
{% block row-control %}
{% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
{% endblock %}
</template>
{% endif %}
{% endfor %}
{% endblock %}
</{{ tag }}>
</div>
{% endblock %}
{% endwith %}

View File

@ -0,0 +1,24 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":v-model" attribute
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.widget.is_hidden or hidden %}
<input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}">
{% elif field|is_select %}
<select :name="{{ name }}" class="select" v-model="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" :name="{{ name }}" v-model="{{ value|default:"" }}">
{% endif %}

View File

@ -1,85 +1,74 @@
{% extends "aircox/page_list.html" %}
{% comment %}Home page{% endcomment %}
{% load i18n %}
{% extends "./public.html" %}
{% load i18n aircox %}
{% block head_title %}{{ station.name }}{% endblock %}
{% block title %}
{% if not page or not page.title %}{{ station.name }}
{% else %}{{ block.super }}
{% endif %}
{% endblock %}
{% block title %}{% if page %}{{ block.super }}{% endif %}{% endblock %}
{% block before_list %}{% endblock %}
{% block pages_list %}
{% if page and page.content %}<hr/>{% endif %}
{% block breadcrumbs-container %}{% endblock %}
{% block content-container %}
{{ block.super }}
{% if next_diffs %}
<div class="columns">
{% with render_card=True %}
{% for object in next_diffs %}
{% with is_primary=object.is_now %}
<div class="column is-relative">
<h4 class="card-super-title" title="{{ object.start }}">
{% if is_primary %}
<span class="fas fa-play"></span>
<time datetime="{{ object.start }}">
{% translate "Currently" %}
</time>
{% else %}
{{ object.start|date:"H:i" }}
{% endif %}
<section class="container">
<h2 class="title">
{% with station.name as station %}
{% blocktrans %}Today on {{ station }}{% endblocktrans %}
{% endwith %}
</h2>
{% if object.episode.category %}
// {{ object.episode.category.title }}
{% endif %}
</h4>
{% include object.item_template_name %}
<div class="mb-3">
{% with next_diffs.0 as obj %}
{% page_widget "wide" obj.episode diffusion=obj timetable=True %}
{% endwith %}
</div>
{% endwith %}
{% endfor %}
{% endwith %}
</div>
{% endif %}
{% if object_list %}
<h4 class="title is-4">{% translate "Today" %}</h4>
<section role="list">
{% include 'aircox/widgets/diffusion_list.html' %}
<hr/>
<a-carousel section-class="card-grid">
{% for obj in next_diffs|slice:"1:" %}
{% if object != diffusion %}
{% page_widget "card" obj.episode diffusion=obj timetable=True %}
{% endif %}
{% endfor %}
</a-carousel>
</section>
{% endif %}
{% endblock %}
{% block pagination %}
<ul class="pagination-list">
<li>
<a href="{% url "page-list" %}" class="pagination-link"
aria-label="{% translate "Show all publication" %}">
{% translate "More publications..." %}
{% if logs %}
<section class="container">
<h2 class="title">{% translate "It just happened" %}</h2>
<div class="grid" role="list">
{% include "./widgets/logs.html" with object_list=logs %}
</div>
<nav class="nav-urls">
<a href="{% url "timetable-list" %}"
aria-label="{% translate "Show all program's for today" %}">
{% translate "Today" %}
</a>
</li>
</ul>
</nav>
</section>
{% endif %}
{% if podcasts %}
<section class="container">
<h2 class="title is-3 p-2">{% translate "Last podcasts" %}</h2>
{% include "./widgets/carousel.html" with objects=podcasts url_name="podcast-list" url_label=_("All podcasts") %}
</section>
{% endif %}
{% if publications %}
<section class="container">
<h2 class="title">{% translate "Last publications" %}</h2>
{% include "./widgets/carousel.html" with objects=publications url_name="page-list" url_label=_("All publications") %}
</section>
{% endif %}
{% endblock %}
{% block sidebar %}
<section>
<h4 class="title is-4">{% translate "Previously on air" %}</h4>
{% with has_cover=False %}
{% with logs as object_list %}
{% include "aircox/widgets/log_list.html" %}
{% endwith %}
{% endwith %}
</section>
<section>
<h4 class="title is-4">{% translate "Last publications" %}</h4>
{% with hide_schedule=True %}
{% with has_headline=False %}
{% for object in last_publications %}
{% include object.item_template_name|default:'aircox/widgets/page_item.html' %}
{% endfor %}
{% endwith %}
{% endwith %}
</section>
{% endblock %}
{% block pages_list %}{% endblock %}

View File

@ -1,29 +0,0 @@
{% extends "aircox/page_list.html" %}
{% comment %}List of logs for a specific date{% endcomment %}
{% load i18n humanize aircox %}
{% block title %}
{% if not page or not page.title %}
{% with station.name as station %}
{% blocktranslate %}That happened on {{ station }}{% endblocktranslate %}
{% endwith %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}
{% block subtitle %}{{ date|date:"l d F Y" }}{% endblock %}
{% block before_list %}
{% with "log-list" as url_name %}
{% include "aircox/widgets/dates_menu.html" %}
{% endwith %}
{% endblock %}
{% block pages_list %}
<section>
{# <h4 class="subtitle size-4">{{ date }}</h4> #}
{% include "aircox/widgets/log_list.html" %}
</section>
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "aircox/basepage_detail.html" %}
{% extends "aircox/public.html" %}
{% load static i18n humanize honeypot aircox %}
{% comment %}
Base template used to display a Page
@ -6,83 +6,88 @@ Base template used to display a Page
Context:
- page: page
- parent: parent page
- related_objects: list of object to display as related publications
- related_url: url to the full list of related_objects
{% endcomment %}
{% block header_crumbs %}
{{ block.super }}
{% if page.category %}
{% if parent %} / {% endif %} {{ page.category.title }}
{% block breadcrumbs %}
{% if parent %}
{% include "./widgets/breadcrumbs.html" with page=parent %}
{% if page %}
<a href="{% url page.list_url_name parent_slug=parent.slug %}">
{{ page|verbose_name:True }}
</a>
{% endif %}
{% elif page %}
{% include "./widgets/breadcrumbs.html" with page=page no_title=True %}
{% endif %}
{% endblock %}
{% block top-nav-tools %}
{% has_perm page "change" as can_edit %}
{% if can_edit %}
<a class="navbar-item" href="{{ page|admin_url:'change' }}"
target="new">
<span class="icon is-small">
<i class="fa fa-pen"></i>
</span>&nbsp;
<span>{% translate "Edit" %}</span>
</a>
{% endif %}
{% block title-container %}
{{ block.super }}
{% block page-actions %}
{% include "aircox/widgets/page_actions.html" %}
{% endblock %}
{% endblock %}
{% block main %}
{{ block.super }}
{% block related %}
{% if related_objects %}
<section class="container">
{% with models=object|verbose_name:True %}
<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 %}
</section>
{% endif %}
{% endblock %}
{% block comments %}
{% if comments or comment_form %}
<section class="mt-6">
<h4 class="title is-4">{% translate "Comments" %}</h4>
{% if comments %}
<section class="container">
<h2 class="title is-2">{% translate "Comments" %}</h2>
{% for comment in comments %}
<div class="media box">
<div class="media-content">
<p>
<strong class="mr-2">{{ comment.nickname }}</strong>
<time datetime="{{ comment.date }}" title="{{ comment.date }}">
<small>{{ comment.date|naturaltime }}</small>
</time>
<br>
{{ comment.content }}
</p>
</div>
</div>
{% for object in comments %}
{% page_widget "item" object %}
{% endfor %}
</section>
{% endif %}
{% if comment_form %}
{% if comment_form %}
<section class="container">
<h2 class="title is-2">{% translate "Post a comment" %}</h2>
<form method="POST">
<h5 class="title is-5">{% translate "Post a comment" %}</h5>
{% csrf_token %}
{% render_honeypot_field "website" %}
{% for field in comment_form %}
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">
{{ field.label_tag }}
</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">{{ field }}</p>
{% if field.errors %}
<p class="help is-danger">{{ field.errors }}</p>
{% endif %}
{% if field.help_text %}
<p class="help">{{ field.help_text|safe }}</p>
{% endif %}
</div>
<div class="field">
<div class="control">
{{ comment_form.content }}
</div>
</div>
{% for field in comment_form %}
{% if field.name != "content" %}
<div class="field is-horizontal">
<label class="label">{{ field.label }}</label>
<div class="control">{{ field }}</div>
</div>
{% if field.errors %}
<p class="help is-danger">{{ field.errors }}</p>
{% endif %}
{% if field.help_text %}
<p class="help">{{ field.help_text|safe }}</p>
{% endif %}
{% endif %}
{% endfor %}
<div class="has-text-right">
<button type="reset" class="button is-danger">{% translate "Reset" %}</button>
<button type="submit" class="button is-success">{% translate "Post comment" %}</button>
<button type="submit" class="button">{% translate "Post comment" %}</button>
</div>
</form>
{% endif %}
</section>
{% endif %}

View File

@ -0,0 +1,91 @@
{% extends "./dashboard/base.html" %}
{% load static aircox_admin i18n %}
{% block init-scripts %}
aircox.labels = {% inline_labels %}
{{ block.super }}
{% endblock %}
{% block header-cover %}
<div class="flex-column">
<img src="{{ cover }}" ref="cover" class="cover">
<button type="button" class="button" @click="$refs['cover-select'].open()">
{% translate "Change cover" %}
</button>
</div>
{% endblock %}
{% block content-container %}
<a-select-file ref="cover-select"
:labels="window.aircox.labels"
list-url="{% url "api:image-list" %}"
upload-url="{% url "api:image-list" %}"
delete-url="{% url "api:image-detail" pk=123 %}"
title="{% translate "Select an image" %}" list-class="grid-4"
@select="(event) => fileSelected('cover-select', 'cover-input', $refs.cover)"
>
<template #upload-preview="{upload}">
<img :src="upload.fileURL" class="upload-preview blink"/>
</template>
<template #default="{item, load, lastUrl}">
<div class="flex-column is-fullheight" v-if="item">
<figure class="flex-grow-1">
<img :src="item.file"/>
</figure>
<div>
<label class="label small">[[ item.name || item.original_filename ]]</label>
<a-action-button
class="has-text-danger small float-right"
icon="fa fa-trash"
confirm="{% translate "Are you sure you want to remove this item from server?" %}"
method="DELETE"
:url="'{% url "api:image-detail" pk="123" %}'.replace('123', item.id)"
@done="load(lastUrl)">
</a-action-button>
</div>
</div>
</template>
</a-select-file>
<section class="container">
<form method="post" enctype="multipart/form-data">
{% block page-form %}
{% csrf_token %}
{% for field in form %}
{% if field.name == "cover" %}
<input type="hidden" name="{{ field.name }}" value="{{ field.value }}" ref="cover-input"/>
{% else %}
<div class="field {% if field.name != "content" %}is-horizontal{% endif %}">
<label class="label">{{ field.label }}</label>
<div class="control clear-unset">
{% if field.name == "pub_date" %}
<input type="datetime-local" class="input" name="{{ field.name }}"
value="{{ field.value|date:"Y-m-d" }}T{{ field.value|date:"H:i" }}"/>
{% elif field.name == "content" %}
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
{% else %}
{% include "aircox/forms/form_field.html" with field=field.field name=field.name value=field.initial %}
{% endif %}
</div>
<p class="help">{{ field.help_text }}</p>
</div>
{% endif %}
{% if field.errors %}
<p class="help is-danger">{{ field.errors }}</p>
{% endif %}
{% if field.help_text %}
<p class="help">{{ field.help_text|safe }}</p>
{% endif %}
{% endfor %}
{% endblock %}
<hr/>
<div class="flex-row">
<div class="flex-grow-1">{% block page-form-actions %}{% endblock %}</div>
<div class="has-text-right">
<button type="submit" class="button">{% translate "Update" %}</button>
</div>
</form>
</section>
{% endblock %}

View File

@ -2,61 +2,60 @@
{% comment %}Display a list of Pages{% endcomment %}
{% load i18n aircox %}
{% block before_list %}
{{ block.super }}
{% if view.has_filters and object_list %}
<form method="GET" action="" class="media">
<div class="media-content">
{% block filters %}
<div class="field is-horizontal">
<div class="field-label">
<label class="label">{% translate "Search" %}</label>
</div>
<div class="field-body">
<div class="field">
<div class="control has-icons-left">
<span class="icon is-small is-left">
<i class="fa fa-search"></i>
</span>
<input class="input" type="text" name="q"
value="{{ filterset_data.q }}"
placeholder="{% translate "Search content" %}">
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">{% translate "Categories" %}</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="control">
{% for label, value in categories %}
<label class="checkbox">
<input type="checkbox" class="checkbox" name="category__id__in"
value="{{ value }}"
{% if value in filterset_data.category__id__in %}checked{% endif %} />
{{ label }}
</label>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block secondary-nav %}
{% if not parent and categories %}
<nav class="nav secondary">
<div class="nav-menu nav-categories">
{% for cat in categories %}
<a class="nav-item{% if cat == category %} active{% endif %}"
href="{% url request.resolver_match.url_name category_slug=cat.slug %}">
{{ cat.title }}
</a>
{% endfor %}
</div>
<div class="media-right">
<div class="field is-grouped is-grouped-right">
<div class="control">
<button class="button is-primary"/>{% translate "Apply" %}</button>
</div>
<div class="control">
<a href="?" class="button is-secondary">{% translate "Reset" %}</a>
</div>
</div>
</div>
</form>
<a-switch class="button burger"
el=".nav-categories" group="nav" icon="fas fa-tags"
aria-label="{% translate "Categories" %}">
</a-switch>
</nav>
{% endif %}
{% endblock %}
{% block title %}
{% if parent %}{{ parent.title }}
{% else %}{{ block.super }}
{% endif %}
{% endblock %}
{% block header %}
{% if page and not object %}
{% with page as object %}
{{ block.super }}
{% endwith %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}
{% block breadcrumbs %}
{% if parent and model.list_url_name %}
{% include "./widgets/breadcrumbs.html" with page=parent %}
<a href="{% url model.list_url_name %}">{{ model|verbose_name:True }}</a>
{% elif page and model.list_url_name %}
<a href="{% url model.list_url_name %}">{{ page.title }}</a>
{% if category %}
<a href="{% url request.resolver_match.url_name category_slug=category.slug %}">
{{ category.title }}
</a>
{% endif %}
{% else %}
<a href="{% url request.resolver_match.url_name %}">{{ model|verbose_name:True }}</a>
{% if category %}
<a href="{% url request.resolver_match.url_name category_slug=category.slug %}">
{{ category.title }}
</a>
{% endif %}
{% endif %}
{% endblock %}
{% block content-container %}{% endblock %}

Some files were not shown because too many files have changed in this diff Show More