@ -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:
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										180
									
								
								aircox/conf.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										180
									
								
								aircox/conf.py
									
									
									
									
									
										Executable file
									
								
							@ -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")
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,3 @@
 | 
			
		||||
from . import signals
 | 
			
		||||
from .article import Article
 | 
			
		||||
from .episode import Diffusion, DiffusionQuerySet, Episode
 | 
			
		||||
from .log import Log, LogArchiver, LogQuerySet
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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("/")]
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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("-", "_"),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										168
									
								
								aircox/settings.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						
									
										168
									
								
								aircox/settings.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							@ -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
 | 
			
		||||
 | 
			
		||||
@ -1,15 +0,0 @@
 | 
			
		||||
from .sound_file import SoundFileTestCase
 | 
			
		||||
from .sound_monitor import (
 | 
			
		||||
    ModifiedHandlerTestCase,
 | 
			
		||||
    MonitorHandlerTestCase,
 | 
			
		||||
    MoveHandlerTestCase,
 | 
			
		||||
    NotifyHandlerTestCase,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
__all__ = (
 | 
			
		||||
    "SoundFileTestCase",
 | 
			
		||||
    "NotifyHandlerTestCase",
 | 
			
		||||
    "MoveHandlerTestCase",
 | 
			
		||||
    "ModifiedHandlerTestCase",
 | 
			
		||||
    "MonitorHandlerTestCase",
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
							
								
								
									
										111
									
								
								aircox/tests/management/test_sound_file.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								aircox/tests/management/test_sound_file.py
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
@ -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()
 | 
			
		||||
@ -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()`."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								instance/settings/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								instance/settings/__init__.py
									
									
									
									
									
										Normal file
									
								
							@ -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(
 | 
			
		||||
@ -1,5 +1,13 @@
 | 
			
		||||
import os
 | 
			
		||||
 | 
			
		||||
from .base import *
 | 
			
		||||
 | 
			
		||||
try:
 | 
			
		||||
    from .settings import *
 | 
			
		||||
except ImportError:
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
LOCALE_PATHS = ["aircox/locale", "aircox_streamer/locale"]
 | 
			
		||||
 | 
			
		||||
LOGGING = {
 | 
			
		||||
@ -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",
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								instance/settings/sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								instance/settings/sample.py
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
							
								
								
									
										4
									
								
								pytest.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								pytest.ini
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
			
		||||
[pytest]
 | 
			
		||||
DJANGO_SETTINGS_MODULE = instance.settings
 | 
			
		||||
# -- recommended but optional:
 | 
			
		||||
python_files = tests.py test_*.py *_tests.py
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								requirements_tests.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								requirements_tests.txt
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,2 @@
 | 
			
		||||
pytest~=7.2
 | 
			
		||||
pytest-django~=4.5
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
#! /bin/sh
 | 
			
		||||
export DJANGO_SETTINGS_MODULE=instance.settings.prod
 | 
			
		||||
 | 
			
		||||
# aircox daily tasks:
 | 
			
		||||
# - diffusions monitoring for the current month
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user