Compare commits

..

No commits in common. "develop-1.0" and "master" have entirely different histories.

487 changed files with 37185 additions and 132789 deletions

3
.gitignore vendored
View File

@ -6,5 +6,4 @@ node_modules/
*.egg-info/
*.egg
db.sqlite3
instance/settings/settings.py

View File

@ -1,20 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.292
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/PyCQA/docformatter.git
rev: v1.5.1
hooks:
- id: docformatter

73
README.md Normal file → Executable file
View File

@ -1,64 +1,20 @@
![](/logo.png)
A platform to manage radio schedules, website content, and more. It uses the power of great tools like Django or Liquidsoap.
Platform to manage a radio, schedules, website, and so on. We use the power of great tools like Django or Liquidsoap.
This project is distributed under GPL version 3. More information in the LICENSE file, except for some files whose license is indicated.
This project is distributed under GPL version 3. More information in the LICENSE file, except for some files whose license is indicated inside source code.
## Features
* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency for each;
* **diffusions**: generate diffusions time slot for programs that have schedule informations. Check for conflicts and rerun.
* **liquidsoap**: create a configuration to use liquidsoap as a stream generator. Also provides interface and control to it;
* **sounds**: each programs have a folder for its podcast. Aircox detects updates, can run quality check, import related playlist (timestamped or position in track list). Sounds can be defined as excerpts or as archives.
* **sounds**: each programs have a folder where sounds can be put, that will be detected by the system. Quality can be check and reported for later use. Later, we plan to have uploaders to external plateforms. Sounds can be defined as excerpts or as archives.
* **cms**: application that can be used as basis for website;
* **log**: keep a trace of every played/loaded sounds on the stream generator.
* **admin**: admin user interface.
* **cms**: content management system.
## Architecture and concepts
Aircox is divided in two main modules:
* `aircox`: basics of Aircox (programs, diffusions, sounds, etc. management); interface for managing a website with Aircox elements (playlists, timetable, players on the website);
* `aircox_streamer`: interact with application to generate audio stream (LiquidSoap);
## Development setup
Start installing a virtual environment :
```
virtualenv venv
source venv/bin/activate
pip install -r requirements.txt
pip install -r requirements_tests.txt
```
Then copy the default settings and initiate the database :
```
cp instance/settings/sample.py instance/settings/settings.py
python -c "from django.core.management.utils import get_random_secret_key; print('SECRET_KEY = \"%s\"' % get_random_secret_key())" >> instance/settings/settings.py
DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py migrate
```
Finally test and run the instance using development settings, and point your browser to http://localhost:8000 :
```
DJANGO_SETTINGS_MODULE=instance.settings.dev pytest
DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py runserver
```
Before requesting a merge, enable pre-commit :
```
pip install pre-commit
pre-commit install
```
## Installation
Running Aircox on production involves:
* Aircox modules and a running Django project;
* a supervisor for common tasks (sounds monitoring, stream control, etc.) -- `supervisord`;
* a wsgi and an HTTP server -- `gunicorn`, `nginx`;
* a database supported by Django (MySQL, SQLite, PostGresSQL);
### Scripts
## Scripts
Are included various configuration scripts that can be used to ease setup. They
assume that the project is present in `/srv/apps/aircox`:
@ -70,6 +26,9 @@ The scripts are written with a combination of `cron`, `supervisord`, `nginx`
and `gunicorn` in mind.
## Installation
Later we plan to have an installation script to reduce the number of above steps.
### Dependencies
For python dependencies take a peek at the `requirements.txt` file, plus
dependencies specific to Django (e.g. for database: `mysqlclient` for MySql
@ -91,7 +50,7 @@ Development dependencies:
All scripts and files assumes that:
- you have cloned aircox in `/srv/apps/` (such as `/srv/apps/aircox/README.md`)
- you have a supervisor running (we have scripts for `supervisord`)
- you use `gunicorn` as WSGI server (otherwise, you'll need to remove it from the requirement list)
- you want to use `gunicorn` as WSGI server (otherwise, you'll need to remove it from the requirement list)
This installation process uses a virtualenv, including all provided scripts.
@ -104,8 +63,8 @@ pip install -r requirements.txt
```
### Configuration
You must write a settings.py file in the `instance/settings` directory (you can just
copy and paste `instance/settings/sample.py`. There still is configuration
You must write a settings.py file in the `instance` directory (you can just
copy and paste `instance/sample_settings.py`. There still is configuration
required in this file, check it in for more info.
@ -128,7 +87,8 @@ server from this directory:
./manage.py runserver
```
You can access to the django admin interface at `http://127.0.0.1:8000/admin`.
You can access to the django admin interface at `http://127.0.0.1:8000/admin`
and to the cms interface at `http://127.0.0.1:8000/cms/`.
From the admin interface:
* create a Station
@ -136,6 +96,8 @@ From the admin interface:
* defines Outputs for the streamer (look at Liquidsoap documentation for
more information on how to configure it)
TODO: cms related documentation here
Once the configuration is okay, you must start the *controllers monitor*,
that creates configuration file for the audio streams using the new information
and that runs the appropriate application (note that you dont need to restart it
@ -145,4 +107,5 @@ If you use supervisord and our script with it, you can use the services defined
in it instead of running commands manually.
## More informations
There are extra informations in `aircox/README.md` and `aircox_streamer/README.md`.
There are extra informations in `aircox/README.md`.

View File

@ -6,13 +6,16 @@ A Station contains programs that can be scheduled or streamed. A *Scheduled Prog
Each program has a directory on the server where user puts its podcasts (in **AIRCOX_PROGRAM_DIR**). It contains the directories **archives** (complete show's podcasts) and **excerpts** (partial or whatever podcasts).
## manage.py's commands
* `diffusions`: update/create, check and clean diffusions based on programs schedules;
* `import_playlist`: import a playlist from a csv file, and associate it to a sound;
* `sounds_monitor`: check for existing and missing sounds files in programs directories and synchronize the database. It can check for the quality of file and update sound info.
* `sounds_quality_check`: check for the quality of the file (don't update database);
* **diffusions**: update/create, check and clean diffusions based on programs schedules;
* **import_playlist**: import a playlist from a csv file, and associate it to a sound;
* **sound_monitor**: check for existing and missing sounds files in programs directories and synchronize the database. It can check for the quality of file and update sound info.
* **sound_quality_check**: check for the quality of the file (don't update database);
* **streamer**: audio stream generation and control it;
## Requirements
* Sox (and soxi): sound file monitor and quality check
* requirements.txt for python's dependecies

View File

@ -0,0 +1 @@

View File

@ -1,25 +1,8 @@
from . import filters
from .article import ArticleAdmin
from .diffusion import DiffusionAdmin
from .episode import EpisodeAdmin
from .episode import DiffusionAdmin, EpisodeAdmin
from .log import LogAdmin
from .page import PageAdmin, StaticPageAdmin
from .program import ProgramAdmin
from .schedule import ScheduleAdmin
from .program import ProgramAdmin, ScheduleAdmin, StreamAdmin
from .sound import SoundAdmin, TrackAdmin
from .station import StationAdmin
__all__ = (
"filters",
"ArticleAdmin",
"DiffusionAdmin",
"EpisodeAdmin",
"LogAdmin",
"PageAdmin",
"StaticPageAdmin",
"ProgramAdmin",
"ScheduleAdmin",
"SoundAdmin",
"TrackAdmin",
"StationAdmin",
)

View File

@ -1,12 +1,17 @@
import copy
from django.contrib import admin
from ..models import Article
from .page import PageAdmin
__all__ = ["ArticleAdmin"]
__all__ = ['ArticleAdmin']
@admin.register(Article)
class ArticleAdmin(PageAdmin):
search_fields = PageAdmin.search_fields + ("parent__title",)
search_fields = PageAdmin.search_fields + ('parent__title',)
# TODO: readonly field

View File

@ -1,50 +0,0 @@
from django.contrib import admin
from django.utils.translation import gettext as _
from aircox.models import Diffusion
__all__ = ("DiffusionBaseAdmin", "DiffusionAdmin", "DiffusionInline")
class DiffusionBaseAdmin:
fields = ("type", "start", "end", "schedule")
readonly_fields = ("schedule",)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if not request.user.has_perm("aircox_program.scheduling"):
fields = fields + ("program", "start", "end")
return [field for field in fields if field in self.fields]
@admin.register(Diffusion)
class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin):
def start_date(self, obj):
return obj.local_start.strftime("%Y/%m/%d %H:%M")
start_date.short_description = _("start")
def end_date(self, obj):
return obj.local_end.strftime("%H:%M")
end_date.short_description = _("end")
list_display = ("episode", "start", "end", "type", "initial")
list_filter = ("type", "start", "program")
list_editable = ("type", "start", "end")
ordering = ("-start", "id")
search_fields = ("program__title", "episode__title")
fields = ("type", "start", "end", "initial", "program", "schedule")
autocomplete_fields = ("episode", "program", "initial")
readonly_fields = ("schedule",)
class DiffusionInline(DiffusionBaseAdmin, admin.TabularInline):
model = Diffusion
fk_name = "episode"
extra = 0
def has_add_permission(self, request, obj):
return request.user.has_perm("aircox_program.scheduling")

View File

@ -1,39 +1,68 @@
from adminsortable2.admin import SortableAdminBase
from copy import copy
from django.contrib import admin
from django.forms import ModelForm
from django.utils.translation import gettext as _
from aircox.models import Episode, EpisodeSound
from .page import ChildPageAdmin
from .sound import TrackInline
from .diffusion import DiffusionInline
from ..models import Episode, Diffusion
from .page import PageAdmin
from .sound import SoundInline, TrackInline
class EpisodeSoundInline(admin.TabularInline):
model = EpisodeSound
class DiffusionBaseAdmin:
fields = ('type', 'start', 'end', 'schedule')
readonly_fields = ('schedule',)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if not request.user.has_perm('aircox_program.scheduling'):
fields = fields + ('program', 'start', 'end')
return [field for field in fields if field in self.fields]
@admin.register(Diffusion)
class DiffusionAdmin(DiffusionBaseAdmin, admin.ModelAdmin):
def start_date(self, obj):
return obj.local_start.strftime('%Y/%m/%d %H:%M')
start_date.short_description = _('start')
def end_date(self, obj):
return obj.local_end.strftime('%H:%M')
end_date.short_description = _('end')
list_display = ('episode', 'start_date', 'end_date', 'type', 'initial')
list_filter = ('type', 'start', 'program')
list_editable = ('type',)
ordering = ('-start', 'id')
fields = ('type', 'start', 'end', 'initial', 'program', 'schedule')
readonly_fields = ('schedule',)
class DiffusionInline(DiffusionBaseAdmin, admin.TabularInline):
model = Diffusion
fk_name = 'episode'
extra = 0
fields = (
"sound",
"position",
"broadcast",
)
autocomplete_fields = ("sound",)
def has_add_permission(self, request, obj):
return request.user.has_perm('aircox_program.scheduling')
class EpisodeAdminForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["parent"].required = True
self.fields['parent'].required = True
@admin.register(Episode)
class EpisodeAdmin(SortableAdminBase, ChildPageAdmin):
class EpisodeAdmin(PageAdmin):
form = EpisodeAdminForm
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 = ChildPageAdmin.search_fields + ("parent__title",)
list_display = PageAdmin.list_display
list_filter = PageAdmin.list_filter + ('diffusion__start',)
search_fields = PageAdmin.search_fields + ('parent__title',)
# readonly_fields = ('parent',)
inlines = (TrackInline, EpisodeSoundInline, DiffusionInline)
inlines = [TrackInline, SoundInline, DiffusionInline]

View File

@ -1,72 +0,0 @@
from django.contrib.admin import filters
from django.db import models
from django.utils.http import urlencode
from django.utils.translation import gettext_lazy as _
__all__ = ("DateFieldFilter", "DateTimeFieldFilter")
class DateFieldFilter(filters.FieldListFilter):
"""Display date input."""
template = "admin/aircox/filters/date_filter.html"
input_type = "date"
def __init__(self, field, request, params, model, model_admin, field_path):
self.field_generic = f"{field_path}__"
self.date_params = {k: v for k, v in params.items() if k.startswith(self.field_generic)}
exact_lookup = "date" if isinstance(field, models.DateTimeField) else "exact"
# links as: (label, param, input_type|None, value)
self.links = [
(_("Exact"), self.field_generic + exact_lookup, self.input_type),
(_("Since"), self.field_generic + "gte", self.input_type),
(_("Until"), self.field_generic + "lte", self.input_type),
]
if field.null:
self.links.insert(0, (_("None"), self.field_generic + "isnull", None, "1"))
self.query_attrs = {k: v for k, v in request.GET.items() if k not in self.date_params}
self.query_string = urlencode(self.query_attrs)
super().__init__(field, request, params, model, model_admin, field_path)
def expected_parameters(self):
return [link[1] for link in self.links]
def choices(self, changelist):
yield {
"label": _("Any"),
"type": None,
"query_string": self.query_string,
}
for link in self.links:
value = len(link) > 3 and link[3] or self.date_params.get(link[1])
yield {
"label": link[0],
"name": link[1],
"value": value,
"type": link[2],
"query_attrs": self.query_attrs,
"query_string": urlencode({link[1]: value}) + "&" + self.query_string if value else self.query_string,
}
class DateTimeFieldFilter(DateFieldFilter):
"""Display datetime input."""
input_type = "datetime-local"
filters.FieldListFilter.register(
lambda f: isinstance(f, models.DateField),
DateFieldFilter,
take_priority=True,
)
filters.FieldListFilter.register(
lambda f: isinstance(f, models.DateTimeField),
DateTimeFieldFilter,
take_priority=True,
)

View File

@ -2,10 +2,12 @@ from django.contrib import admin
from ..models import Log
__all__ = ["LogAdmin"]
__all__ = ['LogAdmin']
@admin.register(Log)
class LogAdmin(admin.ModelAdmin):
list_display = ["id", "date", "station", "source", "type", "comment"]
list_filter = ["date", "source", "station"]
list_display = ['id', 'date', 'station', 'source', 'type', 'comment']
list_filter = ['date', 'source', 'station']

42
aircox/admin/mixins.py Normal file
View File

@ -0,0 +1,42 @@
class UnrelatedInlineMixin:
"""
Inline class that can be included in an admin change view whose model
is not directly related to inline's model.
"""
view_model = None
parent_model = None
parent_fk = ''
def __init__(self, parent_model, admin_site):
self.view_model = parent_model
super().__init__(self.parent_model, admin_site)
def get_parent(self, view_obj):
""" Get formset's instance from `obj` of AdminSite's change form. """
field = self.parent_model._meta.get_field(self.parent_fk).remote_field
return getattr(view_obj, field.name, None)
def save_parent(self, parent, view_obj):
""" Save formset's instance. """
setattr(parent, self.parent_fk, view_obj)
parent.save()
return parent
def get_formset(self, request, obj):
ParentFormSet = super().get_formset(request, obj)
inline = self
class FormSet(ParentFormSet):
view_obj = None
def __init__(self, *args, instance=None, **kwargs):
self.view_obj = instance
instance = inline.get_parent(instance)
self.instance = instance
super().__init__(*args, instance=instance, **kwargs)
def save(self):
inline.save_parent(self.instance, self.view_obj)
return super().save()
return FormSet

View File

@ -1,123 +1,107 @@
from copy import deepcopy
from adminsortable2.admin import SortableInlineAdminMixin
from django.contrib import admin
from django.http import QueryDict
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from adminsortable2.admin import SortableInlineAdminMixin
from ..models import Category, Comment, NavItem, Page, StaticPage
__all__ = ("CategoryAdmin", "PageAdmin", "NavItemInline")
__all__ = ['CategoryAdmin', 'PageAdmin', 'NavItemInline']
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ["pk", "title", "slug"]
list_editable = ["title", "slug"]
search_fields = ["title"]
fields = ["title", "slug"]
list_display = ['pk', 'title', 'slug']
list_editable = ['title', 'slug']
search_fields = ['title']
fields = ['title', 'slug']
prepopulated_fields = {"slug": ("title",)}
ordering = ("title",)
class BasePageAdmin(admin.ModelAdmin):
list_display = ("cover_thumb", "title", "status")
list_display_links = ("cover_thumb", "title")
list_editable = ("status",)
list_filter = ("status",)
list_display = ('cover_thumb', 'title', 'status', 'parent')
list_display_links = ('cover_thumb', 'title')
list_editable = ('status',)
list_filter = ('status',)
prepopulated_fields = {"slug": ("title",)}
# prepopulate fields using changelist's filters
prepopulated_filters = ("parent",)
prepopulated_filters = ('parent',)
search_fields = ("title",)
search_fields = ('title',)
fieldsets = [
(
"",
{
"fields": ["title", "slug", "cover", "content"],
},
),
(
_("Publication Settings"),
{
"fields": [
"status",
],
},
),
('', {
'fields': ['title', 'slug', 'cover', 'content'],
}),
(_('Publication Settings'), {
'fields': ['status', 'parent'],
}),
]
change_form_template = 'admin/aircox/page_change_form.html'
def cover_thumb(self, obj):
if obj.cover and obj.cover.thumbnails:
return mark_safe('<img src="{}"/>'.format(obj.cover.icons["64"]))
return ""
def _get_extra_context(self, query, **extra_context):
return extra_context
def add_view(self, request, form_url="", extra_context=None):
filters = QueryDict(request.GET.get("_changelist_filters", ""))
extra_context = self._get_extra_context(filters, **(extra_context or {}))
return super().add_view(request, form_url, extra_context)
def changelist_view(self, request, extra_context=None):
extra_context = self._get_extra_context(request.GET, **(extra_context or {}))
return super().changelist_view(request, extra_context)
@admin.register(Page)
class PageAdmin(BasePageAdmin):
list_display = BasePageAdmin.list_display + ("category",)
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[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",)
autocomplete_fields = ("parent",)
fieldsets = deepcopy(PageAdmin.fieldsets)
fieldsets[1][1]["fields"] += ("parent",)
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)
filters = QueryDict(request.GET.get('_changelist_filters', ''))
data['parent'] = filters.get('parent', None)
return data
def _get_extra_context(self, query, **extra_context):
parent = query.get("parent", None)
extra_context["parent"] = None if parent is None else Page.objects.get_subclass(id=parent)
return super()._get_extra_context(query, **extra_context)
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
if context['original'] and not 'parent' in context:
context['parent'] = context['original'].parent
return super().render_change_form(request, context, *args, **kwargs)
def add_view(self, request, form_url='', extra_context=None):
filters = QueryDict(request.GET.get('_changelist_filters', ''))
extra_context = self._get_common_context(filters, extra_context)
return super().add_view(request, form_url, extra_context)
def changelist_view(self, request, extra_context=None):
extra_context = self._get_common_context(request.GET, extra_context)
return super().changelist_view(request, extra_context)
class PageAdmin(BasePageAdmin):
change_list_template = 'admin/aircox/page_change_list.html'
list_display = BasePageAdmin.list_display + ('category',)
list_editable = BasePageAdmin.list_editable + ('category',)
list_filter = BasePageAdmin.list_editable + ('category',)
search_fields = ('category__title',)
fieldsets = deepcopy(BasePageAdmin.fieldsets)
fieldsets[0][1]['fields'].insert(fieldsets[0][1]['fields'].index('slug') + 1, 'category')
fieldsets[1][1]['fields'] += ('featured', 'allow_comments')
@admin.register(StaticPage)
class StaticPageAdmin(BasePageAdmin):
list_display = BasePageAdmin.list_display + ("attach_to",)
list_editable = BasePageAdmin.list_editable + ("attach_to",)
list_display = BasePageAdmin.list_display + ('attach_to',)
fieldsets = deepcopy(BasePageAdmin.fieldsets)
fieldsets[1][1]["fields"] += ("attach_to",)
fieldsets[1][1]['fields'] += ('attach_to',)
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ("page_title", "date", "nickname")
list_filter = ("date",)
search_fields = ("page__title", "nickname")
readonly_fields = ("page",)
list_display = ('page_title', 'date', 'nickname')
list_filter = ('date',)
search_fields = ('page__title', 'nickname')
def page_title(self, obj):
return obj.page.title
@ -125,3 +109,4 @@ class CommentAdmin(admin.ModelAdmin):
class NavItemInline(SortableInlineAdminMixin, admin.TabularInline):
model = NavItem

View File

@ -1,20 +1,33 @@
from copy import copy
from django.contrib import admin
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from aircox.models import Program, Schedule, Stream
from ..models import Program, Schedule, Stream
from .page import PageAdmin
from .schedule import ScheduleInline
__all__ = (
"ProgramAdmin",
"StreamInline",
)
# In order to simplify schedule_post_save algorithm, an existing schedule can't
# update the following fields: "frequency", "date"
class ScheduleInlineForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.initial:
self.fields['date'].disabled = True
self.fields['frequency'].disabled = True
class ScheduleInline(admin.TabularInline):
model = Schedule
form = ScheduleInlineForm
readonly_fields = ('timezone',)
extra = 1
class StreamInline(admin.TabularInline):
model = Stream
fields = ["delay", "begin", "end"]
fields = ['delay', 'begin', 'end']
extra = 1
@ -26,23 +39,48 @@ class ProgramAdmin(PageAdmin):
schedule.boolean = True
schedule.short_description = _("Schedule")
list_display = PageAdmin.list_display + ("schedule", "station", "active")
list_filter = PageAdmin.list_filter + ("station", "active")
prepopulated_fields = {"slug": ("title",)}
search_fields = ("title",)
ordering = ("title",)
list_display = PageAdmin.list_display + ('schedule', 'station', 'active')
list_filter = PageAdmin.list_filter + ('station', 'active')
prepopulated_fields = {'slug': ('title',)}
search_fields = ('title',)
inlines = [ScheduleInline, StreamInline]
def get_fieldsets(self, request, obj=None):
fields = super().get_fieldsets(request, obj)
if request.user.has_perm("aircox.program.scheduling"):
if request.user.has_perm('aircox.program.scheduling'):
fields = fields + [
(
_("Program Settings"),
{
"fields": ["active", "station", "sync"],
},
)
(_('Program Settings'), {
'fields': ['active', 'station', 'sync'],
})
]
return fields
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
def program_title(self, obj):
return obj.program.title
program_title.short_description = _('Program')
def freq(self, obj):
return obj.get_frequency_verbose()
freq.short_description = _('Day')
list_filter = ['frequency', 'program']
list_display = ['program_title', 'freq', 'time', 'timezone', 'duration',
'initial']
list_editable = ['time', 'duration', 'initial']
def get_readonly_fields(self, request, obj=None):
if obj:
return ['program', 'date', 'frequency']
else:
return []
@admin.register(Stream)
class StreamAdmin(admin.ModelAdmin):
list_display = ('id', 'program', 'delay', 'begin', 'end')

View File

@ -1,59 +0,0 @@
from django.contrib import admin
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _
from aircox.models import Schedule
__all__ = ("ScheduleInlineForm", "ScheduleInline", "ScheduleAdmin")
# In order to simplify schedule_post_save algorithm, an existing schedule can't
# update the following fields: "frequency", "date"
class ScheduleInlineForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.initial:
self.fields["date"].disabled = True
self.fields["frequency"].disabled = True
class ScheduleInline(admin.TabularInline):
model = Schedule
form = ScheduleInlineForm
readonly_fields = ("timezone",)
autocomplete_fields = ("initial",)
extra = 1
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin):
def program_title(self, obj):
return obj.program.title
program_title.short_description = _("Program")
def freq(self, obj):
return obj.get_frequency_display()
freq.short_description = _("Day")
list_filter = ["frequency", "program"]
list_display = [
"program_title",
"freq",
"time",
"timezone",
"duration",
"initial",
]
list_editable = ("time", "duration", "initial")
autocomplete_fields = ("initial",)
search_fields = ("program__title",)
ordering = ("program__title", "initial", "date")
def get_readonly_fields(self, request, obj=None):
if obj:
return ["program", "date", "frequency"]
else:
return []

View File

@ -1,158 +1,83 @@
import math
from adminsortable2.admin import SortableAdminBase
from django.contrib import admin
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from adminsortable2.admin import SortableInlineAdminMixin
from ..models import Sound, Track
class TrackInline(admin.TabularInline):
class TrackInline(SortableInlineAdminMixin, admin.TabularInline):
template = 'admin/aircox/playlist_inline.html'
model = Track
extra = 0
fields = ("position", "artist", "title", "tags", "album", "year", "info")
list_display = ["artist", "album", "title", "tags", "related"]
list_filter = ["artist", "album", "title", "tags"]
fields = ('position', 'artist', 'title', 'info', 'tags')
list_display = ['artist', 'title', 'tags', 'related']
list_filter = ['artist', 'title', 'tags']
class SoundTrackInline(TrackInline):
fields = TrackInline.fields + ("timestamp",)
fields = TrackInline.fields + ('timestamp',)
class SoundInline(admin.TabularInline):
model = Sound
fields = [
"name",
"audio",
"duration",
"broadcast",
"is_good_quality",
"is_public",
"is_downloadable",
"is_removed",
]
readonly_fields = ["broadcast", "audio", "duration", "is_good_quality"]
fields = ['type', 'name', 'audio', 'duration', 'is_good_quality', 'is_public']
readonly_fields = ['type', 'audio', 'duration', 'is_good_quality']
extra = 0
max_num = 0
def audio(self, obj):
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url))
return mark_safe('<audio src="{}" controls></audio>'.format(obj.url()))
audio.short_descripton = _('Audio')
audio.short_description = _("Audio")
def get_queryset(self, request):
return super().get_queryset(request).available()
@admin.register(Sound)
class SoundAdmin(SortableAdminBase, admin.ModelAdmin):
class SoundAdmin(admin.ModelAdmin):
fields = None
list_display = [
"id",
"name",
# "related",
"broadcast",
"duration",
"is_public",
"is_good_quality",
"is_downloadable",
"audio",
]
list_filter = ("broadcast", "is_good_quality", "is_public")
list_editable = ["name", "is_public", "is_downloadable"]
list_display = ['id', 'name', 'related',
'type', 'duration', 'is_public', 'is_good_quality',
'audio']
list_filter = ('type', 'is_good_quality', 'is_public')
list_editable = ['name', 'type', 'is_public']
search_fields = ["name", "program__title", "file"]
autocomplete_fields = ("program",)
search_fields = ['name', 'program__title']
fieldsets = [
(
None,
{
"fields": [
"name",
"file",
"broadcast",
"program",
]
},
),
(
None,
{
"fields": [
"duration",
"is_public",
"is_downloadable",
"is_good_quality",
"mtime",
]
},
),
(None, {'fields': ['name', 'path', 'type', 'program', 'episode']}),
(None, {'fields': ['duration', 'is_public', 'is_good_quality', 'mtime']}),
]
readonly_fields = ("file", "duration", "is_removed")
readonly_fields = ('path', 'duration',)
inlines = [SoundTrackInline]
def related(self, obj):
# # TODO: link to episode or program edit
return obj.program.title if obj.program else ""
# return obj.episode.title if obj.episode else obj.program.title if obj.program else ""
related.short_description = _("Program")
# TODO: link to episode or program edit
return obj.episode.title if obj.episode else\
obj.program.title if obj.program else ''
related.short_description = _('Program / Episode')
def audio(self, obj):
return mark_safe('<audio src="{}" controls></audio>'.format(obj.file.url)) if not obj.is_removed else ""
audio.short_description = _("Audio")
def add_view(self, request, form_url="", context=None):
context = context or {}
context["init_app"] = True
context["init_el"] = "#inline-tracks"
context["track_timestamp"] = True
return super().add_view(request, form_url, context)
def change_view(self, request, object_id, form_url="", context=None):
context = context or {}
context["init_app"] = True
context["init_el"] = "#inline-tracks"
context["track_timestamp"] = True
return super().change_view(request, object_id, form_url, context)
return mark_safe('<audio src="{}" controls></audio>'.format(obj.url()))
audio.short_descripton = _('Audio')
@admin.register(Track)
class TrackAdmin(admin.ModelAdmin):
def tag_list(self, obj):
return ", ".join(o.name for o in obj.tags.all())
return u", ".join(o.name for o in obj.tags.all())
list_display = [
"pk",
"artist",
"title",
"tag_list",
"episode",
"sound",
"ts",
]
list_editable = ["artist", "title"]
list_filter = ["artist", "title", "tags"]
list_display = ['pk', 'artist', 'title', 'tag_list', 'episode', 'sound', 'timestamp']
list_editable = ['artist', 'title']
list_filter = ['artist', 'title', 'tags']
search_fields = ["artist", "title"]
search_fields = ['artist', 'title']
fieldsets = [
(
_("Playlist"),
{"fields": ["episode", "sound", "position", "timestamp"]},
),
(_("Info"), {"fields": ["artist", "title", "info", "tags"]}),
(_('Playlist'), {'fields': ['episode', 'sound', 'position', 'timestamp']}),
(_('Info'), {'fields': ['artist', 'title', 'info', 'tags']}),
]
# TODO on edit: readonly_fields = ['episode', 'sound']
def ts(self, obj):
ts = obj.timestamp
if ts is None:
return ""
h = math.floor(ts / 3600)
m = math.floor((ts - h) / 60)
s = ts - h * 3600 - m * 60
return "{:0>2}:{:0>2}:{:0>2}".format(h, m, s)
ts.short_description = _("timestamp")

View File

@ -1,10 +1,10 @@
from adminsortable2.admin import SortableAdminBase
from django.contrib import admin
from ..models import Port, Station
from .page import NavItemInline
__all__ = ["PortInline", "StationAdmin"]
__all__ = ['PortInline', 'StationAdmin']
class PortInline(admin.StackedInline):
@ -13,6 +13,8 @@ class PortInline(admin.StackedInline):
@admin.register(Station)
class StationAdmin(SortableAdminBase, admin.ModelAdmin):
prepopulated_fields = {"slug": ("name",)}
class StationAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)}
inlines = (PortInline, NavItemInline)

65
aircox/admin_site.py Normal file
View File

@ -0,0 +1,65 @@
from django.contrib import admin
from django.urls import path, include, reverse
from django.utils.translation import gettext_lazy as _
from rest_framework.routers import DefaultRouter
from .models import Comment, Diffusion, Program
from .views.admin import StatisticsView
__all__ = ['AdminSite']
class AdminSite(admin.AdminSite):
extra_urls = None
tools = [
(_('Statistics'), 'admin:tools-stats'),
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.router = DefaultRouter()
self.extra_urls = []
self.tools = type(self).tools.copy()
def each_context(self, request):
context = super().each_context(request)
context.update({
# all programs
'programs': Program.objects.active().values('pk', 'title') \
.order_by('title'),
# today's diffusions
'diffusions': Diffusion.objects.on_air().date().order_by('start') \
.select_related('episode'),
# TODO: only for dashboard
# last comments
'comments': Comment.objects.order_by('-date')
.select_related('page')[0:10],
})
return context
def get_urls(self):
urls = super().get_urls() + [
path('api/', include((self.router.urls, 'api'))),
path('tools/statistics/',
self.admin_view(StatisticsView.as_view()),
name='tools-stats'),
path('tools/statistics/<date:date>/',
self.admin_view(StatisticsView.as_view()),
name='tools-stats'),
] + self.extra_urls
return urls
def get_tools(self):
return [(label, reverse(url)) for label, url in self.tools]
def route_view(self, url, view, name, admin_view=True, label=None):
self.extra_urls.append(path(
url, self.admin_view(view) if admin_view else view, name=name
))
if label:
self.tools.append((label, 'admin:' + name))

View File

@ -1,6 +1,13 @@
from django.apps import AppConfig
from django.contrib.admin.apps import AdminConfig
class AircoxConfig(AppConfig):
name = "aircox"
verbose_name = "Aircox"
name = 'aircox'
verbose_name = 'Aircox'
class AircoxAdminConfig(AdminConfig):
default_site = 'aircox.admin_site.AdminSite'

View File

@ -1,189 +0,0 @@
import os
import inspect
from bleach import sanitizer
from django.conf import settings as d_settings
__all__ = ("Settings", "settings")
# code from django-fox
class BaseSettings:
"""Utility class used to load and save settings, can be used as model.
Some members are excluded from being configuration:
- Protected/private members;
- On django model, "objects" and "Meta";
- Class declaration and callables
Example:
```
class MySettings(Settings):
a = 13
b = 12
my_settings = MySettings().load('MY_SETTINGS_KEY')
print(my_settings.a, my_settings.get('b'))
```
This will load values from django project settings.
"""
def __init__(self, key, module=None):
self.load(key, module)
def load(self, key, module=None):
"""Load settings from module's item specified by its member name. When
no module is provided, uses ``django.conf.settings``.
:param str key: module member name.
:param module: configuration object.
:returns self
"""
if module is None:
module = d_settings
settings = getattr(module, key, None)
if settings:
self.update(settings)
return self
def update(self, settings):
"""Update self's values from provided settings. ``settings`` can be an
iterable of ``(key, value)``.
:param dict|Settings|iterable settings: value to update from.
"""
if isinstance(settings, (dict, Settings)):
settings = settings.items()
for key, value in settings:
if self.is_config_item(key, value):
setattr(self, key, value)
def get(self, key, default=None):
"""Return settings' value for provided key."""
return getattr(self, key, default)
def items(self):
"""Iterate over items members, as tupple of ``key, value``."""
for key in dir(self):
value = getattr(self, key)
if self.is_config_item(key, value):
yield key, value
def is_config_item(self, key, value):
"""Return True if key/value item is a configuration setting."""
if key.startswith("_") or callable(value) or inspect.isclass(value):
return False
return True
class Settings(BaseSettings):
# --- Global & misc
DEFAULT_USER_GROUPS = {
"radio hosts": (
# TODO include content_type in order to avoid clash with potential
# extra applications
# aircox
"view_program",
"view_episode",
"change_diffusion",
"add_comment",
"change_comment",
"delete_comment",
"add_article",
"change_article",
"delete_article",
"change_sound",
"add_track",
"change_track",
"delete_track",
# taggit
"add_tag",
"change_tag",
"delete_tag",
# filer
"add_folder",
"change_folder",
"delete_folder",
"can_use_directory_listing",
"add_image",
"change_image",
"delete_image",
),
}
"""Groups to assign to users at their creation, along with the permissions
to add to each group."""
PROGRAMS_DIR = "programs"
"""Directory for the programs data."""
@property
def PROGRAMS_DIR_ABS(self):
return os.path.join(d_settings.MEDIA_ROOT, self.PROGRAMS_DIR)
# --- Programs & episodes
EPISODE_TITLE = "{program.title} - {date}"
"""Default title for episodes."""
EPISODE_TITLE_DATE_FORMAT = "%-d %B %Y"
"""Date format in episode title (python's strftime)"""
# --- Logs & archives
LOGS_ARCHIVES_DIR = "logs/archives"
"""Directory where to save logs' archives."""
@property
def LOGS_ARCHIVES_DIR_ABS(self):
return os.path.join(d_settings.PROJECT_ROOT, self.LOGS_ARCHIVES_DIR)
LOGS_ARCHIVES_AGE = 60
"""In days, minimal age of a log before it is archived."""
# --- Sounds
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."""
SOUND_QUALITY = {
"attribute": "RMS lev dB",
"range": (-18.0, -8.0),
"sample_length": 120,
}
"""Quality attributes passed to sound_quality_check from sounds_monitor
(Soxi parameters)."""
SOUND_FILE_EXT = (".ogg", ".flac", ".wav", ".mp3", ".opus")
"""Extension of sound files."""
SOUND_KEEP_DELETED = False
"""Tag sounds as deleted instead of deleting them when file has been
removed from filesystem (sound monitoring)."""
# --- Streamer & Controllers
CONTROLLERS_WORKING_DIR = "/tmp/aircox"
"""Controllers working directory."""
# --- Playlist import from CSV
IMPORT_PLAYLIST_CSV_COLS = (
"artist",
"title",
"minutes",
"seconds",
"tags",
"info",
)
"""Columns for CSV file."""
IMPORT_PLAYLIST_CSV_DELIMITER = ";"
"""Column delimiter of csv text files."""
IMPORT_PLAYLIST_CSV_TEXT_QUOTE = '"'
"""Text delimiter of csv text files."""
ALLOW_COMMENTS = True
"""Allow comments."""
# ---- bleach
ALLOWED_TAGS = [*sanitizer.ALLOWED_TAGS, "br", "p", "h3", "h4", "h5"]
ALLOWED_ATTRIBUTES = sanitizer.ALLOWED_ATTRIBUTES
ALLOWED_PROTOCOLS = sanitizer.ALLOWED_PROTOCOLS
settings = Settings("AIRCOX")

View File

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

View File

@ -1,8 +0,0 @@
# aircox.controllers
This module provides the following controllers classes:
- `log_archiver.LogArchiver`: dumps and load gzip archives from Log models.
- `sound_file.SoundFile`: handle synchronisation between filesystem and database for a sound file.
- `sound_monitor.SoundMonitor`: monitor filesystem for changes on audio files and synchronise database.
- `sound_stats.SoundStats` (+ `SoxStats`): get audio statistics of an audio file using Sox.
- `diffuions.Diffusions`: generate, update and clean diffusions.
- `playlist_import.PlaylistImport`: import playlists from CSV.

View File

@ -1,58 +0,0 @@
from datetime import datetime, time
import logging
from django.db import transaction
from django.utils import timezone as tz
from aircox.models import Diffusion, Schedule
logger = logging.getLogger("aircox.commands")
__all__ = ("DiffusionMonitor",)
class DiffusionMonitor:
"""Handle generation and update of Diffusion instances."""
date = None
def __init__(self, date):
self.date = date or date.today()
def update(self):
episodes, diffusions = [], []
for schedule in Schedule.objects.filter(program__active=True, initial__isnull=True):
eps, diffs = schedule.diffusions_of_month(self.date)
if eps:
episodes += eps
if diffs:
diffusions += diffs
logger.info(
"[update] %s: %d episodes, %d diffusions and reruns",
str(schedule),
len(eps),
len(diffs),
)
with transaction.atomic():
logger.info(
"[update] save %d episodes and %d diffusions",
len(episodes),
len(diffusions),
)
for episode in episodes:
episode.save()
for diffusion in diffusions:
# force episode id's update
diffusion.episode = diffusion.episode
diffusion.save()
def clean(self):
qs = Diffusion.objects.filter(
type=Diffusion.TYPE_UNCONFIRMED,
start__lt=tz.make_aware(datetime.combine(self.date, time.min)),
)
logger.info("[clean] %d diffusions will be removed", qs.count())
qs.delete()

View File

@ -1,107 +0,0 @@
import gzip
import os
import yaml
from django.utils.functional import cached_property
from aircox.conf import settings
from aircox.models import Diffusion, Sound, Track, Log
__all__ = ("LogArchiver",)
class LogArchiver:
"""Commodity class used to manage archives of logs."""
@cached_property
def fields(self):
return Log._meta.get_fields()
@staticmethod
def get_path(station, date):
return os.path.join(
settings.LOGS_ARCHIVES_DIR_ABS,
"{}_{}.log.gz".format(date.strftime("%Y%m%d"), station.pk),
)
def archive(self, qs, keep=False):
"""Archive logs of the given queryset.
Delete archived logs if not `keep`. Return the count of archived
logs
"""
if not qs.exists():
return 0
os.makedirs(settings.LOGS_ARCHIVES_DIR_ABS, exist_ok=True)
count = qs.count()
logs = self.sort_logs(qs)
# Note: since we use Yaml, we can just append new logs when file
# exists yet <3
for (station, date), logs in logs.items():
path = self.get_path(station, date)
# FIXME: remove binary mode
with gzip.open(path, "ab") as archive:
data = yaml.dump([self.serialize(line) for line in logs]).encode("utf8")
archive.write(data)
if not keep:
qs.delete()
return count
@staticmethod
def sort_logs(qs):
"""Sort logs by station and date and return a dict of `{
(station,date): [logs] }`."""
qs = qs.order_by("date")
logs = {}
for log in qs:
key = (log.station, log.date.date())
logs.setdefault(key, []).append(log)
return logs
def serialize(self, log):
"""Serialize log."""
return {i.attname: getattr(log, i.attname) for i in self.fields}
def load(self, station, date):
"""Load an archive returning logs in a list."""
path = self.get_path(station, date)
if not os.path.exists(path):
return []
return self.load_file(path)
def load_file(self, path):
with gzip.open(path, "rb") as archive:
data = archive.read()
logs = yaml.safe_load(data)
# we need to preload diffusions, sounds and tracks
rels = {
"diffusion": self.get_relations(logs, Diffusion, "diffusion"),
"sound": self.get_relations(logs, Sound, "sound"),
"track": self.get_relations(logs, Track, "track"),
}
def rel_obj(log, attr):
rel_id = log.get(attr + "_id")
return rels[attr][rel_id] if rel_id else None
return [
Log(
diffusion=rel_obj(log, "diffusion"), sound=rel_obj(log, "sound"), track=rel_obj(log, "track"), **log
)
for log in logs
]
@staticmethod
def get_relations(logs, model, attr):
"""From a list of dict representing logs, retrieve related objects of
the given type."""
attr_id = attr + "_id"
pks = {log[attr_id] for log in logs if attr_id in log}
return {rel.pk: rel for rel in model.objects.filter(pk__in=pks)}

View File

@ -1,100 +0,0 @@
import csv
import logging
import os
from aircox.conf import settings
from aircox.models import Track
__all__ = ("PlaylistImport",)
logger = logging.getLogger("aircox.commands")
class PlaylistImport:
"""Import one or more playlist for the given sound. Attach it to the
provided sound.
Playlists are in CSV format, where columns are separated with a
'{settings.IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
{settings.IMPORT_PLAYLIST_CSV_TEXT_QUOTE}.
If 'minutes' or 'seconds' are given, position will be expressed as timed
position, instead of position in playlist.
"""
path = None
data = None
tracks = None
track_kwargs = {}
def __init__(self, path=None, **track_kwargs):
self.path = path
self.track_kwargs = track_kwargs
def reset(self):
self.data = None
self.tracks = None
def run(self):
self.read()
if self.track_kwargs.get("sound") is not None:
self.make_playlist()
def read(self):
if not os.path.exists(self.path):
return True
with open(self.path, "r") as file:
logger.info("start reading csv " + self.path)
self.data = list(
csv.DictReader(
(row for row in file if not (row.startswith("#") or row.startswith("\ufeff#")) and row.strip()),
fieldnames=settings.IMPORT_PLAYLIST_CSV_COLS,
delimiter=settings.IMPORT_PLAYLIST_CSV_DELIMITER,
quotechar=settings.IMPORT_PLAYLIST_CSV_TEXT_QUOTE,
)
)
def make_playlist(self):
"""Make a playlist from the read data, and return it.
If save is true, save it into the database
"""
if self.track_kwargs.get("sound") is None:
logger.error("related track's sound is missing. Skip import of " + self.path + ".")
return
maps = settings.IMPORT_PLAYLIST_CSV_COLS
tracks = []
logger.info("parse csv file " + self.path)
has_timestamp = ("minutes" or "seconds") in maps
for index, line in enumerate(self.data):
if ("title" or "artist") not in line:
return
try:
timestamp = (
int(line.get("minutes") or 0) * 60 + int(line.get("seconds") or 0) if has_timestamp else None
)
track, created = Track.objects.get_or_create(
title=line.get("title"), artist=line.get("artist"), position=index, **self.track_kwargs
)
track.timestamp = timestamp
track.info = line.get("info")
tags = line.get("tags")
if tags:
track.tags.add(*tags.lower().split(","))
except Exception as err:
logger.warning(
"an error occured for track {index}, it may not "
"have been saved: {err}".format(index=index, err=err)
)
continue
track.save()
tracks.append(track)
self.tracks = tracks
return tracks

View File

@ -1,95 +0,0 @@
#! /usr/bin/env python3
"""Provide SoundFile which is used to link between database and file system.
File name
=========
It tries to parse the file name to get the date of the diffusion of an
episode and associate the file with it; We use the following format:
yyyymmdd[_n][_][name]
Where:
'yyyy' the year of the episode's diffusion;
'mm' the month of the episode's diffusion;
'dd' the day of the episode's diffusion;
'n' the number of the episode (if multiple episodes);
'name' the title of the sound;
Sound Quality
=============
To check quality of files, call the command sound_quality_check using the
parameters given by the setting SOUND_QUALITY. This script requires
Sox (and soxi).
"""
import logging
from django.conf import settings as conf
from aircox.models import Program, Sound, EpisodeSound
logger = logging.getLogger("aircox.commands")
__all__ = ("SoundFile",)
class SoundFile:
"""Handle synchronisation between sounds on files and database."""
path = None
info = None
path_info = None
sound = None
def __init__(self, path):
self.path = path
@property
def sound_path(self):
"""Relative path name."""
return self.path.replace(conf.MEDIA_ROOT + "/", "")
@property
def episode(self):
return self.sound and self.sound.episode
def sync(self, sound=None, program=None, deleted=False, keep_deleted=False, **kwargs):
"""Update related sound model and save it."""
if deleted:
self.sound = self._on_delete(self.path, keep_deleted)
return self.sound
program = sound and sound.program or Program.get_from_path(self.path)
if program:
kwargs["program_id"] = program.pk
created = False
if not sound:
sound, created = Sound.objects.get_or_create(file=self.sound_path, defaults=kwargs)
self.sound = sound
sound.sync_fs(on_update=True, find_playlist=True)
sound.save()
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):
sound = None
if keep_deleted:
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

@ -1,309 +0,0 @@
#! /usr/bin/env python3
"""Monitor sound files; For each program, check for:
- new files;
- deleted files;
- differences between files and sound;
- quality of the files;
It tries to parse the file name to get the date of the diffusion of an
episode and associate the file with it; WNotifye the following format:
yyyymmdd[_n][_][name]
Where:
'yyyy' the year Notifyhe episode's diffusion;
'mm' the month of the episode's difNotifyon;
'dd' the day of the episode's diffusion;
'n' the number of the episode (if multiple episodes);
'name' the title of the sNotify;
To check quality of files, call the command sound_quality_check using the
parameters given by the setting SOUND_QUALITY. This script requires
Sox (and soxi).
"""
import atexit
from concurrent import futures
import logging
import time
import os
# from datetime import datetime, timedelta
from django.utils.timezone import datetime, timedelta
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler
from aircox.conf import settings
from aircox.models import Sound, Program
from .sound_file import SoundFile
# FIXME: logger should be different in used classes (e.g. "aircox.commands")
# defaulting to logging.
logger = logging.getLogger("aircox.commands")
__all__ = (
"Task",
"CreateTask",
"DeleteTask",
"MoveTask",
"ModifiedTask",
"MonitorHandler",
)
class Task:
"""Base class used to execute a specific task on file change event.
Handlers are sent to a multithread pool.
"""
future = None
"""Future that promised the handler's call."""
log_msg = None
"""Log message to display on event happens."""
timestamp = None
"""Last ping timestamp (the event happened)."""
def __init__(self, logger=logging):
self.ping()
def ping(self):
""""""
self.timestamp = datetime.now()
def __call__(self, event, path=None, logger=logging, **kw):
sound_file = SoundFile(path or event.src_path)
if self.log_msg:
msg = self.log_msg.format(event=event, sound_file=sound_file)
logger.info(msg)
sound_file.sync(**kw)
return sound_file
class CreateTask(Task):
log_msg = "Sound file created: {sound_file.path}"
class DeleteTask(Task):
log_msg = "Sound file deleted: {sound_file.path}"
def __call__(self, *args, **kwargs):
kwargs["deleted"] = True
return super().__call__(*args, **kwargs)
class MoveTask(Task):
log_msg = "Sound file moved: {event.src_path} -> {event.dest_path}"
def __call__(self, event, **kw):
sound = Sound.objects.filter(file=event.src_path).first()
if sound:
kw = {**kw, "sound": sound, "path": event.src_path}
else:
kw["path"] = event.dest_path
return super().__call__(event, **kw)
class ModifiedTask(Task):
timeout_delta = timedelta(seconds=30)
log_msg = "Sound file updated: {sound_file.path}"
def wait(self):
# multiple call of this handler can be done consecutively, we block
# its thread using timeout
# Note: this method may be subject to some race conflicts, but this
# should not be big a real issue.
timeout = self.timestamp + self.timeout_delta
while datetime.now() < timeout:
time.sleep(self.timeout_delta.total_seconds())
timeout = self.timestamp + self.timeout_delta
def __call__(self, event, **kw):
self.wait()
return super().__call__(event, **kw)
class MonitorHandler(PatternMatchingEventHandler):
"""MonitorHandler is used as a Watchdog event handler.
It uses a multithread pool in order to execute tasks on events. If a
job already exists for this file and event, it pings existing job
without creating a new one.
"""
pool = None
jobs = None
def __init__(self, subdir, pool, jobs=None, **sync_kw):
"""
:param str subdir: sub-directory in program dirs to monitor \
(SOUND_ARCHIVES_SUBDIR or SOUND_EXCERPTS_SUBDIR);
:param concurrent.futures.Executor pool: pool executing jobs on file
change;
:param **sync_kw: kwargs passed to `SoundFile.sync`;
"""
self.subdir = subdir
self.pool = pool
self.jobs = jobs or {}
self.sync_kw = sync_kw
patterns = ["*/{}/*{}".format(self.subdir, ext) for ext in settings.SOUND_FILE_EXT]
super().__init__(patterns=patterns, ignore_directories=True)
def on_created(self, event):
self._submit(CreateTask(), event, "new", **self.sync_kw)
def on_deleted(self, event):
self._submit(DeleteTask(), event, "del")
def on_moved(self, event):
self._submit(MoveTask(), event, "mv", **self.sync_kw)
def on_modified(self, event):
self._submit(ModifiedTask(), event, "up", **self.sync_kw)
def _submit(self, handler, event, job_key_prefix, **kwargs):
"""Send handler job to pool if not already running.
Return tuple with running job and boolean indicating if its a
new one.
"""
key = job_key_prefix + ":" + event.src_path
job = self.jobs.get(key)
if job and not job.future.done():
job.ping()
return job, False
handler.future = self.pool.submit(handler, event, **kwargs)
self.jobs[key] = handler
def done(r):
if self.jobs.get(key) is handler:
del self.jobs[key]
handler.future.add_done_callback(done)
return handler, True
class SoundMonitor:
"""Monitor for filesystem changes in order to synchronise database and
analyse files of a provided program."""
def report(self, program=None, component=None, *content, logger=logging):
content = " ".join([str(c) for c in content])
logger.info(f"{program}: {content}" if not component else f"{program}, {component}: {content}")
def scan(self, logger=logging):
"""For all programs, scan dirs.
Return scanned directories.
"""
logger.info("scan all programs...")
programs = Program.objects.filter()
dirs = []
for program in programs:
logger.info(f"#{program.id} {program.title}")
self.scan_for_program(
program,
settings.SOUND_BROADCASTS_SUBDIR,
logger=logger,
broadcast=True,
)
self.scan_for_program(
program,
settings.SOUND_EXCERPTS_SUBDIR,
logger=logger,
broadcast=False,
)
dirs.append(program.abspath)
return dirs
def scan_for_program(self, program, subdir, logger=logging, **sound_kwargs):
"""Scan a given directory that is associated to the given program, and
update sounds information."""
logger.info("- %s/", subdir)
if not program.ensure_dir(subdir):
return
abs_subdir = os.path.join(program.abspath, subdir)
sounds = []
# sounds in directory
for path in os.listdir(abs_subdir):
path = os.path.join(abs_subdir, path)
if not path.endswith(settings.SOUND_FILE_EXT):
continue
sound_file = SoundFile(path)
sound_file.sync(program=program, **sound_kwargs)
sounds.append(sound_file.sound.pk)
# sounds in db & unchecked
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.sync_fs(on_update=True):
SoundFile(sound.file.path).sync(sound=sound, **sync_kwargs)
_running = False
def monitor(self, logger=logging):
if self._running:
raise RuntimeError("already running")
"""Run in monitor mode."""
with futures.ThreadPoolExecutor() as pool:
archives_handler = MonitorHandler(
settings.SOUND_BROADCASTS_SUBDIR,
pool,
broadcast=True,
logger=logger,
)
excerpts_handler = MonitorHandler(
settings.SOUND_EXCERPTS_SUBDIR,
pool,
broadcast=False,
logger=logger,
)
observer = Observer()
observer.schedule(
archives_handler,
settings.PROGRAMS_DIR_ABS,
recursive=True,
)
observer.schedule(
excerpts_handler,
settings.PROGRAMS_DIR_ABS,
recursive=True,
)
observer.start()
def leave():
observer.stop()
observer.join()
atexit.register(leave)
self._running = True
while self._running:
time.sleep(1)
leave()
atexit.unregister(leave)
def stop(self):
"""Stop monitor() loop."""
self._running = False

View File

@ -1,115 +0,0 @@
"""Provide sound analysis class using Sox."""
import logging
import re
import subprocess
logger = logging.getLogger("aircox.commands")
__all__ = ("SoxStats", "SoundStats")
class SoxStats:
"""Run Sox process and parse output."""
attributes = [
"DC offset",
"Min level",
"Max level",
"Pk lev dB",
"RMS lev dB",
"RMS Pk dB",
"RMS Tr dB",
"Flat factor",
"Length s",
]
values = None
def __init__(self, path=None, **kwargs):
"""If path is given, call analyse with path and kwargs."""
if path:
self.analyse(path, **kwargs)
def analyse(self, path, at=None, length=None):
"""If at and length are given use them as excerpt to analyse."""
args = ["sox", path, "-n"]
if at is not None and length is not None:
args += ["trim", str(at), str(length)]
args.append("stats")
p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
# sox outputs to stderr (my god WHYYYY)
out_, out = p.communicate()
self.values = self.parse(str(out, encoding="utf-8"))
def parse(self, output):
"""Parse sox output, settubg values from it."""
values = {}
for attr in self.attributes:
value = re.search(attr + r"\s+(?P<value>\S+)", output)
value = value and value.groupdict()
if value:
try:
value = float(value.get("value"))
except ValueError:
value = None
values[attr] = value
values["length"] = values.pop("Length s", None)
return values
def get(self, attr):
return self.values.get(attr)
class SoundStats:
path = None # file path
sample_length = 120 # default sample length in seconds
stats = None # list of samples statistics
bad = None # list of bad samples
good = None # list of good samples
def __init__(self, path, sample_length=None):
self.path = path
if sample_length is not None:
self.sample_length = sample_length
def get_file_stats(self):
return self.stats and self.stats[0] or None
def analyse(self):
logger.debug("complete file analysis")
self.stats = [SoxStats(self.path)]
position = 0
length = self.stats[0].get("length")
if not self.sample_length:
return
logger.debug("start samples analysis...")
while position < length:
stats = SoxStats(self.path, at=position, length=self.sample_length)
self.stats.append(stats)
position += self.sample_length
def check(self, name, min_val, max_val):
self.good = [index for index, stats in enumerate(self.stats) if min_val <= stats.get(name) <= max_val]
self.bad = [index for index, stats in enumerate(self.stats) if index not in self.good]
self.resume()
def resume(self):
if self.good:
logger.debug(
self.path + " -> good: \033[92m%s\033[0m",
", ".join(self._view(self.good)),
)
if self.bad:
logger.debug(
self.path + " -> bad: \033[91m%s\033[0m",
", ".join(self._view(self.bad)),
)
def _view(self, array):
return [
"file" if index == 0 else "sample {} (at {} seconds)".format(index, (index - 1) * self.sample_length)
for index in array
]

View File

@ -1,51 +1,50 @@
import datetime
from django.urls.converters import StringConverter
from django.utils.safestring import mark_safe
from django.urls.converters import StringConverter
__all__ = ("PagePathConverter", "WeekConverter", "DateConverter")
from .utils import str_to_date
class PagePathConverter(StringConverter):
"""Match path for pages, including surrounding slashes."""
regex = r"/?|([-_a-zA-Z0-9]+/)*?"
""" Match path for pages, including surrounding slashes. """
regex = r'/?|([-_a-zA-Z0-9]+/)*?'
def to_python(self, value):
if not value or value[0] != "/":
value = "/" + value
if len(value) > 1 and value[-1] != "/":
value = value + "/"
if not value or value[0] != '/':
value = '/' + value
if len(value) > 1 and value[-1] != '/':
value = value + '/'
return value
def to_url(self, value):
if value[0] == "/":
if value[0] == '/':
value = value[1:]
if value[-1] != "/":
value = value + "/"
if value[-1] != '/':
value = value + '/'
return mark_safe(value)
class WeekConverter:
"""Converter for date as YYYYY/WW."""
regex = r"[0-9]{4}/[0-9]{2}"
""" Converter for date as YYYYY/WW """
regex = r'[0-9]{4}/[0-9]{2}'
def to_python(self, value):
return datetime.datetime.strptime(value + "/1", "%G/%V/%u").date()
return datetime.datetime.strptime(value + '/1', '%G/%V/%u').date()
def to_url(self, value):
return value if isinstance(value, str) else "{:04d}/{:02d}".format(*value.isocalendar())
return value if isinstance(value, str) else \
'{:04d}/{:02d}'.format(*value.isocalendar())
class DateConverter:
"""Converter for date as YYYY/MM/DD."""
regex = r"[0-9]{4}/[0-9]{2}/[0-9]{2}"
""" Converter for date as YYYY/MM/DD """
regex = r'[0-9]{4}/[0-9]{2}/[0-9]{2}'
def to_python(self, value):
value = value.split("/")[:3]
value = value.split('/')[:3]
return datetime.date(int(value[0]), int(value[1]), int(value[2]))
def to_url(self, value):
return value if isinstance(value, str) else "{:04d}/{:02d}/{:02d}".format(value.year, value.month, value.day)
return value if isinstance(value, str) else \
'{:04d}/{:02d}/{:02d}'.format(value.year, value.month, value.day)

View File

@ -1,116 +0,0 @@
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 . import models
__all__ = (
"PageFilters",
"EpisodeFilters",
"ImageFilterSet",
"SoundFilterSet",
"TrackFilterSet",
"UserFilterSet",
"GroupFilterSet",
"UserGroupFilterSet",
)
class PageFilters(filters.FilterSet):
q = filters.CharFilter(method="search_filter", label=_("Search"))
class Meta:
model = models.Page
fields = {
"category__id": ["in", "exact"],
"pub_date": ["exact", "gte", "lte"],
}
def search_filter(self, queryset, name, value):
return queryset.search(value)
class EpisodeFilters(PageFilters):
podcast = filters.BooleanFilter(method="podcast_filter", label=_("Podcast"))
class Meta:
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 GroupFilterSet(filters.FilterSet):
search = filters.CharFilter(field_name="search", method="search_filter")
no_user = filters.NumberFilter(field_name="no_user", method="no_user_filter")
def no_user_filter(self, queryset, name, value):
return queryset.exclude(user__in=[value])
def search_filter(self, queryset, name, value):
return queryset.filter(Q(name__icontains=value) | Q(program__title__icontains=value))
class UserGroupFilterSet(filters.FilterSet):
class Meta:
model = User.groups.through
fields = ["group", "user"]
def filter_queryset(self, queryset):
queryset = super().filter_queryset(queryset)
if self.form.cleaned_data.get("user"):
queryset = queryset.order_by("group__name")
elif self.form.cleaned_data.get("group"):
queryset = queryset.order_by("user__first_name")
return queryset

20
aircox/forms.py Normal file
View File

@ -0,0 +1,20 @@
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']

View File

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

View File

@ -1,34 +0,0 @@
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."""

View File

@ -1,37 +0,0 @@
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

View File

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

View File

@ -1,26 +0,0 @@
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()}

View File

@ -1,23 +0,0 @@
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."""

View File

@ -1,89 +0,0 @@
from itertools import chain
from functools import cached_property
from django import forms, http
from django.urls import reverse
__all__ = (
"VueWidget",
"VueAutoComplete",
)
class VueWidget(forms.Widget):
binds = None
"""Dict of `{attribute: value}` attrs set as bindings."""
events = None
"""Dict of `{event: value}` attrs set as events."""
v_model = ""
"""ES6 Model instance to bind to (`v-model`)."""
def __init__(self, *args, binds=None, events=None, v_model=None, **kwargs):
super().__init__(*args, **kwargs)
self.binds = binds or []
self.events = events or []
@cached_property
def vue_attrs(self):
"""Dict of Vue specific attributes."""
binds, events = self.binds, self.events
if isinstance(binds, dict):
binds = binds.items()
if isinstance(events, dict):
events = events.items()
return dict(
chain(
((":" + key, value) for key, value in binds),
(("@" + key, value) for key, value in events),
)
)
def build_attrs(self, base_attrs, extra_attrs=None):
extra_attrs = extra_attrs or {}
extra_attrs.update(self.vue_attrs)
return super().build_attrs(base_attrs, extra_attrs)
class VueAutoComplete(VueWidget, forms.TextInput):
"""Autocomplete Vue component."""
template_name = "aircox/widgets/autocomplete.html"
url: str = ""
"""Url to autocomplete API view.
If it has query parameters, does not generate it based on lookup
(see `get_url()` doc).
"""
lookup: str = ""
"""Field name used as lookup (instead as provided one)."""
params: http.QueryDict
def __init__(self, url_name, *args, lookup=None, params=None, **kwargs):
self.url_name = url_name
self.lookup = lookup
self.params = params
super().__init__(*args, **kwargs)
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["url"] = self.get_url(name, self.lookup, self.params)
return context
def get_url(self, name, lookup, params=None):
"""Return url to autocomplete API. When query parameters are not
provided generate them using `?{lookup}=${query}&field={name}` (where
`${query} is Vue `a-autocomplete` specific).
:param str name: field name (not used by default)
:param str lookup: lookup query parameter
:param http.QueryDict params: additional mutable parameter
"""
url = reverse(self.url_name)
query = http.QueryDict(mutable=True)
if params:
query.update(params)
query.update({lookup: "${query}"})
return f"{url}?{query.urlencode()}"

File diff suppressed because it is too large Load Diff

View File

@ -1,47 +1,41 @@
"""Handle archiving of logs in order to keep database light and fast.
The logs are archived in gzip files, per day.
"""
Handle archiving of logs in order to keep database light and fast. The
logs are archived in gzip files, per day.
"""
from argparse import RawTextHelpFormatter
import datetime
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from django.utils import timezone as tz
from aircox.conf import settings
from aircox.models import Log
import aircox.settings as settings
from aircox.models import Log, Station
from aircox.models.log import LogArchiver
logger = logging.getLogger("aircox.commands")
logger = logging.getLogger('aircox.commands')
__all__ = ("Command",)
class Command(BaseCommand):
class Command (BaseCommand):
help = __doc__
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
group = parser.add_argument_group("actions")
group = parser.add_argument_group('actions')
group.add_argument(
"-a",
"--age",
type=int,
default=settings.LOGS_ARCHIVES_AGE,
help="minimal age in days of logs to archive. Default is " "settings.LOGS_ARCHIVES_AGE",
'-a', '--age', type=int,
default=settings.AIRCOX_LOGS_ARCHIVES_AGE,
help='minimal age in days of logs to archive. Default is '
'settings.AIRCOX_LOGS_ARCHIVES_AGE'
)
group.add_argument(
"-k",
"--keep",
action="store_true",
help="keep logs in database instead of deleting them",
'-k', '--keep', action='store_true',
help='keep logs in database instead of deleting them'
)
def handle(self, *args, age, keep, **options):
date = datetime.date.today() - tz.timedelta(days=age)
# FIXME: mysql support?
logger.info("archive logs for %s and earlier", date)
logger.info('archive logs for %s and earlier', date)
count = LogArchiver().archive(Log.objects.filter(date__date__lte=date))
logger.info("total log archived %d", count)
logger.info('total log archived %d', count)

View File

@ -1,4 +1,5 @@
"""Manage diffusions using schedules, to update, clean up or check diffusions.
"""
Manage diffusions using schedules, to update, clean up or check diffusions.
A generated diffusion can be unconfirmed, that means that the user must confirm
it by changing its type to "normal". The behaviour is controlled using
@ -9,11 +10,47 @@ import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone as tz
from aircox.controllers.diffusion_monitor import DiffusionMonitor
from aircox.models import Schedule, Diffusion
logger = logging.getLogger("aircox.commands")
logger = logging.getLogger('aircox.commands')
class Actions:
date = None
def __init__(self, date):
self.date = date or datetime.date.today()
def update(self):
episodes, diffusions = [], []
for schedule in Schedule.objects.filter(program__active=True,
initial__isnull=True):
eps, diffs = schedule.diffusions_of_month(self.date)
episodes += eps
diffusions += diffs
logger.info('[update] %s: %d episodes, %d diffusions and reruns',
str(schedule), len(eps), len(diffs))
with transaction.atomic():
logger.info('[update] save %d episodes and %d diffusions',
len(episodes), len(diffusions))
for episode in episodes:
episode.save()
for diffusion in diffusions:
# force episode id's update
diffusion.episode = diffusion.episode
diffusion.save()
def clean(self):
qs = Diffusion.objects.filter(type=Diffusion.TYPE_UNCONFIRMED,
start__lt=self.date)
logger.info('[clean] %d diffusions will be removed', qs.count())
qs.delete()
class Command(BaseCommand):
@ -23,54 +60,45 @@ class Command(BaseCommand):
parser.formatter_class = RawTextHelpFormatter
today = datetime.date.today()
group = parser.add_argument_group("action")
group = parser.add_argument_group('action')
group.add_argument(
"-u",
"--update",
action="store_true",
help="generate (unconfirmed) diffusions for the given month. "
"These diffusions must be confirmed manually by changing "
'their type to "normal"',
'-u', '--update', action='store_true',
help='generate (unconfirmed) diffusions for the given month. '
'These diffusions must be confirmed manually by changing '
'their type to "normal"'
)
group.add_argument(
"-l",
"--clean",
action="store_true",
help="remove unconfirmed diffusions older than the given month",
'-l', '--clean', action='store_true',
help='remove unconfirmed diffusions older than the given month'
)
group = parser.add_argument_group("date")
group = parser.add_argument_group('date')
group.add_argument(
"--year",
type=int,
default=today.year,
help="used by update, default is today's year",
)
'--year', type=int, default=today.year,
help='used by update, default is today\'s year')
group.add_argument(
"--month",
type=int,
default=today.month,
help="used by update, default is today's month",
)
'--month', type=int, default=today.month,
help='used by update, default is today\'s month')
group.add_argument(
"--next-month",
action="store_true",
help="set the date to the next month of given date" " (if next month from today",
'--next-month', action='store_true',
help='set the date to the next month of given date'
' (if next month from today'
)
def handle(self, *args, **options):
date = datetime.date(year=options["year"], month=options["month"], day=1)
if options.get("next_month"):
month = options.get("month")
date = datetime.date(year=options['year'], month=options['month'],
day=1)
if options.get('next_month'):
month = options.get('month')
date += tz.timedelta(days=28)
if date.month == month:
date += tz.timedelta(days=28)
date = date.replace(day=1)
actions = DiffusionMonitor(date)
if options.get("update"):
actions = Actions(date)
if options.get('update'):
actions.update()
if options.get("clean"):
if options.get('clean'):
actions.clean()
if options.get("check"):
if options.get('check'):
actions.check()

View File

@ -1,32 +1,111 @@
"""Import one or more playlist for the given sound. Attach it to the provided
"""
Import one or more playlist for the given sound. Attach it to the provided
sound.
Playlists are in CSV format, where columns are separated with a
'{settings.IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
{settings.IMPORT_PLAYLIST_CSV_TEXT_QUOTE}.
The order of the elements is: {settings.IMPORT_PLAYLIST_CSV_COLS}
'{settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER}'. Text quote is
{settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE}.
The order of the elements is: {settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS}
If 'minutes' or 'seconds' are given, position will be expressed as timed
position, instead of position in playlist.
"""
import logging
import os
import csv
import logging
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand
from aircox.conf import settings
from aircox.models import Sound
from aircox.controllers.playlist_import import PlaylistImport
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from aircox import settings
from aircox.models import *
__doc__ = __doc__.format(settings=settings)
__all__ = ("Command",)
logger = logging.getLogger('aircox.commands')
logger = logging.getLogger("aircox.commands")
class PlaylistImport:
path = None
data = None
tracks = None
track_kwargs = {}
def __init__(self, path=None, **track_kwargs):
self.path = path
self.track_kwargs = track_kwargs
def reset(self):
self.data = None
self.tracks = None
def run(self):
self.read()
if self.track_kwargs.get('sound') is not None:
self.make_playlist()
def read(self):
if not os.path.exists(self.path):
return True
with open(self.path, 'r') as file:
logger.info('start reading csv ' + self.path)
self.data = list(csv.DictReader(
(row for row in file
if not (row.startswith('#') or row.startswith('\ufeff#'))
and row.strip()),
fieldnames=settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS,
delimiter=settings.AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER,
quotechar=settings.AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE,
))
def make_playlist(self):
"""
Make a playlist from the read data, and return it. If save is
true, save it into the database
"""
if self.track_kwargs.get('sound') is None:
logger.error('related track\'s sound is missing. Skip import of ' +
self.path + '.')
return
maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS
tracks = []
logger.info('parse csv file ' + self.path)
has_timestamp = ('minutes' or 'seconds') in maps
for index, line in enumerate(self.data):
if ('title' or 'artist') not in line:
return
try:
timestamp = int(line.get('minutes') or 0) * 60 + \
int(line.get('seconds') or 0) \
if has_timestamp else None
track, created = Track.objects.get_or_create(
title=line.get('title'),
artist=line.get('artist'),
position=index,
**self.track_kwargs
)
track.timestamp = timestamp
print('track', track, timestamp)
track.info = line.get('info')
tags = line.get('tags')
if tags:
track.tags.add(*tags.split(','))
except Exception as err:
logger.warning(
'an error occured for track {index}, it may not '
'have been saved: {err}'
.format(index=index, err=err)
)
continue
track.save()
tracks.append(track)
self.tracks = tracks
return tracks
class Command(BaseCommand):
@ -35,36 +114,33 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
parser.add_argument(
"path",
metavar="PATH",
type=str,
help="path of the input playlist to read",
'path', metavar='PATH', type=str,
help='path of the input playlist to read'
)
parser.add_argument(
"--sound",
"-s",
type=str,
help="generate a playlist for the sound of the given path. "
"If not given, try to match a sound with the same path.",
'--sound', '-s', type=str,
help='generate a playlist for the sound of the given path. '
'If not given, try to match a sound with the same path.'
)
def handle(self, path, *args, **options):
# FIXME: absolute/relative path of sounds vs given path
if options.get("sound"):
sound = Sound.objects.filter(file__icontains=options.get("sound")).first()
if options.get('sound'):
sound = Sound.objects.filter(path__icontains=options.get('sound'))\
.first()
else:
path_, ext = os.path.splitext(path)
sound = Sound.objects.filter(path__icontains=path_).first()
if not sound:
logger.error("no sound found in the database for the path " "{path}".format(path=path))
logger.error('no sound found in the database for the path '
'{path}'.format(path=path))
return
# FIXME: auto get sound.episode if any
importer = PlaylistImport(path, sound=sound).run()
for track in importer.tracks:
logger.info(
"track #{pos} imported: {title}, by {artist}".format(
pos=track.position, title=track.title, artist=track.artist
)
)
logger.info('track #{pos} imported: {title}, by {artist}'.format(
pos=track.position, title=track.title, artist=track.artist
))

View File

@ -1,7 +1,7 @@
#! /usr/bin/env python3
"""Monitor sound files; For each program, check for:
"""
Monitor sound files; For each program, check for:
- new files;
- deleted files;
- differences between files and sound;
@ -20,47 +20,333 @@ Where:
To check quality of files, call the command sound_quality_check using the
parameters given by the setting SOUND_QUALITY. This script requires
parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires
Sox (and soxi).
"""
import logging
from argparse import RawTextHelpFormatter
import concurrent.futures as futures
import datetime
import atexit
import logging
import os
import re
import time
from django.core.management.base import BaseCommand
import mutagen
from watchdog.observers import Observer
from watchdog.events import PatternMatchingEventHandler, FileModifiedEvent
from aircox.controllers.sound_monitor import SoundMonitor
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone as tz
from django.utils.translation import gettext as _
logger = logging.getLogger("aircox.commands")
from aircox import settings, utils
from aircox.models import Diffusion, Program, Sound, Track
from .import_playlist import PlaylistImport
logger = logging.getLogger('aircox.commands')
sound_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>.*)$'
)
class SoundFile:
path = None
info = None
path_info = None
sound = None
def __init__(self, path):
self.path = path
def sync(self, sound=None, program=None, deleted=False, **kwargs):
"""
Update related sound model and save it.
"""
if deleted:
sound = Sound.objects.filter(path=self.path).first()
if sound:
sound.type = sound.TYPE_REMOVED
sound.check_on_file()
sound.save()
return sound
# FIXME: sound.program as not null
program = kwargs['program'] = Program.get_from_path(self.path)
sound, created = Sound.objects.get_or_create(path=self.path, defaults=kwargs) \
if not sound else (sound, False)
self.sound = sound
sound.program = program
if created or sound.check_on_file():
logger.info('sound is new or have been modified -> %s', self.path)
self.read_path()
sound.name = self.path_info.get('name')
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 self.read_path():
self.find_episode(program)
sound.save()
if self.info is not None:
self.find_playlist(sound)
return sound
def read_path(self):
"""
Parse file name to get info on the assumption it has the correct
format (given in Command.help). Return True if path contains informations.
"""
if self.path_info:
return 'year' in self.path_info
name = os.path.splitext(os.path.basename(self.path))[0]
match = sound_path_re.search(name)
if match:
path_info = match.groupdict()
for k in ('year', 'month', 'day', 'hour', 'minute'):
if path_info.get(k) is not None:
path_info[k] = int(path_info[k])
self.path_info = path_info
return True
else:
self.path_info = {'name': name}
return False
def read_file_info(self):
""" Read file information and metadata. """
if os.path.exists(self.path):
self.info = mutagen.File(self.path)
else:
self.info = None
def find_episode(self, program):
"""
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.
"""
pi = self.path_info
if 'year' not in pi or not self.sound or self.sound.episode:
return None
if pi.get('hour') is not None:
date = tz.datetime(pi.get('year'), pi.get('month'), pi.get('day'),
pi.get('hour') or 0, pi.get('minute') or 0)
date = tz.get_current_timezone().localize(date)
else:
date = datetime.date(pi.get('year'), pi.get('month'), pi.get('day'))
diffusion = program.diffusion_set.at(date).first()
if not diffusion:
return None
logger.info('%s <--> %s', self.sound.path, str(diffusion.episode))
self.sound.episode = diffusion.episode
return diffusion
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():
return
# import playlist
path = os.path.splitext(self.sound.path)[0] + '.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.tags:
tags = self.info.tags
info = '{} ({})'.format(tags.get('album'), tags.get('year')) \
if ('album' and 'year' in tags) else tags.get('album') \
if 'album' in tags else tags.get('year', '')
track = Track(sound=sound,
position=int(tags.get('tracknumber', 0)),
title=tags.get('title', self.path_info['name']),
artist=tags.get('artist', _('unknown')),
info=info)
track.save()
class MonitorHandler(PatternMatchingEventHandler):
"""
Event handler for watchdog, in order to be used in monitoring.
"""
pool = None
def __init__(self, subdir, pool):
"""
subdir: AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR
"""
self.subdir = subdir
self.pool = pool
if self.subdir == settings.AIRCOX_SOUND_ARCHIVES_SUBDIR:
self.sound_kwargs = {'type': Sound.TYPE_ARCHIVE}
else:
self.sound_kwargs = {'type': Sound.TYPE_EXCERPT}
patterns = ['*/{}/*{}'.format(self.subdir, ext)
for ext in settings.AIRCOX_SOUND_FILE_EXT]
super().__init__(patterns=patterns, ignore_directories=True)
def on_created(self, event):
self.on_modified(event)
def on_modified(self, event):
logger.info('sound modified: %s', event.src_path)
def updated(event, sound_kwargs):
SoundFile(event.src_path).sync(**sound_kwargs)
self.pool.submit(updated, event, self.sound_kwargs)
def on_moved(self, event):
logger.info('sound moved: %s -> %s', event.src_path, event.dest_path)
def moved(event, sound_kwargs):
sound = Sound.objects.filter(path=event.src_path)
sound_file = SoundFile(event.dest_path) if not sound else sound
sound_file.sync(**sound_kwargs)
self.pool.submit(moved, event, self.sound_kwargs)
def on_deleted(self, event):
logger.info('sound deleted: %s', event.src_path)
def deleted(event):
SoundFile(event.src_path).sync(deleted=True)
self.pool.submit(deleted, event.src_path)
class Command(BaseCommand):
help = __doc__
def report(self, program=None, component=None, *content):
if not component:
logger.info('%s: %s', str(program),
' '.join([str(c) for c in content]))
else:
logger.info('%s, %s: %s', str(program), str(component),
' '.join([str(c) for c in content]))
def scan(self):
"""
For all programs, scan dirs
"""
logger.info('scan all programs...')
programs = Program.objects.filter()
dirs = []
for program in programs:
logger.info('#%d %s', program.id, program.title)
self.scan_for_program(
program, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR,
type=Sound.TYPE_ARCHIVE,
)
self.scan_for_program(
program, settings.AIRCOX_SOUND_EXCERPTS_SUBDIR,
type=Sound.TYPE_EXCERPT,
)
dirs.append(os.path.join(program.path))
def scan_for_program(self, program, subdir, **sound_kwargs):
"""
Scan a given directory that is associated to the given program, and
update sounds information.
"""
logger.info('- %s/', subdir)
if not program.ensure_dir(subdir):
return
subdir = os.path.join(program.path, subdir)
sounds = []
# sounds in directory
for path in os.listdir(subdir):
path = os.path.join(subdir, path)
if not path.endswith(settings.AIRCOX_SOUND_FILE_EXT):
continue
sound_file = SoundFile(path)
sound_file.sync(program=program, **sound_kwargs)
sounds.append(sound_file.sound.pk)
# sounds in db & unchecked
sounds = Sound.objects.filter(path__startswith=subdir). \
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():
SoundFile(sound.path).sync(sound=sound, **sync_kwargs)
def monitor(self):
""" Run in monitor mode """
with futures.ThreadPoolExecutor() as pool:
archives_handler = MonitorHandler(settings.AIRCOX_SOUND_ARCHIVES_SUBDIR, pool)
excerpts_handler = MonitorHandler(settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, pool)
observer = Observer()
observer.schedule(archives_handler, settings.AIRCOX_PROGRAMS_DIR,
recursive=True)
observer.schedule(excerpts_handler, settings.AIRCOX_PROGRAMS_DIR,
recursive=True)
observer.start()
def leave():
observer.stop()
observer.join()
atexit.register(leave)
while True:
time.sleep(1)
def add_arguments(self, parser):
parser.formatter_class = RawTextHelpFormatter
parser.add_argument(
"-q",
"--quality_check",
action="store_true",
help="Enable quality check using sound_quality_check on all " "sounds marqued as not good",
'-q', '--quality_check', action='store_true',
help='Enable quality check using sound_quality_check on all '
'sounds marqued as not good'
)
parser.add_argument(
"-s",
"--scan",
action="store_true",
help="Scan programs directories for changes, plus check for a "
" matching diffusion on sounds that have not been yet assigned",
'-s', '--scan', action='store_true',
help='Scan programs directories for changes, plus check for a '
' matching diffusion on sounds that have not been yet assigned'
)
parser.add_argument(
"-m",
"--monitor",
action="store_true",
help="Run in monitor mode, watch for modification in the " "filesystem and react in consequence",
'-m', '--monitor', action='store_true',
help='Run in monitor mode, watch for modification in the filesystem '
'and react in consequence'
)
def handle(self, *args, **options):
monitor = SoundMonitor()
if options.get("scan"):
monitor.scan()
if options.get("monitor"):
monitor.monitor()
if options.get('scan'):
self.scan()
#if options.get('quality_check'):
# self.check_quality(check=(not options.get('scan')))
if options.get('monitor'):
self.monitor()

View File

@ -1,15 +1,119 @@
"""Analyse and check files using Sox, prints good and bad files."""
"""
Analyse and check files using Sox, prints good and bad files.
"""
import sys
import logging
import re
import subprocess
from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand, CommandError
from aircox.controllers.sound_stats import SoundStats, SoxStats
logger = logging.getLogger("aircox.commands")
logger = logging.getLogger('aircox.commands')
class Command(BaseCommand):
class Stats:
attributes = [
'DC offset', 'Min level', 'Max level',
'Pk lev dB', 'RMS lev dB', 'RMS Pk dB',
'RMS Tr dB', 'Flat factor', 'Length s',
]
def __init__(self, path, **kwargs):
"""
If path is given, call analyse with path and kwargs
"""
self.values = {}
if path:
self.analyse(path, **kwargs)
def get(self, attr):
return self.values.get(attr)
def parse(self, output):
for attr in Stats.attributes:
value = re.search(attr + r'\s+(?P<value>\S+)', output)
value = value and value.groupdict()
if value:
try:
value = float(value.get('value'))
except ValueError:
value = None
self.values[attr] = value
self.values['length'] = self.values['Length s']
def analyse(self, path, at=None, length=None):
"""
If at and length are given use them as excerpt to analyse.
"""
args = ['sox', path, '-n']
if at is not None and length is not None:
args += ['trim', str(at), str(length)]
args.append('stats')
p = subprocess.Popen(args, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
# sox outputs to stderr (my god WHYYYY)
out_, out = p.communicate()
self.parse(str(out, encoding='utf-8'))
class SoundStats:
path = None # file path
sample_length = 120 # default sample length in seconds
stats = None # list of samples statistics
bad = None # list of bad samples
good = None # list of good samples
def __init__(self, path, sample_length=None):
self.path = path
self.sample_length = sample_length if sample_length is not None \
else self.sample_length
def get_file_stats(self):
return self.stats and self.stats[0]
def analyse(self):
logger.info('complete file analysis')
self.stats = [Stats(self.path)]
position = 0
length = self.stats[0].get('length')
if not self.sample_length:
return
logger.info('start samples analysis...')
while position < length:
stats = Stats(self.path, at=position, length=self.sample_length)
self.stats.append(stats)
position += self.sample_length
def check(self, name, min_val, max_val):
self.good = [index for index, stats in enumerate(self.stats)
if min_val <= stats.get(name) <= max_val]
self.bad = [index for index, stats in enumerate(self.stats)
if index not in self.good]
self.resume()
def resume(self):
def view(array): return [
'file' if index is 0 else
'sample {} (at {} seconds)'.format(
index, (index-1) * self.sample_length)
for index in array
]
if self.good:
logger.info(self.path + ' -> good: \033[92m%s\033[0m',
', '.join(view(self.good)))
if self.bad:
logger.info(self.path + ' -> bad: \033[91m%s\033[0m',
', '.join(view(self.bad)))
class Command (BaseCommand):
help = __doc__
sounds = None
@ -17,56 +121,46 @@ class Command(BaseCommand):
parser.formatter_class = RawTextHelpFormatter
parser.add_argument(
"files",
metavar="FILE",
type=str,
nargs="+",
help="file(s) to analyse",
'files', metavar='FILE', type=str, nargs='+',
help='file(s) to analyse'
)
parser.add_argument(
"-s",
"--sample_length",
type=int,
default=120,
help="size of sample to analyse in seconds. If not set (or 0), " "does not analyse by sample",
'-s', '--sample_length', type=int, default=120,
help='size of sample to analyse in seconds. If not set (or 0), does'
' not analyse by sample',
)
parser.add_argument(
"-a",
"--attribute",
type=str,
help="attribute name to use to check, that can be:\n"
+ ", ".join(['"{}"'.format(attr) for attr in SoxStats.attributes]),
'-a', '--attribute', type=str,
help='attribute name to use to check, that can be:\n' +
', '.join(['"{}"'.format(attr) for attr in Stats.attributes])
)
parser.add_argument(
"-r",
"--range",
type=float,
nargs=2,
help="range of minimal and maximal accepted value such as: " "--range min max",
'-r', '--range', type=float, nargs=2,
help='range of minimal and maximal accepted value such as: '
'--range min max'
)
parser.add_argument(
"-i",
"--resume",
action="store_true",
help="print a resume of good and bad files",
'-i', '--resume', action='store_true',
help='print a resume of good and bad files'
)
def handle(self, *args, **options):
# parameters
minmax = options.get("range")
minmax = options.get('range')
if not minmax:
raise CommandError("no range specified")
raise CommandError('no range specified')
attr = options.get("attribute")
attr = options.get('attribute')
if not attr:
raise CommandError("no attribute specified")
raise CommandError('no attribute specified')
# sound analyse and checks
self.sounds = [SoundStats(path, options.get("sample_length")) for path in options.get("files")]
self.sounds = [SoundStats(path, options.get('sample_length'))
for path in options.get('files')]
self.bad = []
self.good = []
for sound in self.sounds:
logger.info("analyse " + sound.path)
logger.info('analyse ' + sound.path)
sound.analyse()
sound.check(attr, minmax[0], minmax[1])
if sound.bad:
@ -75,8 +169,8 @@ class Command(BaseCommand):
self.good.append(sound)
# resume
if options.get("resume"):
if options.get('resume'):
for sound in self.good:
logger.info("\033[92m+ %s\033[0m", sound.path)
logger.info('\033[92m+ %s\033[0m', sound.path)
for sound in self.bad:
logger.info("\033[91m+ %s\033[0m", sound.path)
logger.info('\033[91m+ %s\033[0m', sound.path)

View File

@ -1,57 +1,51 @@
from zoneinfo import ZoneInfo
import pytz
from django.db.models import Q
from django.utils import timezone as tz
from .models import Station
from .utils import Redirect
__all__ = ("AircoxMiddleware",)
__all__ = ['AircoxMiddleware']
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
"""
Middleware used to get default info for the given website. Theses
This middleware must be set after the middleware
'django.contrib.auth.middleware.AuthenticationMiddleware',
"""
timezone_session_key = "aircox.timezone"
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()
expr = Q(default=True) | Q(hosts=host) | Q(hosts__contains=host + "\n")
return Station.objects.filter(expr).order_by("default").first()
""" Return station for the provided request """
expr = Q(default=True) | Q(hosts__contains=request.get_host())
# case = Case(When(hosts__contains=request.get_host(), then=Value(0)),
# When(default=True, then=Value(32)))
return Station.objects.filter(expr).order_by('default').first()
# .annotate(resolve_priority=case) \
# .order_by('resolve_priority').first()
def init_timezone(self, request):
# note: later we can use http://freegeoip.net/ on user side if
# required
timezone = None
try:
timezone = request.session.get(self.timezone_session_key)
timezone = request.session.get('aircox.timezone')
if timezone:
timezone = ZoneInfo(timezone)
tz.activate(timezone)
except Exception:
timezone = pytz.timezone(timezone)
except:
pass
if not timezone:
timezone = tz.get_current_timezone()
tz.activate(timezone)
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:

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +0,0 @@
# Generated by Django 3.0.6 on 2020-05-26 13:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("aircox", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name="staticpage",
old_name="view",
new_name="attach_to",
),
]

View File

@ -1,146 +0,0 @@
# Generated by Django 3.0.6 on 2020-05-30 11:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import filer.fields.image
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
("aircox", "0002_auto_20200526_1516"),
]
operations = [
migrations.AlterModelOptions(
name="log",
options={"verbose_name": "Log", "verbose_name_plural": "Logs"},
),
migrations.AlterModelOptions(
name="page",
options={
"verbose_name": "Publication",
"verbose_name_plural": "Publications",
},
),
migrations.AlterModelOptions(
name="program",
options={
"verbose_name": "Program",
"verbose_name_plural": "Programs",
},
),
migrations.RemoveField(
model_name="article",
name="is_static",
),
migrations.AddField(
model_name="diffusion",
name="schedule",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="aircox.Schedule",
verbose_name="schedule",
),
),
migrations.AlterField(
model_name="diffusion",
name="initial",
field=models.ForeignKey(
blank=True,
limit_choices_to={"initial__isnull": True},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rerun_set",
to="aircox.Diffusion",
verbose_name="rerun of",
),
),
migrations.AlterField(
model_name="navitem",
name="page",
field=models.ForeignKey(
blank=True,
limit_choices_to={"attach_to__isnull": True},
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="aircox.StaticPage",
verbose_name="page",
),
),
migrations.AlterField(
model_name="schedule",
name="frequency",
field=models.SmallIntegerField(
choices=[
(0, "ponctual"),
(1, "1st {day} of the month"),
(2, "2nd {day} of the month"),
(4, "3rd {day} of the month"),
(8, "4th {day} of the month"),
(16, "last {day} of the month"),
(5, "1st and 3rd {day} of the month"),
(10, "2nd and 4th {day} of the month"),
(31, "every {day}"),
(32, "one {day} on two"),
],
verbose_name="frequency",
),
),
migrations.AlterField(
model_name="schedule",
name="initial",
field=models.ForeignKey(
blank=True,
limit_choices_to={"initial__isnull": True},
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rerun_set",
to="aircox.Schedule",
verbose_name="rerun of",
),
),
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"),
],
help_text="display this page content to related element",
null=True,
verbose_name="attach to",
),
),
migrations.AlterField(
model_name="station",
name="default_cover",
field=filer.fields.image.FilerImageField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.FILER_IMAGE_MODEL,
verbose_name="Default pages' cover",
),
),
migrations.AlterField(
model_name="track",
name="timestamp",
field=models.PositiveSmallIntegerField(
blank=True,
help_text="position (in seconds)",
null=True,
verbose_name="timestamp",
),
),
]

View File

@ -1,44 +0,0 @@
# Generated by Django 3.1.1 on 2020-09-21 23:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("aircox", "0003_auto_20200530_1116"),
]
operations = [
migrations.AlterModelOptions(
name="comment",
options={
"verbose_name": "Comment",
"verbose_name_plural": "Comments",
},
),
migrations.AlterModelOptions(
name="navitem",
options={
"ordering": ("order", "pk"),
"verbose_name": "Menu item",
"verbose_name_plural": "Menu items",
},
),
migrations.RemoveField(
model_name="sound",
name="embed",
),
migrations.AlterField(
model_name="sound",
name="program",
field=models.ForeignKey(
default=1,
help_text="program related to it",
on_delete=django.db.models.deletion.CASCADE,
to="aircox.program",
verbose_name="program",
),
preserve_default=False,
),
]

View File

@ -1,839 +0,0 @@
# Generated by Django 3.2.12 on 2022-03-18 12:05
import aircox.models.sound
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("aircox", "0004_auto_20200921_2356"),
]
operations = [
migrations.RemoveField(
model_name="sound",
name="path",
),
migrations.AddField(
model_name="sound",
name="file",
field=models.FileField(
default="",
upload_to=aircox.models.sound.Sound._upload_to,
verbose_name="file",
),
preserve_default=False,
),
migrations.AlterField(
model_name="category",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="comment",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="diffusion",
name="end",
field=models.DateTimeField(db_index=True, verbose_name="end"),
),
migrations.AlterField(
model_name="diffusion",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="diffusion",
name="start",
field=models.DateTimeField(db_index=True, verbose_name="start"),
),
migrations.AlterField(
model_name="log",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="navitem",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="navitem",
name="page",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="aircox.staticpage",
verbose_name="page",
),
),
migrations.AlterField(
model_name="page",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="port",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="schedule",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
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/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/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"),
("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=django.utils.timezone.get_current_timezone,
help_text="timezone used for the date",
max_length=100,
verbose_name="timezone",
),
),
migrations.AlterField(
model_name="sound",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="sound",
name="program",
field=models.ForeignKey(
blank=True,
help_text="program related to it",
on_delete=django.db.models.deletion.CASCADE,
to="aircox.program",
verbose_name="program",
),
),
migrations.AlterField(
model_name="staticpage",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="station",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="stream",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
migrations.AlterField(
model_name="track",
name="id",
field=models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 3.2.12 on 2022-03-26 15:21
import aircox.models.sound
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0005_auto_20220318_1205"),
]
operations = [
migrations.AlterField(
model_name="sound",
name="file",
field=models.FileField(
db_index=True,
max_length=256,
upload_to=aircox.models.sound.Sound._upload_to,
verbose_name="file",
),
),
]

View File

@ -1,710 +0,0 @@
# Generated by Django 4.1 on 2022-10-06 13:47
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
("aircox", "0006_alter_sound_file"),
]
operations = [
migrations.AddField(
model_name="sound",
name="is_downloadable",
field=models.BooleanField(
default=False,
help_text="whether it can be publicly downloaded by visitors (sound must be public)",
verbose_name="downloadable",
),
),
migrations.AlterField(
model_name="page",
name="pub_date",
field=models.DateTimeField(
blank=True,
db_index=True,
null=True,
verbose_name="publication date",
),
),
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/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"),
("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=django.utils.timezone.get_current_timezone,
help_text="timezone used for the date",
max_length=100,
verbose_name="timezone",
),
),
migrations.AlterField(
model_name="sound",
name="is_public",
field=models.BooleanField(
default=False,
help_text="whether it is publicly available as podcast",
verbose_name="public",
),
),
migrations.AlterField(
model_name="stream",
name="begin",
field=models.TimeField(
blank=True,
help_text="used to define a time range this stream is played",
null=True,
verbose_name="begin",
),
),
migrations.AlterField(
model_name="stream",
name="end",
field=models.TimeField(
blank=True,
help_text="used to define a time range this stream is played",
null=True,
verbose_name="end",
),
),
]

View File

@ -1,44 +0,0 @@
# Generated by Django 4.1 on 2022-12-09 13:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0007_sound_is_downloadable_alter_page_pub_date_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="diffusion",
options={
"permissions": (("programming", "edit the diffusions' planification"),),
"verbose_name": "Diffusion",
"verbose_name_plural": "Diffusions",
},
),
migrations.AddField(
model_name="track",
name="album",
field=models.CharField(default="", max_length=128, verbose_name="album"),
),
migrations.AlterField(
model_name="schedule",
name="frequency",
field=models.SmallIntegerField(
choices=[
(0, "ponctual"),
(1, "1st {day} of the month"),
(2, "2nd {day} of the month"),
(4, "3rd {day} of the month"),
(8, "4th {day} of the month"),
(16, "last {day} of the month"),
(5, "1st and 3rd {day} of the month"),
(10, "2nd and 4th {day} of the month"),
(31, "{day}"),
(32, "one {day} on two"),
],
verbose_name="frequency",
),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 4.1 on 2022-12-09 13:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0008_alter_diffusion_options_track_album_and_more"),
]
operations = [
migrations.AddField(
model_name="track",
name="year",
field=models.IntegerField(blank=True, null=True, verbose_name="year"),
),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 4.1 on 2022-12-09 18:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0009_track_year"),
]
operations = [
migrations.AlterField(
model_name="track",
name="album",
field=models.CharField(blank=True, max_length=128, null=True, verbose_name="album"),
),
]

View File

@ -1,46 +0,0 @@
# Generated by Django 4.1 on 2022-12-11 12:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("aircox", "0010_alter_track_album"),
]
operations = [
migrations.CreateModel(
name="UserSettings",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"playlist_editor_columns",
models.JSONField(verbose_name="Playlist Editor Columns"),
),
(
"playlist_editor_sep",
models.CharField(max_length=16, verbose_name="Playlist Editor Separator"),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="aircox_settings",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
),
]

View File

@ -1,33 +0,0 @@
# Generated by Django 4.1 on 2023-01-25 15:18
import aircox.models.sound
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0011_usersettings"),
]
operations = [
migrations.AlterField(
model_name="sound",
name="file",
field=models.FileField(
db_index=True,
max_length=256,
unique=True,
upload_to=aircox.models.sound.Sound._upload_to,
verbose_name="file",
),
),
migrations.AlterField(
model_name="station",
name="default",
field=models.BooleanField(
default=False,
help_text="use this station as the main one.",
verbose_name="default station",
),
),
]

View File

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

View File

@ -1,623 +0,0 @@
# Generated by Django 4.2.5 on 2023-10-18 07:26
import aircox.models.schedule
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0013_alter_schedule_timezone_alter_station_hosts"),
]
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

@ -1,641 +0,0 @@
# 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

@ -1,25 +0,0 @@
# 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

@ -1,32 +0,0 @@
# 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

@ -1,36 +0,0 @@
# 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

@ -1,32 +0,0 @@
# 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

@ -1,12 +0,0 @@
# 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

@ -1,42 +0,0 @@
# 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

@ -1,12 +0,0 @@
# 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

@ -1,623 +0,0 @@
# 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

@ -1,31 +0,0 @@
from django.db import migrations, models, transaction
def init_groups_and_permissions(app, schema_editor):
from aircox import permissions
Program = app.get_model("aircox", "Program")
with transaction.atomic():
for program in Program.objects.all():
permissions.program.init(program)
class Migration(migrations.Migration):
atomic = False
dependencies = [
("aircox", "0021_alter_schedule_timezone"),
]
operations = [
migrations.RunPython(init_groups_and_permissions),
migrations.AlterField(
model_name="program",
name="editors_group",
field=models.ForeignKey(
on_delete=models.deletion.CASCADE,
to="auth.group",
verbose_name="editors",
),
),
]

View File

@ -1,634 +0,0 @@
# 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

@ -1,22 +0,0 @@
# 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

@ -1,36 +0,0 @@
# 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

@ -1,162 +0,0 @@
# 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

@ -1,78 +0,0 @@
# 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,64 +0,0 @@
# Generated by Django 5.0.4 on 2024-05-27 12:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("aircox", "0027_remove_page_parent_remove_staticpage_parent_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="episodesound",
options={"verbose_name": "Podcast", "verbose_name_plural": "Podcasts"},
),
migrations.AlterField(
model_name="diffusion",
name="initial",
field=models.ForeignKey(
blank=True,
limit_choices_to=models.Q(("initial__isnull", True), ("program", models.F("program"))),
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rerun_set",
to="aircox.diffusion",
verbose_name="rerun of",
),
),
migrations.AlterField(
model_name="log",
name="source",
field=models.CharField(
blank=True, help_text="Identifier of the log's source.", max_length=64, null=True, verbose_name="source"
),
),
migrations.AlterField(
model_name="program",
name="active",
field=models.BooleanField(
default=True, help_text="if not checked this program is no longer active", verbose_name="active"
),
),
migrations.AlterField(
model_name="schedule",
name="initial",
field=models.ForeignKey(
blank=True,
limit_choices_to=models.Q(("initial__isnull", True), ("program", models.F("program"))),
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rerun_set",
to="aircox.schedule",
verbose_name="rerun of",
),
),
migrations.AlterField(
model_name="sound",
name="is_downloadable",
field=models.BooleanField(
default=False, help_text="Sound can be downloaded by website visitors.", verbose_name="downloadable"
),
),
]

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,17 +1,16 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from .page import ChildPage
from .program import ProgramChildQuerySet
__all__ = ("Article",)
from .page import Page, PageQuerySet
from .program import Program, ProgramChildQuerySet
class Article(ChildPage):
detail_url_name = "article-detail"
template_prefix = "article"
class Article(Page):
detail_url_name = 'article-detail'
objects = ProgramChildQuerySet.as_manager()
class Meta:
verbose_name = _("Article")
verbose_name_plural = _("Articles")
verbose_name = _('Article')
verbose_name_plural = _('Articles')

View File

@ -1,245 +0,0 @@
import datetime
from django.db import models
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from aircox import utils
from .episode import Episode
from .schedule import Schedule
from .rerun import Rerun, RerunQuerySet
__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)
def on_air(self):
"""On air diffusions."""
return self.filter(type=Diffusion.TYPE_ON_AIR)
# TODO: rename to `datetime`
def now(self, now=None, order=True):
"""Diffusions occuring now."""
now = now or tz.now()
qs = self.filter(start__lte=now, end__gte=now).distinct()
return qs.order_by("start") if order else qs
def date(self, date=None, order=True):
"""Diffusions occuring date."""
date = date or datetime.date.today()
start = tz.make_aware(tz.datetime.combine(date, datetime.time()))
end = tz.make_aware(tz.datetime.combine(date, datetime.time(23, 59, 59, 999)))
# start = tz.get_current_timezone().localize(start)
# end = tz.get_current_timezone().localize(end)
qs = self.filter(start__range=(start, end))
return qs.order_by("start") if order else qs
def at(self, date, order=True):
"""Return diffusions at specified date or datetime."""
return self.now(date, order) if isinstance(date, tz.datetime) else self.date(date, order)
def after(self, date=None):
"""Return a queryset of diffusions that happen after the given date
(default: today)."""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(Q(start__gte=date) | Q(end__gte=date))
else:
qs = self.filter(Q(start__date__gte=date) | Q(end__date__gte=date))
return qs.order_by("start")
def before(self, date=None):
"""Return a queryset of diffusions that finish before the given date
(default: today)."""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(start__lt=date)
else:
qs = self.filter(start__date__lt=date)
return qs.order_by("start")
def range(self, start, end):
# FIXME can return dates that are out of range...
return self.after(start).before(end)
class Diffusion(Rerun):
"""A Diffusion is an occurrence of a Program that is scheduled on the
station's timetable. It can be a rerun of a previous diffusion. In such a
case, use rerun's info instead of its own.
A Diffusion without any rerun is named Episode (previously, a
Diffusion was different from an Episode, but in the end, an
episode only has a name, a linked program, and a list of sounds, so we
finally merge theme).
A Diffusion can have different types:
- default: simple diffusion that is planified / did occurred
- unconfirmed: a generated diffusion that has not been confirmed and thus
is not yet planified
- cancel: the diffusion has been canceled
- stop: the diffusion has been manually stopped
"""
list_url_name = "timetable-list"
objects = DiffusionQuerySet.as_manager()
TYPE_ON_AIR = 0x00
TYPE_UNCONFIRMED = 0x01
TYPE_CANCEL = 0x02
TYPE_CHOICES = (
(TYPE_ON_AIR, _("on air")),
(TYPE_UNCONFIRMED, _("not confirmed")),
(TYPE_CANCEL, _("cancelled")),
)
episode = models.ForeignKey(
Episode,
models.CASCADE,
verbose_name=_("episode"),
)
schedule = models.ForeignKey(
Schedule,
models.CASCADE,
verbose_name=_("schedule"),
blank=True,
null=True,
)
type = models.SmallIntegerField(
verbose_name=_("type"),
default=TYPE_ON_AIR,
choices=TYPE_CHOICES,
)
start = models.DateTimeField(_("start"), db_index=True)
end = models.DateTimeField(_("end"), db_index=True)
# port = models.ForeignKey(
# 'self',
# verbose_name = _('port'),
# blank = True, null = True,
# on_delete=models.SET_NULL,
# help_text = _('use this input port'),
# )
class Meta:
verbose_name = _("Diffusion")
verbose_name_plural = _("Diffusions")
permissions = (("programming", _("edit the diffusions' planification")),)
def __str__(self):
str_ = "{episode} - {date}".format(
episode=self.episode and self.episode.title,
date=self.local_start.strftime("%Y/%m/%d %H:%M%z"),
)
if self.initial:
str_ += " ({})".format(_("rerun"))
return str_
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.is_initial and self.episode != self._initial["episode"]:
self.rerun_set.update(episode=self.episode, program=self.program)
# def save(self, no_check=False, *args, **kwargs):
# if self.start != self._initial['start'] or \
# self.end != self._initial['end']:
# self.check_conflicts()
def save_rerun(self):
self.episode = self.initial.episode
super().save_rerun()
def save_initial(self):
self.program = self.episode.program
@property
def duration(self):
return self.end - self.start
@property
def date(self):
"""Return diffusion start as a date."""
return utils.cast_date(self.start)
@cached_property
def local_start(self):
"""Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want to get
it as local time."""
return tz.localtime(self.start, tz.get_current_timezone())
@property
def local_end(self):
"""Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want to get
it as local time."""
return tz.localtime(self.end, tz.get_current_timezone())
@property
def is_now(self):
"""True if diffusion is currently running."""
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.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
range."""
date = date or tz.now()
return self.start < date < self.end
def get_conflicts(self):
"""Return conflicting diffusions queryset."""
# conflicts=Diffusion.objects.filter(
# Q(start__lt=OuterRef('start'), end__gt=OuterRef('end')) |
# Q(start__gt=OuterRef('start'), start__lt=OuterRef('end'))
# )
# diffs= Diffusion.objects.annotate(conflict_with=Exists(conflicts))
# .filter(conflict_with=True)
return (
Diffusion.objects.filter(
Q(start__lt=self.start, end__gt=self.start) | Q(start__gt=self.start, start__lt=self.end)
)
.exclude(pk=self.pk)
.distinct()
)
def check_conflicts(self):
conflicts = self.get_conflicts()
self.conflicts.set(conflicts)
_initial = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._initial = {
"start": self.start,
"end": self.end,
"episode": getattr(self, "episode", None),
}

View File

@ -1,137 +1,318 @@
import os
import datetime
from django.conf import settings as d_settings
from django.db import models
from django.utils.functional import cached_property
from django.db.models import Q
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
from aircox.conf import settings
from easy_thumbnails.files import get_thumbnailer
from .page import ChildPage
from .program import ProgramChildQuerySet
from .sound import Sound
__all__ = ("Episode",)
from aircox import settings, utils
from .program import Program, ProgramChildQuerySet, \
BaseRerun, BaseRerunQuerySet, Schedule
from .page import Page, PageQuerySet
class EpisodeQuerySet(ProgramChildQuerySet):
def with_podcasts(self):
return self.filter(episodesound__sound__is_public=True, episodesound__sound__is_removed=False).distinct()
__all__ = ['Episode', 'Diffusion', 'DiffusionQuerySet']
class Episode(ChildPage):
objects = EpisodeQuerySet.as_manager()
detail_url_name = "episode-detail"
list_url_name = "episode-list"
edit_url_name = "episode-edit"
template_prefix = "episode"
class Episode(Page):
objects = ProgramChildQuerySet.as_manager()
detail_url_name = 'episode-detail'
item_template_name = 'aircox/widgets/episode_item.html'
@property
def program(self):
return self.parent_subclass
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
@program.setter
def program(self, value):
self.parent = value
@cached_property
def podcasts(self):
"""Return serialized data about podcasts."""
query = self.episodesound_set.available().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")
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")
raise ValueError('missing parent program')
super().save(*args, **kwargs)
@classmethod
def get_default_title(cls, page, date):
return settings.EPISODE_TITLE.format(
def get_init_kwargs_from(cls, page, date, title=None, **kwargs):
""" Get default Episode's title """
title = settings.AIRCOX_EPISODE_TITLE.format(
program=page,
date=date.strftime(settings.EPISODE_TITLE_DATE_FORMAT),
)
@classmethod
def get_init_kwargs_from(cls, page, date=None, title=None, **kwargs):
"""Get default Episode's title."""
title = (
settings.EPISODE_TITLE.format(
program=page,
date=date.strftime(settings.EPISODE_TITLE_DATE_FORMAT),
)
if title is None
else title
)
return super().get_init_kwargs_from(page, title=title, program=page, **kwargs)
date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT),
) if title is None 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)
class DiffusionQuerySet(BaseRerunQuerySet):
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)
def available(self):
return self.filter(sound__is_removed=False)
def on_air(self):
""" On air diffusions """
return self.filter(type=Diffusion.TYPE_ON_AIR)
def public(self):
return self.filter(sound__is_public=True)
# TODO: rename to `datetime`
def now(self, now=None, order=True):
""" Diffusions occuring now """
now = now or tz.now()
qs = self.filter(start__lte=now, end__gte=now).distinct()
return qs.order_by('start') if order else qs
def broadcast(self):
return self.available().filter(broadcast=True)
def date(self, date=None, order=True):
""" Diffusions occuring date. """
date = date or datetime.date.today()
start = tz.datetime.combine(date, datetime.time())
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
# start = tz.get_current_timezone().localize(start)
# end = tz.get_current_timezone().localize(end)
qs = self.filter(start__range = (start, end))
return qs.order_by('start') if order else qs
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]
def at(self, date, order=True):
""" Return diffusions at specified date or datetime """
return self.now(date, order) if isinstance(date, tz.datetime) else \
self.date(date, order)
def after(self, date=None):
"""
Return a queryset of diffusions that happen after the given
date (default: today).
"""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(Q(start__gte=date) | Q(end__gte=date))
else:
qs = self.filter(Q(start__date__gte=date) | Q(end__date__gte=date))
return qs.order_by('start')
def before(self, date=None):
"""
Return a queryset of diffusions that finish before the given
date (default: today).
"""
date = utils.date_or_default(date)
if isinstance(date, tz.datetime):
qs = self.filter(start__lt=date)
else:
qs = self.filter(start__date__lt=date)
return qs.order_by('start')
def range(self, start, end):
# FIXME can return dates that are out of range...
return self.after(start).before(end)
class EpisodeSound(models.Model):
"""Element of an episode playlist."""
class Diffusion(BaseRerun):
"""
A Diffusion is an occurrence of a Program that is scheduled on the
station's timetable. It can be a rerun of a previous diffusion. In such
a case, use rerun's info instead of its own.
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"),
A Diffusion without any rerun is named Episode (previously, a
Diffusion was different from an Episode, but in the end, an
episode only has a name, a linked program, and a list of sounds, so we
finally merge theme).
A Diffusion can have different types:
- default: simple diffusion that is planified / did occurred
- unconfirmed: a generated diffusion that has not been confirmed and thus
is not yet planified
- cancel: the diffusion has been canceled
- stop: the diffusion has been manually stopped
"""
objects = DiffusionQuerySet.as_manager()
TYPE_ON_AIR = 0x00
TYPE_UNCONFIRMED = 0x01
TYPE_CANCEL = 0x02
TYPE_CHOICES = (
(TYPE_ON_AIR, _('on air')),
(TYPE_UNCONFIRMED, _('not confirmed')),
(TYPE_CANCEL, _('cancelled')),
)
objects = EpisodeSoundQuerySet.as_manager()
episode = models.ForeignKey(
Episode, models.CASCADE, verbose_name=_('episode'),
)
schedule = models.ForeignKey(
Schedule, models.CASCADE, verbose_name=_('schedule'),
blank=True, null=True,
)
type = models.SmallIntegerField(
verbose_name=_('type'), default=TYPE_ON_AIR, choices=TYPE_CHOICES,
)
start = models.DateTimeField(_('start'), db_index=True)
end = models.DateTimeField(_('end'), db_index=True)
# port = models.ForeignKey(
# 'self',
# verbose_name = _('port'),
# blank = True, null = True,
# on_delete=models.SET_NULL,
# help_text = _('use this input port'),
# )
item_template_name = 'aircox/widgets/diffusion_item.html'
class Meta:
verbose_name = _("Podcast")
verbose_name_plural = _("Podcasts")
verbose_name = _('Diffusion')
verbose_name_plural = _('Diffusions')
permissions = (
('programming', _('edit the diffusion\'s planification')),
)
def __str__(self):
str_ = '{episode} - {date}'.format(
self=self, episode=self.episode and self.episode.title,
date=self.local_start.strftime('%Y/%m/%d %H:%M%z'),
)
if self.initial:
str_ += ' ({})'.format(_('rerun'))
return str_
#def save(self, no_check=False, *args, **kwargs):
#if self.start != self._initial['start'] or \
# self.end != self._initial['end']:
# self.check_conflicts()
def save_rerun(self):
self.episode = self.initial.episode
self.program = self.episode.program
def save_initial(self):
self.program = self.episode.program
if self.episode != self._initial['episode']:
self.rerun_set.update(episode=self.episode, program=self.program)
@property
def duration(self):
return self.end - self.start
@property
def date(self):
""" Return diffusion start as a date. """
return utils.cast_date(self.start)
@cached_property
def local_start(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
return tz.localtime(self.start, tz.get_current_timezone())
@property
def local_end(self):
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
return tz.localtime(self.end, tz.get_current_timezone())
@property
def is_now(self):
""" True if diffusion is currently running """
now = tz.now()
return self.type == self.TYPE_ON_AIR and \
self.start <= now and self.end >= now
# TODO: 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)
def is_date_in_range(self, date=None):
"""
Return true if the given date is in the diffusion's start-end
range.
"""
date = date or tz.now()
return self.start < date < self.end
def get_conflicts(self):
""" Return conflicting diffusions queryset """
# conflicts=Diffusion.objects.filter(Q(start__lt=OuterRef('start'), end__gt=OuterRef('end')) | Q(start__gt=OuterRef('start'), start__lt=OuterRef('end')))
# diffs= Diffusion.objects.annotate(conflict_with=Exists(conflicts)).filter(conflict_with=True)
return Diffusion.objects.filter(
Q(start__lt=self.start, end__gt=self.start) |
Q(start__gt=self.start, start__lt=self.end)
).exclude(pk=self.pk).distinct()
def check_conflicts(self):
conflicts = self.get_conflicts()
self.conflicts.set(conflicts)
_initial = None
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._initial = {
'start': self.start,
'end': self.end,
'episode': getattr(self, 'episode', None),
}
def save(self, *args, **kwargs):
if self.broadcast is None:
self.broadcast = self.sound.broadcast
super().save(*args, **kwargs)

View File

@ -1,153 +0,0 @@
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,40 +1,44 @@
import datetime
import logging
import operator
from collections import deque
import datetime
import gzip
import logging
import os
import yaml
from django.db import models
from django.utils import timezone as tz
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from .diffusion import Diffusion
from .sound import Sound
from aircox import settings
from .episode import Diffusion
from .sound import Sound, Track
from .station import Station
from .track import Track
from .page import Renderable
logger = logging.getLogger("aircox")
__all__ = ("Log", "LogQuerySet")
logger = logging.getLogger('aircox')
__all__ = ['Log', 'LogQuerySet', 'LogArchiver']
class LogQuerySet(models.QuerySet):
def station(self, station=None, id=None):
return self.filter(station=station) if id is None else self.filter(station_id=id)
return self.filter(station=station) if id is None else \
self.filter(station_id=id)
def date(self, date):
start = tz.datetime.combine(date, datetime.time())
end = tz.datetime.combine(date, datetime.time(23, 59, 59, 999))
return self.filter(date__range=(start, end))
return self.filter(date__range = (start, end))
# this filter does not work with mysql
# return self.filter(date__date=date)
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)
return self.filter(date__gte=date) \
if isinstance(date, tz.datetime) else \
self.filter(date__date__gte=date)
def on_air(self):
return self.filter(type=Log.TYPE_ON_AIR)
@ -52,83 +56,65 @@ class LogQuerySet(models.QuerySet):
return self.filter(track__isnull=not with_it)
class Log(Renderable, models.Model):
"""Log sounds and diffusions that are played on the station.
class Log(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."""
""" Source has been stopped, e.g. manually """
# Rule: \/ diffusion != null \/ sound != null
TYPE_START = 0x01
"""Diffusion or sound has been request to be played."""
""" Diffusion or sound has been request to be played. """
TYPE_CANCEL = 0x02
"""Diffusion has been canceled."""
""" Diffusion has been canceled. """
# Rule: \/ sound != null /\ track == null
# \/ sound == null /\ track != null
# \/ sound == null /\ track == null /\ comment = sound_path
TYPE_ON_AIR = 0x03
"""Sound or diffusion occured on air."""
""" Sound or diffusion occured on air """
TYPE_OTHER = 0x04
"""Other log."""
""" Other log """
TYPE_CHOICES = (
(TYPE_STOP, _("stop")),
(TYPE_START, _("start")),
(TYPE_CANCEL, _("cancelled")),
(TYPE_ON_AIR, _("on air")),
(TYPE_OTHER, _("other")),
(TYPE_STOP, _('stop')), (TYPE_START, _('start')),
(TYPE_CANCEL, _('cancelled')), (TYPE_ON_AIR, _('on air')),
(TYPE_OTHER, _('other'))
)
station = models.ForeignKey(
Station,
models.CASCADE,
verbose_name=_("station"),
help_text=_("related station"),
Station, models.CASCADE,
verbose_name=_('station'), help_text=_('related station'),
)
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
date = models.DateTimeField(_("date"), default=tz.now, db_index=True)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
date = models.DateTimeField(_('date'), default=tz.now, db_index=True)
source = models.CharField(
# we use a CharField to avoid loosing logs information if the
# source is removed
max_length=64,
blank=True,
null=True,
verbose_name=_("source"),
help_text=_("Identifier of the log's source."),
max_length=64, blank=True, null=True,
verbose_name=_('source'),
help_text=_('identifier of the source related to this log'),
)
comment = models.CharField(
max_length=512,
blank=True,
null=True,
verbose_name=_("comment"),
max_length=512, blank=True, null=True,
verbose_name=_('comment'),
)
sound = models.ForeignKey(
Sound,
models.SET_NULL,
blank=True,
null=True,
db_index=True,
verbose_name=_("Sound"),
Sound, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Sound'),
)
track = models.ForeignKey(
Track,
models.SET_NULL,
blank=True,
null=True,
db_index=True,
verbose_name=_("Track"),
Track, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Track'),
)
diffusion = models.ForeignKey(
Diffusion,
models.SET_NULL,
blank=True,
null=True,
db_index=True,
verbose_name=_("Diffusion"),
Diffusion, models.SET_NULL,
blank=True, null=True, db_index=True,
verbose_name=_('Diffusion'),
)
objects = LogQuerySet.as_manager()
@ -140,9 +126,11 @@ class Log(Renderable, models.Model):
# FIXME: required????
@property
def local_date(self):
"""Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want to get
it as local time."""
"""
Return a version of self.date that is localized to self.timezone;
This is needed since datetime are stored as UTC date and we want
to get it as local time.
"""
return tz.localtime(self.date, tz.get_current_timezone())
# prepare for the future on crash + ease the use in merged lists with
@ -152,38 +140,34 @@ class Log(Renderable, models.Model):
return self.date
class Meta:
verbose_name = _("Log")
verbose_name_plural = _("Logs")
verbose_name = _('Log')
verbose_name_plural = _('Logs')
def __str__(self):
return "#{} ({}, {}, {})".format(
self.pk,
self.get_type_display(),
self.source,
self.local_date.strftime("%Y/%m/%d %H:%M%z"),
)
return '#{} ({}, {}, {})'.format(
self.pk, self.get_type_display(),
self.source, self.local_date.strftime('%Y/%m/%d %H:%M%z'))
@classmethod
def __list_append(cls, object_list, items):
object_list += [cls(obj) for obj in items]
@classmethod
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`.
def merge_diffusions(cls, logs, diffs, count=None):
"""
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 = diffs.on_air().order_by("-start")
if diff_count:
diffs = diffs[:diff_count]
diffs = deque(diffs)
logs = list(logs.order_by('-date'))
diffs = deque(diffs.on_air().before().order_by('-start'))
object_list = []
while True:
if not len(diffs):
cls._append_logs(object_list, logs, len(logs), group=group_logs)
object_list += logs
break
if not len(logs):
@ -193,17 +177,21 @@ class Log(Renderable, models.Model):
diff = diffs.popleft()
# - takes all logs after diff start
index = cls._next_index(logs, diff.end, len(logs), pred=operator.le)
cls._append_logs(object_list, logs, index, group=group_logs)
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:]
if len(logs):
# FIXME
# - last log while diff is running
# if logs[0].date > diff.start:
#if logs[0].date > diff.start:
# object_list.append(logs[0])
# - skips logs while diff is running
index = cls._next_index(logs, diff.start, len(logs))
index = next((i for i, v in enumerate(logs)
if v.date < diff.start), len(logs))
if index is not None and index > 0:
logs = logs[index:]
@ -212,51 +200,112 @@ class Log(Renderable, 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:
r.append("diff: " + str(self.diffusion_id))
r.append('diff: ' + str(self.diffusion_id))
if self.sound:
r.append("sound: " + str(self.sound_id))
r.append('sound: ' + str(self.sound_id))
if self.track:
r.append("track: " + str(self.track_id))
logger.info(
"log %s: %s%s",
str(self),
self.comment or "",
" (" + ", ".join(r) + ")" if r else "",
r.append('track: ' + str(self.track_id))
logger.info('log %s: %s%s', str(self), self.comment or '',
' (' + ', '.join(r) + ')' if r else '')
class LogArchiver:
""" Commodity class used to manage archives of logs. """
@cached_property
def fields(self):
return Log._meta.get_fields()
@staticmethod
def get_path(station, date):
return os.path.join(
settings.AIRCOX_LOGS_ARCHIVES_DIR,
'{}_{}.log.gz'.format(date.strftime("%Y%m%d"), station.pk)
)
def archive(self, qs, keep=False):
"""
Archive logs of the given queryset. Delete archived logs if not
`keep`. Return the count of archived logs
"""
if not qs.exists():
return 0
os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True)
count = qs.count()
logs = self.sort_logs(qs)
# Note: since we use Yaml, we can just append new logs when file
# exists yet <3
for (station, date), logs in logs.items():
path = self.get_path(station, date)
with gzip.open(path, 'ab') as archive:
data = yaml.dump([self.serialize(l) for l in logs]).encode('utf8')
archive.write(data)
if not keep:
qs.delete()
return count
@staticmethod
def sort_logs(qs):
"""
Sort logs by station and date and return a dict of
`{ (station,date): [logs] }`.
"""
qs = qs.order_by('date')
logs = {}
for log in qs:
key = (log.station, log.date)
if key not in logs:
logs[key] = [log]
else:
logs[key].append(log)
return logs
def serialize(self, log):
""" Serialize log """
return {i.attname: getattr(log, i.attname)
for i in self.fields}
def load(self, station, date):
""" Load an archive returning logs in a list. """
path = self.get_path(station, date)
if not os.path.exists(path):
return []
with gzip.open(path, 'rb') as archive:
data = archive.read()
logs = yaml.load(data)
# we need to preload diffusions, sounds and tracks
rels = {
'diffusion': self.get_relations(logs, Diffusion, 'diffusion'),
'sound': self.get_relations(logs, Sound, 'sound'),
'track': self.get_relations(logs, Track, 'track'),
}
def rel_obj(log, attr):
rel_id = log.get(attr + '_id')
return rels[attr][rel_id] if rel_id else None
return [Log(diffusion=rel_obj(log, 'diffusion'),
sound=rel_obj(log, 'sound'),
track=rel_obj(log, 'track'),
**log) for log in logs]
@staticmethod
def get_relations(logs, model, attr):
"""
From a list of dict representing logs, retrieve related objects
of the given type.
"""
attr_id = attr + '_id'
pks = (log[attr_id] for log in logs if attr_id in log)
return {rel.pk: rel for rel in model.objects.filter(pk__in=pks)}

View File

@ -1,57 +1,44 @@
from enum import IntEnum
import re
import bleach
from django.db import models
from django.urls import reverse
from django.utils import timezone as tz
from django.utils.functional import cached_property
from django.utils.text import slugify
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
import bleach
from ckeditor_uploader.fields import RichTextUploadingField
from filer.fields.image import FilerImageField
from model_utils.managers import InheritanceQuerySet
from ..conf import settings
from .station import Station
__all__ = (
"Renderable",
"Category",
"PageQuerySet",
"Page",
"StaticPage",
"Comment",
"NavItem",
)
__all__ = ['Category', 'PageQuerySet', 'Page', 'Comment', 'NavItem']
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)
headline_re = re.compile(r'(<p>)?'
r'(?P<headline>[^\n]{1,140}(\n|[^\.]*?\.))'
r'(</p>)?')
class Category(models.Model):
title = models.CharField(_("title"), max_length=64)
slug = models.SlugField(_("slug"), max_length=64, db_index=True)
title = models.CharField(_('title'), max_length=64)
slug = models.SlugField(_('slug'), max_length=64, db_index=True)
class Meta:
verbose_name = _("Category")
verbose_name_plural = _("Categories")
verbose_name = _('Category')
verbose_name_plural = _('Categories')
def __str__(self):
return self.title
class BasePageQuerySet(InheritanceQuerySet):
class PageQuerySet(InheritanceQuerySet):
def draft(self):
return self.filter(status=Page.STATUS_DRAFT)
@ -61,80 +48,64 @@ 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)
def search(self, q, search_content=True):
if search_content:
return self.filter(models.Q(title__icontains=q) | models.Q(content__icontains=q))
return self.filter(title__icontains=q)
""" Return pages having this parent. """
return self.filter(parent=parent) if id is None else \
self.filter(parent__id=id)
class BasePage(Renderable, models.Model):
"""Base class for publishable content."""
class BasePage(models.Model):
""" Base class for publishable content """
STATUS_DRAFT = 0x00
STATUS_PUBLISHED = 0x10
STATUS_TRASH = 0x20
STATUS_CHOICES = (
(STATUS_DRAFT, _("draft")),
(STATUS_PUBLISHED, _("published")),
(STATUS_TRASH, _("trash")),
(STATUS_DRAFT, _('draft')),
(STATUS_PUBLISHED, _('published')),
(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)
slug = models.SlugField(_('slug'), max_length=120, blank=True, unique=True,
db_index=True)
status = models.PositiveSmallIntegerField(
_("status"),
default=STATUS_DRAFT,
choices=STATUS_CHOICES,
_('status'), default=STATUS_DRAFT, choices=STATUS_CHOICES,
)
cover = FilerImageField(
on_delete=models.SET_NULL,
verbose_name=_("cover"),
null=True,
blank=True,
verbose_name=_('cover'), null=True, blank=True,
)
content = RichTextUploadingField(
_('content'), blank=True, null=True,
)
content = models.TextField(_("content"), blank=True, null=True)
objects = BasePageQuerySet.as_manager()
objects = PageQuerySet.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)
return '{}'.format(self.title or self.pk)
def save(self, *args, **kwargs):
if self.content:
self.content = bleach.clean(
self.content,
tags=settings.ALLOWED_TAGS,
attributes=settings.ALLOWED_ATTRIBUTES,
protocols=settings.ALLOWED_PROTOCOLS,
)
if not self.slug:
self.slug = slugify(self.title)[:100]
count = Page.objects.filter(slug__startswith=self.slug).count()
if count:
self.slug += "-" + str(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):
if self.is_published:
return reverse(self.detail_url_name, kwargs={"slug": self.slug})
return ""
return reverse(self.detail_url_name, kwargs={'slug': self.slug}) \
if self.is_published else '#'
@property
def is_draft(self):
@ -150,29 +121,22 @@ class BasePage(Renderable, models.Model):
@property
def display_title(self):
return self.is_published and self.title or ""
if self.is_published():
return self.title
return self.parent.display_title()
@cached_property
def display_headline(self):
def headline(self):
if not self.content:
return ''
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)
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))
return mark_safe(headline.groupdict()['headline']) if headline else ''
@classmethod
def get_init_kwargs_from(cls, page, **kwargs):
kwargs.setdefault("cover", page.cover)
kwargs.setdefault("category", page.category)
kwargs.setdefault('cover', page.cover)
kwargs.setdefault('category', page.category)
return kwargs
@classmethod
@ -180,202 +144,119 @@ class BasePage(Renderable, models.Model):
return cls(**cls.get_init_kwargs_from(page, **kwargs))
# FIXME: rename
class PageQuerySet(BasePageQuerySet):
def published(self):
return self.filter(status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now())
class Page(BasePage):
"""Base Page model used for articles and other dated content."""
""" Base Page model used for articles and other dated content. """
category = models.ForeignKey(
Category,
models.SET_NULL,
verbose_name=_("category"),
blank=True,
null=True,
db_index=True,
Category, models.SET_NULL,
verbose_name=_('category'), blank=True, null=True, db_index=True
)
pub_date = models.DateTimeField(_("publication date"), blank=True, null=True, db_index=True)
pub_date = models.DateTimeField(blank=True, null=True)
featured = models.BooleanField(
_("featured"),
default=False,
_('featured'), default=False,
)
allow_comments = models.BooleanField(
_("allow comments"),
default=True,
_('allow comments'), default=True,
)
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
verbose_name = _('Publication')
verbose_name_plural = _('Publications')
def save(self, *args, **kwargs):
if self.is_published and self.pub_date is None:
self.pub_date = tz.now()
elif not self.is_published:
self.pub_date = None
super().save(*args, **kwargs)
class ChildPage(Page):
parent = models.ForeignKey(Page, models.CASCADE, blank=True, null=True, db_index=True, related_name="%(class)s_set")
class Meta:
abstract = True
@property
def display_title(self):
if self.is_published:
return self.title
return self.parent and self.parent.title or ""
@property
def display_headline(self):
if not self.content or not self.is_published:
return self.parent and self.parent.display_headline or ""
return super().display_headline
@cached_property
def parent_subclass(self):
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
if self.parent and not self.category:
self.category = self.parent.category
super().save(*args, **kwargs)
class StaticPage(BasePage):
"""Static page that eventually can be attached to a specific view."""
""" Static page that eventually can be attached to a specific view. """
detail_url_name = 'static-page-detail'
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 = models.CharField(
_("attach to"),
choices=Target.choices,
max_length=32,
blank=True,
null=True,
help_text=_("display this page content to related element"),
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',
}
def get_related_view(self):
from ..views.page import attached_views
return self.attach_to and attached_views.get(self.attach_to) or None
attach_to = models.SmallIntegerField(
_('attach to'), choices=ATTACH_TO_CHOICES, blank=True, null=True,
help_text=_('display this page content to related element'),
)
def get_absolute_url(self):
if self.attach_to:
return reverse(self.attach_to)
return reverse(self.VIEWS[self.attach_to])
return super().get_absolute_url()
class Comment(Renderable, models.Model):
class Comment(models.Model):
page = models.ForeignKey(
Page,
models.CASCADE,
verbose_name=_("related page"),
Page, models.CASCADE, verbose_name=_('related page'),
db_index=True,
# TODO: allow_comment filter
)
nickname = models.CharField(_("nickname"), max_length=32)
email = models.EmailField(_("email"), max_length=32)
nickname = models.CharField(_('nickname'), max_length=32)
email = models.EmailField(_('email'), max_length=32)
date = models.DateTimeField(auto_now_add=True)
content = models.TextField(_("content"), max_length=1024)
template_prefix = "comment"
@cached_property
def parent(self):
"""Return Page as its subclass."""
return Page.objects.select_subclasses().filter(id=self.page_id).first()
def get_absolute_url(self):
return self.parent.get_absolute_url() + f"#{self._meta.label_lower}-{self.pk}"
content = models.TextField(_('content'), max_length=1024)
class Meta:
verbose_name = _("Comment")
verbose_name_plural = _("Comments")
verbose_name = _('Comment')
verbose_name_plural = _('Comments')
class NavItem(models.Model):
"""Navigation menu items."""
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, blank=True, null=True)
url = models.CharField(_("url"), max_length=256, blank=True, null=True)
page = models.ForeignKey(
StaticPage,
models.CASCADE,
db_index=True,
verbose_name=_("page"),
blank=True,
null=True,
)
""" Navigation menu items """
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)
url = models.CharField(_('url'), max_length=256, blank=True, null=True)
page = models.ForeignKey(StaticPage, models.CASCADE, db_index=True,
verbose_name=_('page'), blank=True, null=True)
class Meta:
verbose_name = _("Menu item")
verbose_name_plural = _("Menu items")
ordering = ("order", "pk")
verbose_name = _('Menu item')
verbose_name_plural = _('Menu items')
ordering = ('order', 'pk')
def get_url(self):
return self.url if self.url else self.page.get_absolute_url() if self.page else None
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=""):
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
css_class += ' ' + active_class
if not url:
return label
return self.text
elif not css_class:
return format_html('<a href="{}">{}</a>', url, label)
return format_html('<a href="{}">{}</a>', url, self.text)
else:
return format_html('<a href="{}" class="{}">{}</a>', url, css_class, label)
return format_html('<a href="{}" class="{}">{}</a>', url,
css_class, self.text)

View File

@ -1,22 +1,30 @@
import calendar
from collections import OrderedDict
import datetime
from enum import IntEnum
import logging
import os
import shutil
from django.conf import settings as conf
from django.contrib.auth.models import Group
import pytz
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.db.models.functions import Concat, Substr
from django.utils import timezone as tz
from django.utils.translation import gettext_lazy as _
from django.utils.functional import cached_property
from aircox.conf import settings
from aircox import settings, utils
from .page import Page, PageQuerySet
from .station import Station
__all__ = (
"ProgramQuerySet",
"Program",
"ProgramChildQuerySet",
"Stream",
)
logger = logging.getLogger('aircox')
__all__ = ['Program', 'ProgramQuerySet', 'Stream', 'Schedule',
'ProgramChildQuerySet', 'BaseRerun', 'BaseRerunQuerySet']
class ProgramQuerySet(PageQuerySet):
@ -27,19 +35,10 @@ 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.
"""
A Program can either be a Streamed or a Scheduled program.
A Streamed program is used to generate non-stop random playlists when there
is not scheduled diffusion. In such a case, a Stream is used to describe
@ -50,137 +49,426 @@ class Program(Page):
Renaming a Program rename the corresponding directory to matches the new
name if it does not exists.
"""
# explicit foreign key in order to avoid related name clashes
station = models.ForeignKey(Station, models.CASCADE, verbose_name=_("station"))
station = models.ForeignKey(Station, models.CASCADE,
verbose_name=_('station'))
active = models.BooleanField(
_("active"),
_('active'),
default=True,
help_text=_("if not checked this program is no longer active"),
help_text=_('if not checked this program is no longer active')
)
sync = models.BooleanField(
_("syncronise"),
_('syncronise'),
default=True,
help_text=_("update later diffusions according to schedule changes"),
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"
detail_url_name = 'program-detail'
@property
def path(self):
"""Return program's directory path."""
return os.path.join(settings.PROGRAMS_DIR, self.slug.replace("-", "_"))
@property
def abspath(self):
"""Return absolute path to program's dir."""
return os.path.join(conf.MEDIA_ROOT, self.path)
""" Return program's directory path """
return os.path.join(settings.AIRCOX_PROGRAMS_DIR,
self.slug.replace('-', '_'))
@property
def archives_path(self):
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR)
@property
def excerpts_path(self):
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
return os.path.join(
self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, *kargs, **kwargs):
super().__init__(*kargs, **kwargs)
if self.slug:
self.__initial_path = self.path
self.__initial_cover = self.cover
@classmethod
def get_from_path(cl, path):
"""Return a Program from the given path.
We assume the path has been given in a previous time by this
model (Program.path getter).
"""
if path.startswith(settings.PROGRAMS_DIR_ABS):
path = path.replace(settings.PROGRAMS_DIR_ABS, "")
while path[0] == "/":
Return a Program from the given path. We assume the path has been
given in a previous time by this model (Program.path getter).
"""
path = path.replace(settings.AIRCOX_PROGRAMS_DIR, '')
while path[0] == '/':
path = path[1:]
path = path[: path.index("/")]
return cl.objects.filter(slug=path.replace("_", "-")).first()
path = path[:path.index('/')]
return cl.objects.filter(slug=path.replace('_','-')).first()
def ensure_dir(self, subdir=None):
"""Make sur the program's dir exists (and optionally subdir).
Return True if the dir (or subdir) exists.
"""
path = os.path.join(self.abspath, subdir) if subdir else self.abspath
Make sur the program's dir exists (and optionally subdir). Return True
if the dir (or subdir) exists.
"""
path = os.path.join(self.path, subdir) if subdir else \
self.path
os.makedirs(path, exist_ok=True)
return os.path.exists(path)
class Meta:
verbose_name = _("Program")
verbose_name_plural = _("Programs")
verbose_name = _('Program')
verbose_name_plural = _('Programs')
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.editors_group_id:
from aircox import permissions
def save(self, *kargs, **kwargs):
from .sound import Sound
saved = permissions.program.init(self)
if saved:
return
super().save(*kargs, **kwargs)
super().save()
# TODO: move in signals
path_ = getattr(self, '__initial_path', None)
if path_ is not None and path_ != self.path and \
os.path.exists(path_) and not os.path.exists(self.path):
logger.info('program #%s\'s dir changed to %s - update it.',
self.id, self.title)
shutil.move(path_, self.path)
Sound.objects.filter(path__startswith=path_) \
.update(path=Concat('path', Substr(F('path'), 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
else self.filter(parent__program__station__id=id)
)
return self.filter(parent__program__station=station) if id is None else \
self.filter(parent__program__station__id=id)
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 BaseRerunQuerySet(models.QuerySet):
""" Queryset for BaseRerun (sub)classes. """
def station(self, station=None, id=None):
return self.filter(program__station=station) if id is None else \
self.filter(program__station__id=id)
def program(self, program=None, id=None):
return self.filter(program=program) if id is None else \
self.filter(program__id=id)
def rerun(self):
return self.filter(initial__isnull=False)
def initial(self):
return self.filter(initial__isnull=True)
class BaseRerun(models.Model):
"""
Abstract model offering rerun facilities. Assume `start` is a
datetime field or attribute implemented by subclass.
"""
program = models.ForeignKey(
Program, models.CASCADE, db_index=True,
verbose_name=_('related program'),
)
initial = models.ForeignKey(
'self', models.SET_NULL, related_name='rerun_set',
verbose_name=_('rerun of'),
limit_choices_to={'initial__isnull': True},
blank=True, null=True, db_index=True,
)
objects = BaseRerunQuerySet.as_manager()
class Meta:
abstract = True
def save(self, *args, **kwargs):
if self.initial is not None:
self.initial = self.initial.get_initial()
if self.initial == self:
self.initial = None
if self.is_rerun:
self.save_rerun()
else:
self.save_initial()
super().save(*args, **kwargs)
def save_rerun(self):
pass
def save_initial(self):
pass
@property
def is_initial(self):
return self.initial is None
@property
def is_rerun(self):
return self.initial is not None
def get_initial(self):
""" Return the initial schedule (self or initial) """
return self if self.initial is None else self.initial.get_initial()
def clean(self):
super().clean()
if self.initial is not None and self.initial.start >= self.start:
raise ValidationError({
'initial': _('rerun must happen after original')
})
# ? BIG FIXME: self.date is still used as datetime
class Schedule(BaseRerun):
"""
A Schedule defines time slots of programs' diffusions. It can be an initial
run or a rerun (in such case it is linked to the related schedule).
"""
# Frequency for schedules. Basically, it is a mask of bits where each bit is
# a week. Bits > rank 5 are used for special schedules.
# Important: the first week is always the first week where the weekday of
# the schedule is present.
# For ponctual programs, there is no need for a schedule, only a diffusion
class Frequency(IntEnum):
ponctual = 0b000000
first = 0b000001
second = 0b000010
third = 0b000100
fourth = 0b001000
last = 0b010000
first_and_third = 0b000101
second_and_fourth = 0b001010
every = 0b011111
one_on_two = 0b100000
date = models.DateField(
_('date'), help_text=_('date of the first diffusion'),
)
time = models.TimeField(
_('time'), help_text=_('start time'),
)
timezone = models.CharField(
_('timezone'),
default=tz.get_current_timezone, max_length=100,
choices=[(x, x) for x in pytz.all_timezones],
help_text=_('timezone used for the date')
)
duration = models.TimeField(
_('duration'),
help_text=_('regular duration'),
)
frequency = models.SmallIntegerField(
_('frequency'),
choices=[(int(y), {
'ponctual': _('ponctual'),
'first': _('1st {day} of the month'),
'second': _('2nd {day} of the month'),
'third': _('3rd {day} of the month'),
'fourth': _('4th {day} of the month'),
'last': _('last {day} of the month'),
'first_and_third': _('1st and 3rd {day} of the month'),
'second_and_fourth': _('2nd and 4th {day} of the month'),
'every': _('every {day}'),
'one_on_two': _('one {day} on two'),
}[x]) for x, y in Frequency.__members__.items()],
)
class Meta:
verbose_name = _('Schedule')
verbose_name_plural = _('Schedules')
def __str__(self):
return '{} - {}, {}'.format(
self.program.title, self.get_frequency_verbose(),
self.time.strftime('%H:%M')
)
def save_rerun(self, *args, **kwargs):
self.program = self.initial.program
self.duration = self.initial.duration
self.frequency = self.initial.frequency
@cached_property
def tz(self):
""" Pytz timezone of the schedule. """
import pytz
return pytz.timezone(self.timezone)
@cached_property
def start(self):
""" Datetime of the start (timezone unaware) """
return tz.datetime.combine(self.date, self.time)
@cached_property
def end(self):
""" Datetime of the end """
return self.start + utils.to_timedelta(self.duration)
def get_frequency_verbose(self):
""" Return frequency formated for display """
from django.template.defaultfilters import date
return self.get_frequency_display().format(
day=date(self.date, 'l')
).capitalize()
# initial cached data
__initial = None
def changed(self, fields=['date', 'duration', 'frequency', 'timezone']):
initial = self._Schedule__initial
if not initial:
return
this = self.__dict__
for field in fields:
if initial.get(field) != this.get(field):
return True
return False
def normalize(self, date):
"""
Return a datetime set to schedule's time for the provided date,
handling timezone (based on schedule's timezone).
"""
date = tz.datetime.combine(date, self.time)
return self.tz.normalize(self.tz.localize(date))
def dates_of_month(self, date):
""" Return normalized diffusion dates of provided date's month. """
if self.frequency == Schedule.Frequency.ponctual:
return []
sched_wday, freq = self.date.weekday(), self.frequency
date = date.replace(day=1)
# last of the month
if freq == Schedule.Frequency.last:
date = date.replace(
day=calendar.monthrange(date.year, date.month)[1])
date_wday = date.weekday()
# end of month before the wanted weekday: move one week back
if date_wday < sched_wday:
date -= tz.timedelta(days=7)
date += tz.timedelta(days=sched_wday - date_wday)
return [self.normalize(date)]
# move to the first day of the month that matches the schedule's weekday
# check on SO#3284452 for the formula
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:
# - adjust date with modulo 14 (= 2 weeks in days)
# - there are max 3 "weeks on two" per month
if (date - self.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))
return [self.normalize(date) for date in dates if date.month == month]
def _exclude_existing_date(self, dates):
from .episode import Diffusion
saved = set(Diffusion.objects.filter(start__in=dates)
.values_list('start', flat=True))
return [date for date in dates if date not in saved]
def diffusions_of_month(self, date):
"""
Get episodes and diffusions for month of provided date, including
reruns.
:returns: tuple([Episode], [Diffusion])
"""
from .episode import Diffusion, Episode
if self.initial is not None or \
self.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()]
dates = OrderedDict((date, None) for date in self.dates_of_month(date))
dates.update([(rerun.normalize(date.date() + delta), date)
for date in dates.keys() for rerun, delta in reruns])
# remove dates corresponding to existing diffusions
saved = set(Diffusion.objects.filter(start__in=dates.keys(),
program=self.program,
schedule=self)
.values_list('start', flat=True))
# make diffs
duration = utils.to_timedelta(self.duration)
diffusions = {}
episodes = {}
for date, initial in dates.items():
if date in saved:
continue
if initial is None:
episode = Episode.from_page(self.program, date=date)
episode.date = date
episodes[date] = episode
else:
episode = episodes[initial]
initial = diffusions[initial]
diffusions[date] = Diffusion(
episode=episode, schedule=self, type=Diffusion.TYPE_ON_AIR,
initial=initial, start=date, end=date+duration
)
return episodes.values(), diffusions.values()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# TODO/FIXME: use validators?
if self.initial is not None and self.date > self.date:
raise ValueError('initial must be later')
class Stream(models.Model):
"""When there are no program scheduled, it is possible to play sounds in
order to avoid blanks. A Stream is a Program that plays this role, and
whose linked to a Stream.
"""
When there are no program scheduled, it is possible to play sounds
in order to avoid blanks. A Stream is a Program that plays this role,
and whose linked to a Stream.
All sounds that are marked as good and that are under the related
program's archive dir are elligible for the sound's selection.
"""
program = models.ForeignKey(
Program,
models.CASCADE,
verbose_name=_("related program"),
Program, models.CASCADE,
verbose_name=_('related program'),
)
delay = models.TimeField(
_("delay"),
blank=True,
null=True,
help_text=_("minimal delay between two sound plays"),
_('delay'), blank=True, null=True,
help_text=_('minimal delay between two sound plays')
)
begin = models.TimeField(
_("begin"),
blank=True,
null=True,
help_text=_("used to define a time range this stream is " "played"),
_('begin'), blank=True, null=True,
help_text=_('used to define a time range this stream is'
'played')
)
end = models.TimeField(
_("end"),
blank=True,
null=True,
help_text=_("used to define a time range this stream is " "played"),
_('end'),
blank=True, null=True,
help_text=_('used to define a time range this stream is'
'played')
)

View File

@ -1,96 +0,0 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q
from django.utils.translation import gettext_lazy as _
from .program import Program
__all__ = (
"Rerun",
"RerunQuerySet",
)
class RerunQuerySet(models.QuerySet):
"""Queryset for Rerun (sub)classes."""
def station(self, station=None, id=None):
return self.filter(program__station=station) if id is None else self.filter(program__station__id=id)
def program(self, program=None, id=None):
return self.filter(program=program) if id is None else self.filter(program__id=id)
def rerun(self):
return self.filter(initial__isnull=False)
def initial(self):
return self.filter(initial__isnull=True)
class Rerun(models.Model):
"""Abstract model offering rerun facilities.
Assume `start` is a datetime field or attribute implemented by
subclass.
"""
program = models.ForeignKey(
Program,
models.CASCADE,
db_index=True,
verbose_name=_("related program"),
)
initial = models.ForeignKey(
"self",
models.SET_NULL,
related_name="rerun_set",
verbose_name=_("rerun of"),
limit_choices_to=Q(initial__isnull=True) & Q(program=F("program")),
blank=True,
null=True,
db_index=True,
)
objects = RerunQuerySet.as_manager()
class Meta:
abstract = True
@property
def is_initial(self):
return self.initial is None
@property
def is_rerun(self):
return self.initial is not None
def get_initial(self):
"""Return the initial schedule (self or initial)"""
return self if self.initial is None else self.initial.get_initial()
def clean(self):
super().clean()
if hasattr(self, "start") and self.initial is not None and self.initial.start >= self.start:
raise ValidationError({"initial": _("rerun must happen after original")})
def save_rerun(self):
if not self.program_id:
self.program = self.initial.program
if self.program != self.initial.program:
raise ValidationError("Program for the rerun should be the same")
def save_initial(self):
pass
def save(self, *args, **kwargs):
if self.initial is not None:
self.initial = self.initial.get_initial()
if self.initial == self:
self.initial = None
if self.is_rerun:
self.save_rerun()
else:
self.save_initial()
super().save(*args, **kwargs)

View File

@ -1,226 +0,0 @@
import calendar
import zoneinfo
from django.db import models
from django.utils import timezone as tz
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from aircox import utils
from .rerun import Rerun
__all__ = ("Schedule",)
def current_timezone_key():
return tz.get_current_timezone().key
# ? BIG FIXME: self.date is still used as datetime
class Schedule(Rerun):
"""A Schedule defines time slots of programs' diffusions.
It can be an initial run or a rerun (in such case it is linked to
the related schedule).
"""
# Frequency for schedules. Basically, it is a mask of bits where each bit
# is a week. Bits > rank 5 are used for special schedules.
# Important: the first week is always the first week where the weekday of
# the schedule is present.
# For ponctual programs, there is no need for a schedule, only a diffusion
class Frequency(models.IntegerChoices):
ponctual = 0b000000, _("ponctual")
first = 0b000001, _("1st {day} of the month")
second = 0b000010, _("2nd {day} of the month")
third = 0b000100, _("3rd {day} of the month")
fourth = 0b001000, _("4th {day} of the month")
last = 0b010000, _("last {day} of the month")
first_and_third = 0b000101, _("1st and 3rd {day} of the month")
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"),
help_text=_("date of the first diffusion"),
)
time = models.TimeField(
_("time"),
help_text=_("start time"),
)
timezone = models.CharField(
_("timezone"),
default=current_timezone_key,
max_length=100,
choices=sorted([(x, x) for x in zoneinfo.available_timezones()]),
help_text=_("timezone used for the date"),
)
duration = models.TimeField(
_("duration"),
help_text=_("regular duration"),
)
frequency = models.SmallIntegerField(
_("frequency"),
choices=Frequency.choices,
)
class Meta:
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,
self.get_frequency_display(),
self.time.strftime("%H:%M"),
)
def save_rerun(self):
super().save_rerun()
self.duration = self.initial.duration
self.frequency = self.initial.frequency
@cached_property
def tz(self):
"""Pytz timezone of the schedule."""
return zoneinfo.ZoneInfo(self.timezone)
@cached_property
def start(self):
"""Datetime of the start (timezone unaware)"""
return tz.datetime.combine(self.date, self.time)
@cached_property
def end(self):
"""Datetime of the end."""
return self.start + utils.to_timedelta(self.duration)
def get_frequency_display(self):
"""Return frequency formated for display."""
from django.template.defaultfilters import date
return self._get_FIELD_display(self._meta.get_field("frequency")).format(day=date(self.date, "l")).capitalize()
def normalize(self, date):
"""Return a datetime set to schedule's time for the provided date,
handling timezone (based on schedule's timezone)."""
date = tz.datetime.combine(date, self.time)
return date.replace(tzinfo=self.tz)
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 = sched_date.weekday()
date = date.replace(day=1)
# last of the month
if frequency == Schedule.Frequency.last:
date = date.replace(day=calendar.monthrange(date.year, date.month)[1])
date_wday = date.weekday()
# end of month before the wanted weekday: move one week back
if date_wday < sched_wday:
date -= tz.timedelta(days=7)
date += tz.timedelta(days=sched_wday - date_wday)
return [self.normalize(date)]
# move to the first day of the month that matches the schedule's
# weekday. Check on SO#3284452 for the formula
date_wday, month = date.weekday(), date.month
date += tz.timedelta(days=(7 if date_wday > sched_wday else 0) - date_wday + sched_wday)
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 - 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 frequency & (0b1 << week))
return [self.normalize(date) for date in dates if date.month == month]
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 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 - sched_date) for rerun in self.rerun_set.all()]
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
)
# remove dates corresponding to existing diffusions
saved = set(
Diffusion.objects.filter(start__in=dates.keys(), program=self.program, schedule=self).values_list(
"start", flat=True
)
)
# make diffs
duration = utils.to_timedelta(self.duration)
diffusions = {}
episodes = {}
for date, initial in dates.items():
if date in saved:
continue
if initial is None:
episode = Episode.from_page(self.program, date=date)
episode.date = date
episodes[date] = episode
else:
episode = episodes[initial]
initial = diffusions[initial]
diffusions[date] = Diffusion(
episode=episode,
schedule=self,
type=Diffusion.TYPE_ON_AIR,
initial=initial,
start=date,
end=date + duration,
)
return episodes.values(), diffusions.values()

View File

@ -1,27 +1,13 @@
import logging
import os
import shutil
import pytz
from django.conf import settings as conf
from django.contrib.auth.models import Group, Permission, User
from django.contrib.auth.models import User, Group, Permission
from django.db import transaction
from django.db.models import signals, F
from django.db.models.functions import Concat, Substr
from django.db.models import F, signals
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")
from .. import settings, utils
from . import Diffusion, Episode, Page, Program, Schedule
# Add a default group to a user when it is created. It also assigns a list
@ -32,72 +18,67 @@ logger = logging.getLogger("aircox")
#
@receiver(signals.post_save, sender=User)
def user_default_groups(sender, instance, created, *args, **kwargs):
"""Set users to different default groups."""
"""
Set users to different default groups
"""
if not created or instance.is_superuser:
return
for group_name, permissions in settings.DEFAULT_USER_GROUPS.items():
for group_name, permissions in settings.AIRCOX_DEFAULT_USER_GROUPS.items():
if instance.groups.filter(name=group_name).count():
continue
group, created = Group.objects.get_or_create(name=group_name)
if created and permissions:
for codename in permissions:
permission = Permission.objects.filter(codename=codename).first()
permission = Permission.objects.filter(
codename=codename).first()
if permission:
group.permissions.add(permission)
group.save()
instance.groups.add(group)
# ---- page
@receiver(signals.post_save, sender=Page)
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)
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)
# ---- program
@receiver(signals.post_save, sender=Program)
def program_post_save__clean_later_episodes(sender, instance, created, *args, **kwargs):
def program_post_save(sender, instance, created, *args, **kwargs):
"""
Clean-up later diffusions when a program becomes inactive
"""
if not instance.active:
Diffusion.objects.program(instance).after(tz.now()).delete()
Episode.objects.parent(instance).filter(diffusion__isnull=True).delete()
Diffusion.object.program(instance).after(tz.now()).delete()
Episode.object.parent(instance).filter(diffusion__isnull=True) \
.delete()
cover = getattr(instance, '__initial_cover', None)
if cover is None and instance.cover is not None:
Episode.objects.parent(instance) \
.filter(cover__isnull=True) \
.update(cover=instance.cover)
@receiver(signals.post_save, sender=Program)
def program_post_save__mv_sounds(sender, instance, created, *args, **kwargs):
path_ = getattr(instance, "__initial_path", None)
if path_ in (None, instance.path):
return
abspath = path_ and os.path.join(conf.MEDIA_ROOT, path_)
if os.path.exists(abspath) and not os.path.exists(instance.abspath):
logger.info(
f"program #{instance.pk}'s dir changed to {instance.title} - update it.", instance.id, instance.title
)
shutil.move(abspath, instance.abspath)
Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_))))
# ---- schedule
@receiver(signals.pre_save, sender=Schedule)
def schedule_pre_save(sender, instance, *args, **kwargs):
if getattr(instance, "pk") is not None and "raw" not in kwargs:
if getattr(instance, 'pk') is not None:
instance._initial = Schedule.objects.get(pk=instance.pk)
@receiver(signals.post_save, sender=Schedule)
def schedule_post_save(sender, instance, created, *args, **kwargs):
"""Handles Schedule's time, duration and timezone changes and update
corresponding diffusions accordingly."""
initial = getattr(instance, "_initial", None)
if not initial or (
(instance.time, instance.duration, instance.timezone) == (initial.time, initial.duration, initial.timezone)
):
"""
Handles Schedule's time, duration and timezone changes and update
corresponding diffusions accordingly.
"""
initial = getattr(instance, '_initial', None)
if not initial or ((instance.time, instance.duration, instance.timezone) ==
(initial.time, initial.duration, initial.timezone)):
return
today = tz.datetime.today()
@ -113,25 +94,14 @@ def schedule_post_save(sender, instance, created, *args, **kwargs):
@receiver(signals.pre_delete, sender=Schedule)
def schedule_pre_delete(sender, instance, *args, **kwargs):
"""Delete later corresponding diffusion to a changed schedule."""
""" 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, episodesound__isnull=True).delete()
Episode.objects.filter(diffusion__isnull=True, content__isnull=True,
sound__isnull=True).delete()
# ---- diffusion
@receiver(signals.post_delete, sender=Diffusion)
def diffusion_post_delete(sender, instance, *args, **kwargs):
Episode.objects.filter(diffusion__isnull=True, content__isnull=True, episodesound__isnull=True).delete()
Episode.objects.filter(diffusion__isnull=True, content__isnull=True,
sound__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,195 +1,260 @@
from datetime import date
from enum import IntEnum
import logging
import os
import re
from django.conf import settings as conf
from django.conf import settings as main_settings
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 aircox import utils
from aircox.conf import settings
from taggit.managers import TaggableManager
from aircox import settings
from .program import Program
from .file import File, FileQuerySet
from .episode import Episode
__all__ = ("Sound", "SoundQuerySet")
logger = logging.getLogger('aircox')
class SoundQuerySet(FileQuerySet):
def downloadable(self):
"""Return sounds available as podcasts."""
return self.filter(is_downloadable=True)
__all__ = ['Sound', 'SoundQuerySet', 'Track']
def broadcast(self):
"""Return sounds that are archives."""
return self.filter(broadcast=True, is_removed=False)
def playlist(self, order_by="file"):
"""Return files absolute paths as a flat list (exclude sound without
path)."""
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)
def archive(self):
""" Return sounds that are archives """
return self.filter(type=Sound.TYPE_ARCHIVE)
def paths(self, archive=True, order_by=True):
"""
Return paths as a flat list (exclude sound without path).
If `order_by` is True, order by path.
"""
if archive:
self = self.archive()
if order_by:
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)
]
self = self.order_by('path')
return self.filter(path__isnull=False).values_list('path', flat=True)
def search(self, query):
return self.filter(
Q(name__icontains=query) | Q(path__icontains=query) |
Q(program__title__icontains=query) |
Q(episode__title__icontains=query)
)
class Sound(File):
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'),
)
# FIXME: url() does not use the same directory than here
# should we use FileField for more reliability?
path = models.FilePathField(
_('file'),
path=settings.AIRCOX_PROGRAMS_DIR,
match=r'(' + '|'.join(settings.AIRCOX_SOUND_FILE_EXT)
.replace('.', r'\.') + ')$',
recursive=True, max_length=255,
blank=True, null=True, unique=True,
)
#embed = models.TextField(
# _('embed'),
# blank=True, null=True,
# help_text=_('HTML code to embed a sound from an external plateform'),
#)
duration = models.TimeField(
_("duration"),
blank=True,
null=True,
help_text=_("duration of the sound"),
_('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,
_('good quality'), help_text=_('sound meets quality requirements'),
blank=True, null=True
)
is_downloadable = models.BooleanField(
_("downloadable"),
help_text=_("Sound can be downloaded by website visitors."),
is_public = models.BooleanField(
_('public'), help_text=_('if it can be podcasted from the server'),
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 file")
verbose_name_plural = _("Sound files")
_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>.*)$"
)
@classmethod
def read_path(cls, 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 = 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])
name = info.get("name")
info["name"] = name and cls._as_name(name) or basename
else:
info = {"name": basename}
return info
@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"))
)
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,
)
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 read_metadata(self):
import mutagen
meta = mutagen.File(self.file.path)
metadata = {"duration": utils.seconds_to_time(meta.info.length), "meta": meta}
path_info = self.read_path(self.file.path)
if name := path_info.get("name"):
metadata["name"] = name
return metadata
verbose_name = _('Sound')
verbose_name_plural = _('Sounds')
def __str__(self):
infos = ""
if self.is_removed:
infos += _("removed")
if infos:
return f"{self.file.name} [{infos}]"
return f"{self.file.name}"
return '/'.join(self.path.split('/')[-3:])
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()
self.__check_name()
super().save(*args, **kwargs)
def url(self):
""" Return an url to the file. """
path = self.path.replace(main_settings.MEDIA_ROOT, '', 1)
return (main_settings.MEDIA_URL + path).replace('//','/')
# TODO: rename get_file_mtime(self)
def get_mtime(self):
"""
Get the last modification date from file
"""
mtime = os.stat(self.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.path)
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.
"""
if not self.file_exists():
if self.type == self.TYPE_REMOVED:
return
logger.info('sound %s: has been removed', self.path)
self.type = self.TYPE_REMOVED
return True
# not anymore removed
changed = False
if self.type == self.TYPE_REMOVED and self.program:
changed = True
self.type = self.TYPE_ARCHIVE \
if self.path.startswith(self.program.archives_path) else \
self.TYPE_EXCERPT
# 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.info('sound %s: m_time has changed. Reset quality info',
self.path)
return True
return changed
def __check_name(self):
if not self.name and self.path:
# FIXME: later, remove date?
self.name = os.path.basename(self.path)
self.name = os.path.splitext(self.name)[0]
self.name = self.name.replace('_', ' ')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__check_name()
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)
tags = TaggableManager(verbose_name=_('tags'), blank=True,)
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

@ -1,17 +1,24 @@
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 .. import settings
__all__ = ("Station", "StationQuerySet", "Port")
__all__ = ['Station', 'StationQuerySet', 'Port']
class StationQuerySet(models.QuerySet):
def default(self, station=None):
"""Return station model instance, using defaults or given one."""
"""
Return station model instance, using defaults or
given one.
"""
if station is None:
return self.order_by("-default", "pk").first()
return self.order_by('-default', 'pk').first()
return self.filter(pk=station).first()
def active(self):
@ -19,73 +26,62 @@ class StationQuerySet(models.QuerySet):
class Station(models.Model):
"""Represents a radio station, to which multiple programs are attached and
that is used as the top object for everything.
"""
Represents a radio station, to which multiple programs are attached
and that is used as the top object for everything.
A Station holds controllers for the audio stream generation too.
Theses are set up when needed (at the first access to these
elements) then cached.
Theses are set up when needed (at the first access to these elements)
then cached.
"""
name = models.CharField(_("name"), max_length=64)
slug = models.SlugField(_("slug"), max_length=64, unique=True)
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,
help_text=_("use this station as the main one."),
_('default station'),
default=True,
help_text=_('use this station as the main one.')
)
active = models.BooleanField(
_("active"),
_('active'),
default=True,
help_text=_("whether this station is still active or not."),
help_text=_('whether this station is still active or not.')
)
logo = FilerImageField(
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("Logo"),
on_delete=models.SET_NULL, null=True, blank=True,
verbose_name=_('Logo'),
)
hosts = models.TextField(
_("website's urls"),
max_length=512,
null=True,
blank=True,
help_text=_("specify one domain per line, without 'http://' prefix"),
_("website's urls"), max_length=512, null=True, blank=True,
help_text=_('specify one url per line')
)
audio_streams = models.TextField(
_("audio streams"),
max_length=2048,
null=True,
blank=True,
help_text=_("Audio streams urls used by station's player. One url a line."),
_("audio streams"), max_length=2048, null=True, blank=True,
help_text=_("Audio streams urls used by station's player. One url "
"a line.")
)
default_cover = FilerImageField(
on_delete=models.SET_NULL,
verbose_name=_("Default pages' cover"),
null=True,
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.")
verbose_name=_('Default pages\' cover'), null=True, blank=True,
related_name='+',
)
objects = StationQuerySet.as_manager()
@cached_property
def streams(self):
"""Audio streams as list of urls."""
return self.audio_streams.split("\n") if self.audio_streams else []
def __str__(self):
return self.name
def save(self, make_sources=True, *args, **kwargs):
if not self.path:
self.path = os.path.join(settings.AIRCOX_CONTROLLERS_WORKING_DIR,
self.slug.replace('-', '_'))
if self.default:
qs = Station.objects.filter(default=True)
if self.pk is not None:
@ -97,20 +93,22 @@ class Station(models.Model):
class PortQuerySet(models.QuerySet):
def active(self, value=True):
"""Active ports."""
""" Active ports """
return self.filter(active=value)
def output(self):
"""Filter in output ports."""
""" Filter in output ports """
return self.filter(direction=Port.DIRECTION_OUTPUT)
def input(self):
"""Fitler in input ports."""
""" Fitler in input ports """
return self.filter(direction=Port.DIRECTION_INPUT)
class Port(models.Model):
"""Represent an audio input/output for the audio stream generation.
"""
Represent an audio input/output for the audio stream
generation.
You might want to take a look to LiquidSoap's documentation
for the options available for each kind of input/output.
@ -118,13 +116,10 @@ class Port(models.Model):
Some port types may be not available depending on the
direction of the port.
"""
DIRECTION_INPUT = 0x00
DIRECTION_OUTPUT = 0x01
DIRECTION_CHOICES = (
(DIRECTION_INPUT, _("input")),
(DIRECTION_OUTPUT, _("output")),
)
DIRECTION_CHOICES = ((DIRECTION_INPUT, _('input')),
(DIRECTION_OUTPUT, _('output')))
TYPE_JACK = 0x00
TYPE_ALSA = 0x01
@ -134,28 +129,27 @@ class Port(models.Model):
TYPE_HTTPS = 0x05
TYPE_FILE = 0x06
TYPE_CHOICES = (
(TYPE_JACK, "jack"),
(TYPE_ALSA, "alsa"),
(TYPE_PULSEAUDIO, "pulseaudio"),
(TYPE_ICECAST, "icecast"),
(TYPE_HTTP, "http"),
(TYPE_HTTPS, "https"),
(TYPE_FILE, _("file")),
(TYPE_JACK, 'jack'), (TYPE_ALSA, 'alsa'),
(TYPE_PULSEAUDIO, 'pulseaudio'), (TYPE_ICECAST, 'icecast'),
(TYPE_HTTP, 'http'), (TYPE_HTTPS, 'https'),
(TYPE_FILE, _('file'))
)
station = models.ForeignKey(Station, models.CASCADE, verbose_name=_("station"))
direction = models.SmallIntegerField(_("direction"), choices=DIRECTION_CHOICES)
type = models.SmallIntegerField(_("type"), choices=TYPE_CHOICES)
active = models.BooleanField(_("active"), default=True, help_text=_("this port is active"))
station = models.ForeignKey(
Station, models.CASCADE, verbose_name=_('station'))
direction = models.SmallIntegerField(
_('direction'), choices=DIRECTION_CHOICES)
type = models.SmallIntegerField(_('type'), choices=TYPE_CHOICES)
active = models.BooleanField(
_('active'), default=True,
help_text=_('this port is active')
)
settings = models.TextField(
_("port settings"),
help_text=_(
"list of comma separated params available; "
"this is put in the output config file as raw code; "
"plugin related"
),
blank=True,
null=True,
_('port settings'),
help_text=_('list of comma separated params available; '
'this is put in the output config file as raw code; '
'plugin related'),
blank=True, null=True
)
objects = PortQuerySet.as_manager()
@ -163,20 +157,28 @@ class Port(models.Model):
def __str__(self):
return "{direction}: {type} #{id}".format(
direction=self.get_direction_display(),
type=self.get_type_display(),
id=self.pk or "",
type=self.get_type_display(), id=self.pk or ''
)
def is_valid_type(self):
"""Return True if the type is available for the given direction."""
"""
Return True if the type is available for the given direction.
"""
if self.direction == self.DIRECTION_INPUT:
return self.type not in (self.TYPE_ICECAST, self.TYPE_FILE)
return self.type not in (
self.TYPE_ICECAST, self.TYPE_FILE
)
return self.type not in (self.TYPE_HTTP, self.TYPE_HTTPS)
return self.type not in (
self.TYPE_HTTP, self.TYPE_HTTPS
)
def save(self, *args, **kwargs):
if not self.is_valid_type():
raise ValueError("port type is not allowed with the given port direction")
raise ValueError(
"port type is not allowed with the given port direction"
)
return super().save(*args, **kwargs)

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