diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 724fe73..68db793 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,17 +12,11 @@ repos: args: - --line-length=79 - --exclude="""\.git|\.__pycache__|venv|_build|buck-out|build|dist""" -- repo: https://github.com/PyCQA/autoflake.git - rev: v2.0.2 - hooks: - - id: autoflake - args: - - --remove-all-unused-imports - repo: https://github.com/PyCQA/flake8.git rev: 6.0.0 hooks: - id: flake8 - exclude: instance/sample_settings.py + exclude: instance/settings/ - repo: https://github.com/PyCQA/docformatter.git rev: v1.5.1 hooks: diff --git a/aircox/conf.py b/aircox/conf.py new file mode 100755 index 0000000..c54f8be --- /dev/null +++ b/aircox/conf.py @@ -0,0 +1,180 @@ +import os + +import inspect + +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 + "change_program", + "change_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_ARCHIVES_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.""" + + +settings = Settings("AIRCOX") diff --git a/aircox/management/commands/archiver.py b/aircox/management/commands/archiver.py index e47e039..42531c0 100644 --- a/aircox/management/commands/archiver.py +++ b/aircox/management/commands/archiver.py @@ -9,7 +9,7 @@ from argparse import RawTextHelpFormatter from django.core.management.base import BaseCommand from django.utils import timezone as tz -import aircox.settings as settings +from aircox.conf import settings from aircox.models import Log from aircox.models.log import LogArchiver @@ -29,9 +29,9 @@ class Command(BaseCommand): "-a", "--age", type=int, - default=settings.AIRCOX_LOGS_ARCHIVES_AGE, + default=settings.LOGS_ARCHIVES_AGE, help="minimal age in days of logs to archive. Default is " - "settings.AIRCOX_LOGS_ARCHIVES_AGE", + "settings.LOGS_ARCHIVES_AGE", ) group.add_argument( "-k", diff --git a/aircox/management/commands/import_playlist.py b/aircox/management/commands/import_playlist.py index e9689c4..41707f0 100755 --- a/aircox/management/commands/import_playlist.py +++ b/aircox/management/commands/import_playlist.py @@ -2,9 +2,9 @@ sound. Playlists are in CSV format, where columns are separated with a -'{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} +'{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} If 'minutes' or 'seconds' are given, position will be expressed as timed position, instead of position in playlist. @@ -16,7 +16,7 @@ from argparse import RawTextHelpFormatter from django.core.management.base import BaseCommand -from aircox import settings +from aircox.conf import settings from aircox.models import Sound, Track __doc__ = __doc__.format(settings=settings) @@ -61,9 +61,9 @@ class PlaylistImport: ) 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, + fieldnames=settings.IMPORT_PLAYLIST_CSV_COLS, + delimiter=settings.IMPORT_PLAYLIST_CSV_DELIMITER, + quotechar=settings.IMPORT_PLAYLIST_CSV_TEXT_QUOTE, ) ) @@ -80,7 +80,7 @@ class PlaylistImport: ) return - maps = settings.AIRCOX_IMPORT_PLAYLIST_CSV_COLS + maps = settings.IMPORT_PLAYLIST_CSV_COLS tracks = [] logger.info("parse csv file " + self.path) diff --git a/aircox/management/commands/sounds_monitor.py b/aircox/management/commands/sounds_monitor.py index dfd436f..9990b30 100755 --- a/aircox/management/commands/sounds_monitor.py +++ b/aircox/management/commands/sounds_monitor.py @@ -20,7 +20,7 @@ Where: To check quality of files, call the command sound_quality_check using the -parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires +parameters given by the setting SOUND_QUALITY. This script requires Sox (and soxi). """ import atexit @@ -33,7 +33,7 @@ from argparse import RawTextHelpFormatter from django.core.management.base import BaseCommand from watchdog.observers import Observer -from aircox import settings +from aircox.conf import settings from aircox.management.sound_file import SoundFile from aircox.management.sound_monitor import MonitorHandler from aircox.models import Program, Sound @@ -67,12 +67,12 @@ class Command(BaseCommand): logger.info("#%d %s", program.id, program.title) self.scan_for_program( program, - settings.AIRCOX_SOUND_ARCHIVES_SUBDIR, + settings.SOUND_ARCHIVES_SUBDIR, type=Sound.TYPE_ARCHIVE, ) self.scan_for_program( program, - settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, + settings.SOUND_EXCERPTS_SUBDIR, type=Sound.TYPE_EXCERPT, ) dirs.append(os.path.join(program.abspath)) @@ -91,7 +91,7 @@ class Command(BaseCommand): # sounds in directory for path in os.listdir(subdir): path = os.path.join(subdir, path) - if not path.endswith(settings.AIRCOX_SOUND_FILE_EXT): + if not path.endswith(settings.SOUND_FILE_EXT): continue sound_file = SoundFile(path) @@ -115,12 +115,12 @@ class Command(BaseCommand): """Run in monitor mode.""" with futures.ThreadPoolExecutor() as pool: archives_handler = MonitorHandler( - settings.AIRCOX_SOUND_ARCHIVES_SUBDIR, + settings.SOUND_ARCHIVES_SUBDIR, pool, type=Sound.TYPE_ARCHIVE, ) excerpts_handler = MonitorHandler( - settings.AIRCOX_SOUND_EXCERPTS_SUBDIR, + settings.SOUND_EXCERPTS_SUBDIR, pool, type=Sound.TYPE_EXCERPT, ) @@ -128,12 +128,12 @@ class Command(BaseCommand): observer = Observer() observer.schedule( archives_handler, - settings.AIRCOX_PROGRAMS_DIR_ABS, + settings.PROGRAMS_DIR_ABS, recursive=True, ) observer.schedule( excerpts_handler, - settings.AIRCOX_PROGRAMS_DIR_ABS, + settings.PROGRAMS_DIR_ABS, recursive=True, ) observer.start() diff --git a/aircox/management/sound_file.py b/aircox/management/sound_file.py index b10c2f8..adba6db 100644 --- a/aircox/management/sound_file.py +++ b/aircox/management/sound_file.py @@ -17,7 +17,7 @@ Where: Sound Quality ============= To check quality of files, call the command sound_quality_check using the -parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires +parameters given by the setting SOUND_QUALITY. This script requires Sox (and soxi). """ import logging @@ -175,7 +175,7 @@ class SoundFile: at = tz.datetime( year, month, day, pi.get("hour", 0), pi.get("minute", 0) ) - at = tz.get_current_timezone().localize(at) + at = tz.make_aware(at) else: at = date(year, month, day) diff --git a/aircox/management/sound_monitor.py b/aircox/management/sound_monitor.py index ed4b43d..dde7b26 100644 --- a/aircox/management/sound_monitor.py +++ b/aircox/management/sound_monitor.py @@ -20,7 +20,7 @@ Where: To check quality of files, call the command sound_quality_check using the -parameters given by the setting AIRCOX_SOUND_QUALITY. This script requires +parameters given by the setting SOUND_QUALITY. This script requires Sox (and soxi). """ import logging @@ -29,7 +29,7 @@ from datetime import datetime, timedelta from watchdog.events import PatternMatchingEventHandler -from aircox import settings +from aircox.conf import settings from aircox.models import Sound from .sound_file import SoundFile @@ -121,7 +121,7 @@ class MonitorHandler(PatternMatchingEventHandler): def __init__(self, subdir, pool, **sync_kw): """ :param str subdir: sub-directory in program dirs to monitor \ - (AIRCOX_SOUND_ARCHIVES_SUBDIR or AIRCOX_SOUND_EXCERPTS_SUBDIR); + (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`; @@ -132,7 +132,7 @@ class MonitorHandler(PatternMatchingEventHandler): patterns = [ "*/{}/*{}".format(self.subdir, ext) - for ext in settings.AIRCOX_SOUND_FILE_EXT + for ext in settings.SOUND_FILE_EXT ] super().__init__(patterns=patterns, ignore_directories=True) diff --git a/aircox/models/__init__.py b/aircox/models/__init__.py index 4e1419f..b2d2ba9 100644 --- a/aircox/models/__init__.py +++ b/aircox/models/__init__.py @@ -1,4 +1,3 @@ -from . import signals from .article import Article from .episode import Diffusion, DiffusionQuerySet, Episode from .log import Log, LogArchiver, LogQuerySet diff --git a/aircox/models/episode.py b/aircox/models/episode.py index 397dae8..ab95c2d 100644 --- a/aircox/models/episode.py +++ b/aircox/models/episode.py @@ -7,7 +7,8 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from easy_thumbnails.files import get_thumbnailer -from aircox import settings, utils +from aircox.conf import settings +from aircox import utils from .page import Page from .program import ( @@ -70,18 +71,18 @@ class Episode(Page): @classmethod def get_default_title(cls, page, date): - return settings.AIRCOX_EPISODE_TITLE.format( + return settings.EPISODE_TITLE.format( program=page, - date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT), + date=date.strftime(settings.EPISODE_TITLE_DATE_FORMAT), ) @classmethod def get_init_kwargs_from(cls, page, date, title=None, **kwargs): """Get default Episode's title.""" title = ( - settings.AIRCOX_EPISODE_TITLE.format( + settings.EPISODE_TITLE.format( program=page, - date=date.strftime(settings.AIRCOX_EPISODE_TITLE_DATE_FORMAT), + date=date.strftime(settings.EPISODE_TITLE_DATE_FORMAT), ) if title is None else title diff --git a/aircox/models/log.py b/aircox/models/log.py index 199c4d9..77e6220 100644 --- a/aircox/models/log.py +++ b/aircox/models/log.py @@ -10,8 +10,9 @@ 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 settings +from aircox.conf import settings +__all__ = ("Settings", "settings") from .episode import Diffusion from .sound import Sound, Track from .station import Station @@ -251,7 +252,7 @@ class LogArchiver: @staticmethod def get_path(station, date): return os.path.join( - settings.AIRCOX_LOGS_ARCHIVES_DIR, + settings.LOGS_ARCHIVES_DIR_ABS, "{}_{}.log.gz".format(date.strftime("%Y%m%d"), station.pk), ) @@ -264,7 +265,7 @@ class LogArchiver: if not qs.exists(): return 0 - os.makedirs(settings.AIRCOX_LOGS_ARCHIVES_DIR, exist_ok=True) + os.makedirs(settings.LOGS_ARCHIVES_DIR_ABS, exist_ok=True) count = qs.count() logs = self.sort_logs(qs) diff --git a/aircox/models/program.py b/aircox/models/program.py index 9354237..c6a89a5 100644 --- a/aircox/models/program.py +++ b/aircox/models/program.py @@ -15,7 +15,8 @@ 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 settings, utils +from aircox import utils +from aircox.conf import settings from .page import Page, PageQuerySet from .station import Station @@ -77,9 +78,7 @@ class Program(Page): @property def path(self): """Return program's directory path.""" - return os.path.join( - settings.AIRCOX_PROGRAMS_DIR, self.slug.replace("-", "_") - ) + return os.path.join(settings.PROGRAMS_DIR, self.slug.replace("-", "_")) @property def abspath(self): @@ -88,11 +87,11 @@ class Program(Page): @property def archives_path(self): - return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR) + return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR) @property def excerpts_path(self): - return os.path.join(self.path, settings.AIRCOX_SOUND_ARCHIVES_SUBDIR) + return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR) def __init__(self, *kargs, **kwargs): super().__init__(*kargs, **kwargs) @@ -107,8 +106,8 @@ class Program(Page): We assume the path has been given in a previous time by this model (Program.path getter). """ - if path.startswith(settings.AIRCOX_PROGRAMS_DIR_ABS): - path = path.replace(settings.AIRCOX_PROGRAMS_DIR_ABS, "") + if path.startswith(settings.PROGRAMS_DIR_ABS): + path = path.replace(settings.PROGRAMS_DIR_ABS, "") while path[0] == "/": path = path[1:] path = path[: path.index("/")] diff --git a/aircox/models/signals.py b/aircox/models/signals.py index a9c9eae..7011deb 100755 --- a/aircox/models/signals.py +++ b/aircox/models/signals.py @@ -4,7 +4,8 @@ from django.db.models import signals from django.dispatch import receiver from django.utils import timezone as tz -from .. import settings, utils +from aircox import utils +from aircox.conf import settings from . import Diffusion, Episode, Page, Program, Schedule @@ -20,7 +21,7 @@ def user_default_groups(sender, instance, created, *args, **kwargs): if not created or instance.is_superuser: return - for group_name, permissions in settings.AIRCOX_DEFAULT_USER_GROUPS.items(): + for group_name, permissions in settings.DEFAULT_USER_GROUPS.items(): if instance.groups.filter(name=group_name).count(): continue diff --git a/aircox/models/sound.py b/aircox/models/sound.py index 31da7f3..d54ba09 100644 --- a/aircox/models/sound.py +++ b/aircox/models/sound.py @@ -8,7 +8,7 @@ from django.utils import timezone as tz from django.utils.translation import gettext_lazy as _ from taggit.managers import TaggableManager -from aircox import settings +from aircox.conf import settings from .episode import Episode from .program import Program @@ -123,9 +123,9 @@ class Sound(models.Model): def _upload_to(self, filename): subdir = ( - settings.AIRCOX_SOUND_ARCHIVES_SUBDIR + settings.SOUND_ARCHIVES_SUBDIR if self.type == self.TYPE_ARCHIVE - else settings.AIRCOX_SOUND_EXCERPTS_SUBDIR + else settings.SOUND_EXCERPTS_SUBDIR ) return os.path.join(self.program.path, subdir, filename) diff --git a/aircox/models/station.py b/aircox/models/station.py index 3dd3509..72f0e57 100644 --- a/aircox/models/station.py +++ b/aircox/models/station.py @@ -5,7 +5,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField -from .. import settings +from aircox.conf import settings __all__ = ("Station", "StationQuerySet", "Port") @@ -92,7 +92,7 @@ class Station(models.Model): def save(self, make_sources=True, *args, **kwargs): if not self.path: self.path = os.path.join( - settings.AIRCOX_CONTROLLERS_WORKING_DIR, + settings.CONTROLLERS_WORKING_DIR, self.slug.replace("-", "_"), ) diff --git a/aircox/settings.py b/aircox/settings.py old mode 100755 new mode 100644 index 2f45da6..3bfe2ab --- a/aircox/settings.py +++ b/aircox/settings.py @@ -1,125 +1,73 @@ -import os +import inspect -from django.conf import settings +from django.conf import settings as django_settings +from django.db import models -def ensure(key, default): - value = getattr(settings, key, default) - globals()[key] = value - return value +class Settings: + """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 -######################################################################## -# Global & misc -######################################################################## -# group to assign to users at their creation, along with the permissions -# to add to each group. -ensure( - "AIRCOX_DEFAULT_USER_GROUPS", - { - "radio hosts": ( - # TODO include content_type in order to avoid clash with potential - # extra applications - # aircox - "change_program", - "change_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", - ), - }, -) + Example: -# Directory for the programs data -AIRCOX_PROGRAMS_DIR = ensure("AIRCOX_PROGRAMS_DIR", "programs") -ensure( - "AIRCOX_PROGRAMS_DIR_ABS", - os.path.join(settings.MEDIA_ROOT, AIRCOX_PROGRAMS_DIR), -) + ``` + class MySettings(Settings): + a = 13 + b = 12 + my_settings = MySettings().load('MY_SETTINGS_KEY') + print(my_settings.a, my_settings.get('b')) + ``` -######################################################################## -# Programs & Episodes -######################################################################## -# default title for episodes -ensure("AIRCOX_EPISODE_TITLE", "{program.title} - {date}") -# date format in episode title (python's strftime) -ensure("AIRCOX_EPISODE_TITLE_DATE_FORMAT", "%-d %B %Y") + This will load values from django project settings. + """ -######################################################################## -# Logs & Archives -######################################################################## -# Directory where to save logs' archives -ensure( - "AIRCOX_LOGS_ARCHIVES_DIR", - os.path.join(settings.PROJECT_ROOT, "logs/archives"), -) -# In days, minimal age of a log before it is archived -ensure("AIRCOX_LOGS_ARCHIVES_AGE", 60) + 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 = django_settings + settings = getattr(module, key, None) + if settings: + self.update(settings) + return self -######################################################################## -# Sounds -######################################################################## -# Sub directory used for the complete episode sounds -ensure("AIRCOX_SOUND_ARCHIVES_SUBDIR", "archives") -# Sub directory used for the excerpts of the episode -ensure("AIRCOX_SOUND_EXCERPTS_SUBDIR", "excerpts") + def update(self, settings): + """Update self's values from provided settings. ``settings`` can be an + iterable of ``(key, value)``. -# Quality attributes passed to sound_quality_check from sounds_monitor -ensure( - "AIRCOX_SOUND_QUALITY", - { - "attribute": "RMS lev dB", - "range": (-18.0, -8.0), - "sample_length": 120, - }, -) + :param dict|Settings|iterable settings: value to update from. + """ + if isinstance(settings, (dict, Settings)): + settings = settings.items() + for key, value in settings: + if hasattr(self, key) and self.is_config_item(key, value): + setattr(self, key, value) -# Extension of sound files -ensure("AIRCOX_SOUND_FILE_EXT", (".ogg", ".flac", ".wav", ".mp3", ".opus")) + def get(self, key, default=None): + """Return settings' value for provided key.""" + return getattr(self, key, default) -# Tag sounds as deleted instead of deleting them when file has been removed -# from filesystem (sound monitoring) -ensure("AIRCOX_SOUND_KEEP_DELETED", False) + 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 - -######################################################################## -# Streamer & Controllers -######################################################################## -# Controllers working directory -ensure("AIRCOX_CONTROLLERS_WORKING_DIR", "/tmp/aircox") - - -######################################################################## -# Playlist import from CSV -######################################################################## -# Columns for CSV file -ensure( - "AIRCOX_IMPORT_PLAYLIST_CSV_COLS", - ("artist", "title", "minutes", "seconds", "tags", "info"), -) -# Column delimiter of csv text files -ensure("AIRCOX_IMPORT_PLAYLIST_CSV_DELIMITER", ";") -# Text delimiter of csv text files -ensure("AIRCOX_IMPORT_PLAYLIST_CSV_TEXT_QUOTE", '"') + 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 + if isinstance(self, models.Model) and key == "object": + return False + return True diff --git a/aircox/tests/management/__init__.py b/aircox/tests/management/__init__.py index d8f681a..e69de29 100644 --- a/aircox/tests/management/__init__.py +++ b/aircox/tests/management/__init__.py @@ -1,15 +0,0 @@ -from .sound_file import SoundFileTestCase -from .sound_monitor import ( - ModifiedHandlerTestCase, - MonitorHandlerTestCase, - MoveHandlerTestCase, - NotifyHandlerTestCase, -) - -__all__ = ( - "SoundFileTestCase", - "NotifyHandlerTestCase", - "MoveHandlerTestCase", - "ModifiedHandlerTestCase", - "MonitorHandlerTestCase", -) diff --git a/aircox/tests/management/sound_file.py b/aircox/tests/management/sound_file.py deleted file mode 100644 index 1bb83c7..0000000 --- a/aircox/tests/management/sound_file.py +++ /dev/null @@ -1,103 +0,0 @@ -from datetime import timedelta - -from django.conf import settings as conf -from django.test import TestCase -from django.utils import timezone as tz - -from aircox import models -from aircox.management.sound_file import SoundFile - -__all__ = ("SoundFileTestCase",) - - -class SoundFileTestCase(TestCase): - path_infos = { - "test/20220101_10h13_1_sample_1.mp3": { - "year": 2022, - "month": 1, - "day": 1, - "hour": 10, - "minute": 13, - "n": 1, - "name": "Sample 1", - }, - "test/20220102_10h13_sample_2.mp3": { - "year": 2022, - "month": 1, - "day": 2, - "hour": 10, - "minute": 13, - "name": "Sample 2", - }, - "test/20220103_1_sample_3.mp3": { - "year": 2022, - "month": 1, - "day": 3, - "n": 1, - "name": "Sample 3", - }, - "test/20220104_sample_4.mp3": { - "year": 2022, - "month": 1, - "day": 4, - "name": "Sample 4", - }, - "test/20220105.mp3": { - "year": 2022, - "month": 1, - "day": 5, - "name": "20220105", - }, - } - subdir_prefix = "test" - sound_files = { - k: r - for k, r in ( - (path, SoundFile(conf.MEDIA_ROOT + "/" + path)) - for path in path_infos.keys() - ) - } - - def test_sound_path(self): - for path, sound_file in self.sound_files.items(): - self.assertEqual(path, sound_file.sound_path) - - def test_read_path(self): - for path, sound_file in self.sound_files.items(): - expected = self.path_infos[path] - result = sound_file.read_path(path) - # remove None values - result = {k: v for k, v in result.items() if v is not None} - self.assertEqual(expected, result, "path: {}".format(path)) - - def _setup_diff(self, program, info): - episode = models.Episode(program=program, title="test-episode") - at = tz.datetime( - **{ - k: info[k] - for k in ("year", "month", "day", "hour", "minute") - if info.get(k) - } - ) - at = tz.make_aware(at) - diff = models.Diffusion( - episode=episode, start=at, end=at + timedelta(hours=1) - ) - episode.save() - diff.save() - return diff - - def test_find_episode(self): - station = models.Station(name="test-station") - program = models.Program(station=station, title="test") - station.save() - program.save() - - for path, sound_file in self.sound_files.items(): - infos = sound_file.read_path(path) - diff = self._setup_diff(program, infos) - sound = models.Sound(program=diff.program, file=path) - result = sound_file.find_episode(sound, infos) - self.assertEquals(diff.episode, result) - - # TODO: find_playlist, sync diff --git a/aircox/tests/management/test_sound_file.py b/aircox/tests/management/test_sound_file.py new file mode 100644 index 0000000..0f855dd --- /dev/null +++ b/aircox/tests/management/test_sound_file.py @@ -0,0 +1,111 @@ +import pytest + +from datetime import timedelta + +from django.conf import settings as conf +from django.utils import timezone as tz + +from aircox import models +from aircox.management.sound_file import SoundFile + + +@pytest.fixture +def path_infos(): + return { + "test/20220101_10h13_1_sample_1.mp3": { + "year": 2022, + "month": 1, + "day": 1, + "hour": 10, + "minute": 13, + "n": 1, + "name": "Sample 1", + }, + "test/20220102_10h13_sample_2.mp3": { + "year": 2022, + "month": 1, + "day": 2, + "hour": 10, + "minute": 13, + "name": "Sample 2", + }, + "test/20220103_1_sample_3.mp3": { + "year": 2022, + "month": 1, + "day": 3, + "n": 1, + "name": "Sample 3", + }, + "test/20220104_sample_4.mp3": { + "year": 2022, + "month": 1, + "day": 4, + "name": "Sample 4", + }, + "test/20220105.mp3": { + "year": 2022, + "month": 1, + "day": 5, + "name": "20220105", + }, + } + + +@pytest.fixture +def sound_files(path_infos): + return { + k: r + for k, r in ( + (path, SoundFile(conf.MEDIA_ROOT + "/" + path)) + for path in path_infos.keys() + ) + } + + +def test_sound_path(sound_files): + for path, sound_file in sound_files.items(): + assert path == sound_file.sound_path + + +def test_read_path(path_infos, sound_files): + for path, sound_file in sound_files.items(): + expected = path_infos[path] + result = sound_file.read_path(path) + # remove None values + result = {k: v for k, v in result.items() if v is not None} + assert expected == result, "path: {}".format(path) + + +def _setup_diff(program, info): + episode = models.Episode(program=program, title="test-episode") + at = tz.datetime( + **{ + k: info[k] + for k in ("year", "month", "day", "hour", "minute") + if info.get(k) + } + ) + at = tz.make_aware(at) + diff = models.Diffusion( + episode=episode, start=at, end=at + timedelta(hours=1) + ) + episode.save() + diff.save() + return diff + + +@pytest.mark.django_db(transaction=True) +def test_find_episode(sound_files): + station = models.Station(name="test-station") + program = models.Program(station=station, title="test") + station.save() + program.save() + + for path, sound_file in sound_files.items(): + infos = sound_file.read_path(path) + diff = _setup_diff(program, infos) + sound = models.Sound(program=diff.program, file=path) + result = sound_file.find_episode(sound, infos) + assert diff.episode == result + + # TODO: find_playlist, sync diff --git a/aircox/tests/management/sound_monitor.py b/aircox/tests/management/test_sound_monitor.py similarity index 52% rename from aircox/tests/management/sound_monitor.py rename to aircox/tests/management/test_sound_monitor.py index 84c0f12..3382ecb 100644 --- a/aircox/tests/management/sound_monitor.py +++ b/aircox/tests/management/test_sound_monitor.py @@ -1,22 +1,15 @@ +import pytest + import concurrent.futures as futures import time from datetime import datetime, timedelta -from django.test import TestCase - from aircox.management.sound_monitor import ( ModifiedHandler, MonitorHandler, NotifyHandler, ) -__all__ = ( - "NotifyHandlerTestCase", - "MoveHandlerTestCase", - "ModifiedHandlerTestCase", - "MonitorHandlerTestCase", -) - class FakeEvent: def __init__(self, **kwargs): @@ -31,22 +24,28 @@ class WaitHandler(NotifyHandler): pass -class NotifyHandlerTestCase(TestCase): +@pytest.fixture +def monitor(): + pool = futures.ThreadPoolExecutor(2) + return MonitorHandler("archives", pool) + + +class TestNotifyHandler: pass -class MoveHandlerTestCase(TestCase): +class TestMoveHandler: pass -class ModifiedHandlerTestCase(TestCase): +class TestModifiedHandler: def test_wait(self): handler = ModifiedHandler() handler.timeout_delta = timedelta(seconds=0.1) start = datetime.now() handler.wait() delta = datetime.now() - start - self.assertTrue(delta < handler.timeout_delta + timedelta(seconds=0.1)) + assert delta < handler.timeout_delta + timedelta(seconds=0.1) def test_wait_ping(self): pool = futures.ThreadPoolExecutor(1) @@ -57,28 +56,24 @@ class ModifiedHandlerTestCase(TestCase): time.sleep(0.3) handler.ping() time.sleep(0.3) - self.assertTrue(future.running()) + assert future.running() -class MonitorHandlerTestCase(TestCase): - def setUp(self): - pool = futures.ThreadPoolExecutor(2) - self.monitor = MonitorHandler("archives", pool) - - def test_submit_new_job(self): +class TestMonitorHandler: + def test_submit_new_job(self, monitor): event = FakeEvent(src_path="dummy_src") handler = NotifyHandler() - result, _ = self.monitor._submit(handler, event, "up") - self.assertIs(handler, result) - self.assertIsInstance(handler.future, futures.Future) - self.monitor.pool.shutdown() + result, _ = monitor._submit(handler, event, "up") + assert handler == result + assert isinstance(handler.future, futures.Future) + monitor.pool.shutdown() - def test_submit_job_exists(self): + def test_submit_job_exists(self, monitor): event = FakeEvent(src_path="dummy_src") - job_1, new_1 = self.monitor._submit(WaitHandler(), event, "up") - job_2, new_2 = self.monitor._submit(NotifyHandler(), event, "up") - self.assertIs(job_1, job_2) - self.assertTrue(new_1) - self.assertFalse(new_2) - self.monitor.pool.shutdown() + job_1, new_1 = monitor._submit(WaitHandler(), event, "up") + job_2, new_2 = monitor._submit(NotifyHandler(), event, "up") + assert job_1 == job_2 + assert new_1 + assert not new_2 + monitor.pool.shutdown() diff --git a/aircox/utils.py b/aircox/utils.py index 774eee5..34958d6 100755 --- a/aircox/utils.py +++ b/aircox/utils.py @@ -1,8 +1,7 @@ import datetime - import django.utils.timezone as tz -__all__ = [ +__all__ = ( "Redirect", "redirect", "date_range", @@ -10,9 +9,10 @@ __all__ = [ "date_or_default", "to_timedelta", "seconds_to_time", -] +) +# FIXME: usage & why we don't use Django's class Redirect(Exception): """Redirect exception -- see `redirect()`.""" diff --git a/aircox_streamer/controllers.py b/aircox_streamer/controllers.py index a785117..ca1d233 100755 --- a/aircox_streamer/controllers.py +++ b/aircox_streamer/controllers.py @@ -11,7 +11,7 @@ from django.template.loader import render_to_string from django.utils import timezone as tz from django.utils.translation import gettext_lazy as _ -from aircox import settings +from aircox.conf import settings from aircox.utils import to_seconds from .connector import Connector diff --git a/instance/settings/__init__.py b/instance/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/instance/sample_settings.py b/instance/settings/base.py similarity index 83% rename from instance/sample_settings.py rename to instance/settings/base.py index 8eb89d2..c58739a 100755 --- a/instance/sample_settings.py +++ b/instance/settings/base.py @@ -1,15 +1,3 @@ -"""Django and Aircox instance settings. This file should be saved as -`settings.py` in the same directory as this one. - -User MUST define the following values: `SECRET_KEY`, `ALLOWED_HOSTS`, `DATABASES` - -The following environment variables are used in settings: - * `AIRCOX_DEBUG` (`DEBUG`): enable/disable debugging - -For Django settings see: - https://docs.djangoproject.com/en/3.1/topics/settings/ - https://docs.djangoproject.com/en/3.1/ref/settings/ -""" import os import sys @@ -19,7 +7,8 @@ from django.utils import timezone sys.path.insert(1, os.path.dirname(os.path.realpath(__file__))) # Project root directory -PROJECT_ROOT = os.path.dirname(os.path.dirname(__file__)) +PROJECT_ROOT = os.path.abspath(__file__ + "/../../../") + # DEBUG mode DEBUG = ( (os.environ["AIRCOX_DEBUG"].lower() in ("true", 1)) @@ -38,12 +27,6 @@ LC_LOCALE = "en_US.UTF-8" TIME_ZONE = os.environ.get("TZ") or "UTC" -######################################################################## -# -# You MUST configure those values -# -######################################################################## - # Secret key: you MUST put a consistent secret key. You can generate one # at https://djecrety.ir/ SECRET_KEY = "" @@ -56,15 +39,11 @@ DATABASES = { "TIMEZONE": TIME_ZONE, } } +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Allowed host for HTTP requests ALLOWED_HOSTS = ("127.0.0.1",) -######################################################################## -# -# You CAN configure starting from here -# -######################################################################## # Assets and medias: # In production, user MUST configure webserver in order to serve static @@ -81,26 +60,6 @@ STATIC_ROOT = os.path.join(PROJECT_ROOT, "static") # Path to media directory (by default in static's directory) MEDIA_ROOT = os.path.join(STATIC_ROOT, "media") -# Include specific configuration depending of DEBUG -if DEBUG: - from .dev import * -else: - from .prod import * - - # Enable caching using memcache - CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", - "LOCATION": "127.0.0.1:11211", - } - } - - -######################################################################## -# -# You don't really need to configure what is happening below -# -######################################################################## # Enables internationalization and timezone USE_I18N = True USE_L10N = True @@ -112,7 +71,7 @@ try: import locale locale.setlocale(locale.LC_ALL, LC_LOCALE) -except: +except Exception: print( "Can not set locale {LC}. Is it available on you system? Hint: " "Check /etc/locale.gen and rerun locale-gen as sudo if needed.".format( diff --git a/instance/dev.py b/instance/settings/dev.py similarity index 90% rename from instance/dev.py rename to instance/settings/dev.py index 1e9f64f..44d1d29 100755 --- a/instance/dev.py +++ b/instance/settings/dev.py @@ -1,5 +1,13 @@ import os +from .base import * + +try: + from .settings import * +except ImportError: + pass + + LOCALE_PATHS = ["aircox/locale", "aircox_streamer/locale"] LOGGING = { diff --git a/instance/prod.py b/instance/settings/prod.py similarity index 76% rename from instance/prod.py rename to instance/settings/prod.py index c727f2e..207d886 100755 --- a/instance/prod.py +++ b/instance/settings/prod.py @@ -1,5 +1,10 @@ import os +try: + from .settings import * +except ImportError: + pass + LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -30,3 +35,12 @@ LOGGING = { }, }, } + + +# Enable caching using memcache +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", + "LOCATION": "127.0.0.1:11211", + } +} diff --git a/instance/settings/sample.py b/instance/settings/sample.py new file mode 100644 index 0000000..b68d318 --- /dev/null +++ b/instance/settings/sample.py @@ -0,0 +1,43 @@ +"""Django and Aircox instance settings. This file should be saved as +`settings.py` in the same directory as this one. + +User MUST define the following values: `SECRET_KEY`, `ALLOWED_HOSTS`, `DATABASES` + +The following environment variables are used in settings: + * `AIRCOX_DEBUG` (`DEBUG`): enable/disable debugging + +For Django settings see: + https://docs.djangoproject.com/en/3.1/topics/settings/ + https://docs.djangoproject.com/en/3.1/ref/settings/ +""" + +# Debug mode: set to True for dev +# DEBUG = False +LANGUAGE_CODE = "fr-BE" +LC_LOCALE = "fr_BE.UTF-8" + +# Secret key: you MUST put a consistent secret key. You can generate one +# at https://djecrety.ir/ +SECRET_KEY = "" + +# Database configuration: defaults to db.sqlite3 +# DATABASES + +# Allowed host for HTTP requests +# ALLOWED_HOSTS = ('127.0.0.1',) + + +# When LC_LOCALE is set here, this code activates it. +# Otherwise it can be removed +try: + import locale + + locale.setlocale(locale.LC_ALL, LC_LOCALE) +except Exception: + print( + "Can not set locale {LC}. Is it available on you system? Hint: " + "Check /etc/locale.gen and rerun locale-gen as sudo if needed.".format( + LC=LANGUAGE_CODE + ) + ) + pass diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c67b6e7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +DJANGO_SETTINGS_MODULE = instance.settings +# -- recommended but optional: +python_files = tests.py test_*.py *_tests.py diff --git a/requirements.txt b/requirements.txt index e0fa1b4..65bdec7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ djangorestframework~=3.13 django-model-utils>=4.2 django-filter~=22.1 +django-content-editor~=6.3 django-filer~=2.2 django-honeypot~=1.0 django-taggit~=3.0 diff --git a/requirements_tests.txt b/requirements_tests.txt new file mode 100644 index 0000000..d18e0aa --- /dev/null +++ b/requirements_tests.txt @@ -0,0 +1,2 @@ +pytest~=7.2 +pytest-django~=4.5 diff --git a/scripts/cron b/scripts/cron index 2eadfe2..4b3fa84 100755 --- a/scripts/cron +++ b/scripts/cron @@ -1,4 +1,5 @@ #! /bin/sh +export DJANGO_SETTINGS_MODULE=instance.settings.prod # aircox daily tasks: # - diffusions monitoring for the current month diff --git a/scripts/supervisord_aircox b/scripts/supervisord_aircox index d05c6ce..8460195 100755 --- a/scripts/supervisord_aircox +++ b/scripts/supervisord_aircox @@ -21,7 +21,7 @@ autostart = true autorestart = true stdout_logfile = /srv/www/aircox/logs/server.log redirect_stderr = true -environment=AIRCOX_DEBUG="False" +environment=AIRCOX_DEBUG="False",DJANGO_SETTINGS_MODULE=instance.settings.prod [program:aircox_sounds_monitor] command = /srv/www/aircox/scripts/launch_in_venv ./manage.py sounds_monitor -qsm @@ -31,7 +31,7 @@ autostart = true autorestart = true stdout_logfile = /srv/www/aircox/logs/sounds_monitor.log redirect_stderr = true -environment=AIRCOX_DEBUG="False" +environment=AIRCOX_DEBUG="False",DJANGO_SETTINGS_MODULE=instance.settings.prod [program:aircox_streamer] command = /srv/www/aircox/scripts/launch_in_venv ./manage.py streamer -crm @@ -41,4 +41,4 @@ autostart = true autorestart = true stdout_logfile = /srv/www/aircox/logs/streamer.log redirect_stderr = true -environment=AIRCOX_DEBUG="False" +environment=AIRCOX_DEBUG="False",DJANGO_SETTINGS_MODULE=instance.settings.prod