Compare commits
10 Commits
2a8aa80d8b
...
4b8acf5fbd
Author | SHA1 | Date | |
---|---|---|---|
4b8acf5fbd | |||
3a5eeba273 | |||
8a361924e9 | |||
b36d20a99d | |||
d9645865c8 | |||
0221fc1ac2 | |||
89ae1d666b | |||
22784efe25 | |||
64615089fe | |||
f7210de088 |
50
README.md
50
README.md
|
@ -1,10 +1,9 @@
|
||||||

|

|
||||||
|
|
||||||
Platform to manage a radio, schedules, website, and so on. We use the power of great tools like Django or Liquidsoap.
|
A platform to manage radio schedules, website content, and more. It uses the power of great tools like Django or Liquidsoap.
|
||||||
|
|
||||||
This project is distributed under GPL version 3. More information in the LICENSE file, except for some files whose license is indicated inside source code.
|
This project is distributed under GPL version 3. More information in the LICENSE file, except for some files whose license is indicated inside source code.
|
||||||
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency for each;
|
* **streams**: multiple random music streams when no program is played. We also can specify a time range and frequency for each;
|
||||||
* **diffusions**: generate diffusions time slot for programs that have schedule informations. Check for conflicts and rerun.
|
* **diffusions**: generate diffusions time slot for programs that have schedule informations. Check for conflicts and rerun.
|
||||||
|
@ -15,7 +14,51 @@ This project is distributed under GPL version 3. More information in the LICENSE
|
||||||
* **cms**: content management system.
|
* **cms**: content management system.
|
||||||
|
|
||||||
|
|
||||||
## Scripts
|
## Architecture and concepts
|
||||||
|
Aircox is divided in two main modules:
|
||||||
|
* `aircox`: basics of Aircox (programs, diffusions, sounds, etc. management); interface for managing a website with Aircox elements (playlists, timetable, players on the website);
|
||||||
|
* `aircox_streamer`: interact with application to generate audio stream (LiquidSoap);
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
Start installing a virtual environment :
|
||||||
|
|
||||||
|
```
|
||||||
|
virtualenv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements_tests.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then copy the default settings and initiate the database :
|
||||||
|
|
||||||
|
```
|
||||||
|
cp instance/settings/sample.py instance/settings/settings.py
|
||||||
|
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" >> instance/settings/settings.py
|
||||||
|
DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally test and run the instance using development settings, and point your browser to http://localhost:8000 :
|
||||||
|
|
||||||
|
```
|
||||||
|
DJANGO_SETTINGS_MODULE=instance.settings.dev pytest
|
||||||
|
DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Before requesting a merge, enable pre-commit :
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
Running Aircox on production involves:
|
||||||
|
* Aircox modules and a running Django project;
|
||||||
|
* a supervisor for common tasks (sounds monitoring, stream control, etc.) -- `supervisord`;
|
||||||
|
* a wsgi and an HTTP server -- `gunicorn`, `nginx`;
|
||||||
|
* a database supported by Django (MySQL, SQLite, PostGresSQL);
|
||||||
|
|
||||||
|
### Scripts
|
||||||
Are included various configuration scripts that can be used to ease setup. They
|
Are included various configuration scripts that can be used to ease setup. They
|
||||||
assume that the project is present in `/srv/apps/aircox`:
|
assume that the project is present in `/srv/apps/aircox`:
|
||||||
|
|
||||||
|
@ -27,7 +70,6 @@ The scripts are written with a combination of `cron`, `supervisord`, `nginx`
|
||||||
and `gunicorn` in mind.
|
and `gunicorn` in mind.
|
||||||
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
For python dependencies take a peek at the `requirements.txt` file, plus
|
For python dependencies take a peek at the `requirements.txt` file, plus
|
||||||
dependencies specific to Django (e.g. for database: `mysqlclient` for MySql
|
dependencies specific to Django (e.g. for database: `mysqlclient` for MySql
|
||||||
|
|
|
@ -86,8 +86,8 @@ class Settings(BaseSettings):
|
||||||
# TODO include content_type in order to avoid clash with potential
|
# TODO include content_type in order to avoid clash with potential
|
||||||
# extra applications
|
# extra applications
|
||||||
# aircox
|
# aircox
|
||||||
"change_program",
|
"view_program",
|
||||||
"change_episode",
|
"view_episode",
|
||||||
"change_diffusion",
|
"change_diffusion",
|
||||||
"add_comment",
|
"add_comment",
|
||||||
"change_comment",
|
"change_comment",
|
||||||
|
|
25
aircox/migrations/0015_program_editors.py
Normal file
25
aircox/migrations/0015_program_editors.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 4.2.5 on 2023-10-18 13:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("auth", "0012_alter_user_first_name_max_length"),
|
||||||
|
("aircox", "0014_alter_schedule_timezone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="program",
|
||||||
|
name="editors",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="auth.group",
|
||||||
|
verbose_name="editors",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -3,6 +3,8 @@ import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from django.conf import settings as conf
|
from django.conf import settings as conf
|
||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import F
|
from django.db.models import F
|
||||||
from django.db.models.functions import Concat, Substr
|
from django.db.models.functions import Concat, Substr
|
||||||
|
@ -58,6 +60,7 @@ class Program(Page):
|
||||||
default=True,
|
default=True,
|
||||||
help_text=_("update later diffusions according to schedule changes"),
|
help_text=_("update later diffusions according to schedule changes"),
|
||||||
)
|
)
|
||||||
|
editors = models.ForeignKey(Group, models.CASCADE, blank=True, null=True, verbose_name=_("editors"))
|
||||||
|
|
||||||
objects = ProgramQuerySet.as_manager()
|
objects = ProgramQuerySet.as_manager()
|
||||||
detail_url_name = "program-detail"
|
detail_url_name = "program-detail"
|
||||||
|
@ -80,6 +83,14 @@ class Program(Page):
|
||||||
def excerpts_path(self):
|
def excerpts_path(self):
|
||||||
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
|
return os.path.join(self.path, settings.SOUND_ARCHIVES_SUBDIR)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def editors_group_name(self):
|
||||||
|
return "{self.title} editors"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def change_permission_codename(self):
|
||||||
|
return f"change_program_{self.slug}"
|
||||||
|
|
||||||
def __init__(self, *kargs, **kwargs):
|
def __init__(self, *kargs, **kwargs):
|
||||||
super().__init__(*kargs, **kwargs)
|
super().__init__(*kargs, **kwargs)
|
||||||
if self.slug:
|
if self.slug:
|
||||||
|
@ -109,6 +120,18 @@ class Program(Page):
|
||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
return os.path.exists(path)
|
return os.path.exists(path)
|
||||||
|
|
||||||
|
def set_group_ownership(self):
|
||||||
|
editors, created = Group.objects.get_or_create(name=self.editors_group_name)
|
||||||
|
if created:
|
||||||
|
self.editors = editors
|
||||||
|
permission, _ = Permission.objects.get_or_create(
|
||||||
|
name=f"change program {self.title}",
|
||||||
|
codename=self.change_permission_codename,
|
||||||
|
content_type=ContentType.objects.get_for_model(self),
|
||||||
|
)
|
||||||
|
if permission not in editors.permissions.all():
|
||||||
|
editors.permissions.add(permission)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Program")
|
verbose_name = _("Program")
|
||||||
verbose_name_plural = _("Programs")
|
verbose_name_plural = _("Programs")
|
||||||
|
@ -134,6 +157,9 @@ class Program(Page):
|
||||||
shutil.move(abspath, self.abspath)
|
shutil.move(abspath, self.abspath)
|
||||||
Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_))))
|
Sound.objects.filter(path__startswith=path_).update(file=Concat("file", Substr(F("file"), len(path_))))
|
||||||
|
|
||||||
|
self.set_group_ownership()
|
||||||
|
super().save(*kargs, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ProgramChildQuerySet(PageQuerySet):
|
class ProgramChildQuerySet(PageQuerySet):
|
||||||
def station(self, station=None, id=None):
|
def station(self, station=None, id=None):
|
||||||
|
|
|
@ -157,3 +157,8 @@ def tracks(episode, sound):
|
||||||
items += [baker.prepare(models.Track, sound=sound, position=i, timestamp=i * 60) for i in range(0, 3)]
|
items += [baker.prepare(models.Track, sound=sound, position=i, timestamp=i * 60) for i in range(0, 3)]
|
||||||
models.Track.objects.bulk_create(items)
|
models.Track.objects.bulk_create(items)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user():
|
||||||
|
return User.objects.create_user(username="user1", password="bar")
|
||||||
|
|
37
aircox/tests/test_permissions.py
Normal file
37
aircox/tests/test_permissions.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import User, Group
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_no_admin(user, client):
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get("/admin/")
|
||||||
|
assert response.status_code != 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_user_cannot_change_program_or_episode(user, client, program):
|
||||||
|
assert not user.has_perm("aircox.change_program")
|
||||||
|
assert not user.has_perm("aircox.change_episode")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_group_can_change_program(user, client, program):
|
||||||
|
program_editors = program.editors
|
||||||
|
assert program_editors in Group.objects.all()
|
||||||
|
assert not user.has_perm("aircox.%s" % program.change_permission_codename)
|
||||||
|
user.groups.add(program_editors)
|
||||||
|
user = User.objects.get(pk=user.pk) # reload user in order to have permissions set
|
||||||
|
assert program_editors in user.groups.all()
|
||||||
|
assert user.has_perm("aircox.%s" % program.change_permission_codename)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_group_change_program(user, client, program):
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get(reverse("program-edit", kwargs={"slug": program.slug}))
|
||||||
|
assert response.status_code == 403
|
||||||
|
user.groups.add(program.editors)
|
||||||
|
response = client.get(reverse("program-edit", kwargs={"slug": program.slug}))
|
||||||
|
assert response.status_code == 200
|
|
@ -92,6 +92,11 @@ urls = [
|
||||||
views.ProgramDetailView.as_view(),
|
views.ProgramDetailView.as_view(),
|
||||||
name="program-detail",
|
name="program-detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
_("program/<slug:slug>/edit/"),
|
||||||
|
views.ProgramUpdateView.as_view(),
|
||||||
|
name="program-edit",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
_("programs/<slug:parent_slug>/episodes/"),
|
_("programs/<slug:parent_slug>/episodes/"),
|
||||||
views.EpisodeListView.as_view(),
|
views.EpisodeListView.as_view(),
|
||||||
|
|
|
@ -138,3 +138,7 @@ class PageDetailView(BasePageDetailView):
|
||||||
comment.page = self.object
|
comment.page = self.object
|
||||||
comment.save()
|
comment.save()
|
||||||
return self.get(request, *args, **kwargs)
|
return self.get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class PageUpdateView(PageDetailView):
|
||||||
|
pass
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
|
|
||||||
from ..models import Page, Program, StaticPage
|
from ..models import Page, Program, StaticPage
|
||||||
from .mixins import ParentMixin
|
from .mixins import ParentMixin
|
||||||
from .page import PageDetailView, PageListView
|
from .page import PageDetailView, PageListView, PageUpdateView
|
||||||
|
|
||||||
__all__ = ["ProgramPageDetailView", "ProgramDetailView", "ProgramPageListView"]
|
__all__ = ["ProgramPageDetailView", "ProgramDetailView", "ProgramPageListView"]
|
||||||
|
|
||||||
|
@ -27,6 +29,18 @@ class ProgramDetailView(BaseProgramMixin, PageDetailView):
|
||||||
return super().get_sidebar_queryset().filter(parent=self.program)
|
return super().get_sidebar_queryset().filter(parent=self.program)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
||||||
|
model = Program
|
||||||
|
|
||||||
|
def get_sidebar_queryset(self):
|
||||||
|
return super().get_sidebar_queryset().filter(parent=self.program)
|
||||||
|
|
||||||
|
def test_func(self):
|
||||||
|
program = self.get_object()
|
||||||
|
print("XXX, aircox.%s" % program.change_permission_codename)
|
||||||
|
return self.request.user.has_perm("aircox.%s" % program.change_permission_codename)
|
||||||
|
|
||||||
|
|
||||||
class ProgramListView(PageListView):
|
class ProgramListView(PageListView):
|
||||||
model = Program
|
model = Program
|
||||||
attach_to_value = StaticPage.ATTACH_TO_PROGRAMS
|
attach_to_value = StaticPage.ATTACH_TO_PROGRAMS
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
|
|
||||||
# General information
|
|
||||||
Aircox is a set of Django applications that aims to provide a radio management solution, and is
|
|
||||||
written in Python 3.5.
|
|
||||||
|
|
||||||
Running Aircox on production involves:
|
|
||||||
* Aircox modules and a running Django project;
|
|
||||||
* a supervisor for common tasks (sounds monitoring, stream control, etc.) -- `supervisord`;
|
|
||||||
* a wsgi and an HTTP server -- `gunicorn`, `nginx`;
|
|
||||||
* a database supported by Django (MySQL, SQLite, PostGresSQL);
|
|
||||||
|
|
||||||
# Architecture and concepts
|
|
||||||
Aircox is divided in three main modules:
|
|
||||||
* `programs`: basics of Aircox (programs, diffusions, sounds, etc. management);
|
|
||||||
* `controllers`: interact with application to generate audio stream (LiquidSoap);
|
|
||||||
* `cms`: create a website with Aircox elements (playlists, timetable, players on the website);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
|
|
||||||
# Configuration
|
|
|
@ -7,6 +7,7 @@ try:
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
LOCALE_PATHS = ["aircox/locale", "aircox_streamer/locale"]
|
LOCALE_PATHS = ["aircox/locale", "aircox_streamer/locale"]
|
||||||
|
|
||||||
|
@ -15,7 +16,7 @@ LOGGING = {
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
"timestamp": {
|
"timestamp": {
|
||||||
"format": "{asctime} {levelname} {message}",
|
"format": "{asctime} {module} {levelname} {message}",
|
||||||
"style": "{",
|
"style": "{",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -26,6 +27,10 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
|
"root": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": os.getenv("DJANGO_LOG_LEVEL", "DEBUG"),
|
||||||
|
},
|
||||||
"aircox": {
|
"aircox": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": os.getenv("DJANGO_LOG_LEVEL", "DEBUG"),
|
"level": os.getenv("DJANGO_LOG_LEVEL", "DEBUG"),
|
||||||
|
@ -40,3 +45,9 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user