Compare commits
12 Commits
0f1ca3017e
...
1d9dc4628a
Author | SHA1 | Date | |
---|---|---|---|
1d9dc4628a | |||
9e952735b8 | |||
291949e6e8 | |||
d63d949096 | |||
a89117f69d | |||
0eeeb3bc09 | |||
a1d6f0ef4a | |||
b0afa0fd86 | |||
92f9a08856 | |||
9097bced4a | |||
61e6732b19 | |||
fd4c765dc4 |
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -5,3 +5,6 @@ venv/
|
||||||
node_modules/
|
node_modules/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.egg
|
*.egg
|
||||||
|
|
||||||
|
db.sqlite3
|
||||||
|
instance/settings/settings.py
|
||||||
|
|
54
README.md
54
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('SECRET_KEY = \"%s\"' % get_random_secret_key())" >> instance/settings/settings.py
|
||||||
|
DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally test and run the instance using development settings, and point your browser to http://localhost:8000 :
|
||||||
|
|
||||||
|
```
|
||||||
|
DJANGO_SETTINGS_MODULE=instance.settings.dev pytest
|
||||||
|
DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Before requesting a merge, enable pre-commit :
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
Running Aircox on production involves:
|
||||||
|
* Aircox modules and a running Django project;
|
||||||
|
* a supervisor for common tasks (sounds monitoring, stream control, etc.) -- `supervisord`;
|
||||||
|
* a wsgi and an HTTP server -- `gunicorn`, `nginx`;
|
||||||
|
* a database supported by Django (MySQL, SQLite, PostGresSQL);
|
||||||
|
|
||||||
|
### Scripts
|
||||||
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
|
||||||
|
@ -62,8 +104,8 @@ pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
You must write a settings.py file in the `instance` directory (you can just
|
You must write a settings.py file in the `instance/settings` directory (you can just
|
||||||
copy and paste `instance/sample_settings.py`. There still is configuration
|
copy and paste `instance/settings/sample.py`. There still is configuration
|
||||||
required in this file, check it in for more info.
|
required in this file, check it in for more info.
|
||||||
|
|
||||||
|
|
||||||
|
|
3
aircox/context_processors/__init__.py
Normal file
3
aircox/context_processors/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
def station(request):
|
||||||
|
station = request.station
|
||||||
|
return {"station": station, "audio_streams": station.streams}
|
|
@ -1,5 +1,4 @@
|
||||||
#! /usr/bin/env python3
|
#! /usr/bin/env python3
|
||||||
# TODO: SoundMonitor class
|
|
||||||
|
|
||||||
"""Monitor sound files; For each program, check for:
|
"""Monitor sound files; For each program, check for:
|
||||||
|
|
||||||
|
@ -60,10 +59,8 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
SoundMonitor()
|
monitor = SoundMonitor()
|
||||||
if options.get("scan"):
|
if options.get("scan"):
|
||||||
self.scan()
|
monitor.scan()
|
||||||
# if options.get('quality_check'):
|
|
||||||
# self.check_quality(check=(not options.get('scan')))
|
|
||||||
if options.get("monitor"):
|
if options.get("monitor"):
|
||||||
self.monitor()
|
monitor.monitor()
|
||||||
|
|
|
@ -85,7 +85,7 @@ class Program(Page):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def editors_group_name(self):
|
def editors_group_name(self):
|
||||||
return "{self.title} editors"
|
return f"{self.title} editors"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def change_permission_codename(self):
|
def change_permission_codename(self):
|
||||||
|
|
15
aircox/templates/accounts/profile.html
Normal file
15
aircox/templates/accounts/profile.html
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends "aircox/base.html" %}
|
||||||
|
{% load i18n aircox %}
|
||||||
|
|
||||||
|
{% block head_title %}
|
||||||
|
{% block title %}{{ user.username }}{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<h2 class="subtitle is-3">Mes émissions</h2>
|
||||||
|
<ul>
|
||||||
|
{% for p in programs %}
|
||||||
|
<li>• <a href="{% url 'program-detail' slug=p.slug %}">{{ p.title }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
|
@ -68,6 +68,7 @@ Usefull context:
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
{% block top-nav-tools %}
|
{% block top-nav-tools %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block top-nav-end %}
|
{% block top-nav-end %}
|
||||||
<div class="navbar-item">
|
<div class="navbar-item">
|
||||||
<form action="{% url 'page-list' %}" method="GET">
|
<form action="{% url 'page-list' %}" method="GET">
|
||||||
|
@ -81,6 +82,12 @@ Usefull context:
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div class="navbar-item">
|
||||||
|
<a href="{% url 'profile' %}">{{ user.username }}</a> <a href="{% url 'logout' %}"> <i class="fa fa-power-off"></i></a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,67 +1,90 @@
|
||||||
{% extends "aircox/page_detail.html" %}
|
{% extends "aircox/basepage_detail.html" %}
|
||||||
{% comment %}Detail page of a show{% endcomment %}
|
{% load static i18n humanize honeypot aircox %}
|
||||||
{% load i18n %}
|
{% comment %}
|
||||||
|
Base template used to display a Page
|
||||||
|
|
||||||
{% include "aircox/program_sidebar.html" %}
|
Context:
|
||||||
|
- page: page
|
||||||
|
- parent: parent page
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block header_crumbs %}
|
||||||
{% block header_nav %}
|
{{ block.super }}
|
||||||
|
{% if page.category %}
|
||||||
|
{% if parent %} / {% endif %} {{ page.category.title }}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block top-nav-tools %}
|
||||||
{% block content %}
|
{% has_perm page page.change_permission_codename simple=True as can_edit %}
|
||||||
{{ block.super }}
|
{% if can_edit %}
|
||||||
<br>
|
<a class="navbar-item" href="{% url 'program-edit' page.pk %}"
|
||||||
{% with has_headline=False %}
|
target="new">
|
||||||
{% if articles %}
|
<span class="icon is-small">
|
||||||
<section>
|
<i class="fa fa-pen"></i>
|
||||||
<h4 class="title is-4">{% translate "Articles" %}</h4>
|
</span>
|
||||||
|
<span>{% translate "Edit" %}</span>
|
||||||
{% for object in articles %}
|
|
||||||
{% include "aircox/widgets/page_item.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<br>
|
|
||||||
<nav class="pagination is-centered">
|
|
||||||
<ul class="pagination-list">
|
|
||||||
<li>
|
|
||||||
<a href="{% url "article-list" parent_slug=program.slug %}"
|
|
||||||
class="pagination-link"
|
|
||||||
aria-label="{% translate "Show all program's articles" %}">
|
|
||||||
{% translate "More articles" %}
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block sidebar %}
|
{% block main %}
|
||||||
<section>
|
|
||||||
<h4 class="title is-4">{% translate "Diffusions" %}</h4>
|
|
||||||
{% for schedule in program.schedule_set.all %}
|
|
||||||
{{ schedule.get_frequency_display }}
|
|
||||||
{% with schedule.start|date:"H:i" as start %}
|
|
||||||
{% with schedule.end|date:"H:i" as end %}
|
|
||||||
<time datetime="{{ start }}">{{ start }}</time>
|
|
||||||
—
|
|
||||||
<time datetime="{{ end }}">{{ end }}</time>
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
<small>
|
|
||||||
{% if schedule.initial %}
|
|
||||||
{% with schedule.initial.date as date %}
|
|
||||||
<span title="{% blocktranslate %}Rerun of {{ date }}{% endblocktranslate %}">
|
|
||||||
({% translate "Rerun" %})
|
|
||||||
</span>
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
</small>
|
|
||||||
<br>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
{% block comments %}
|
||||||
|
{% if comments or comment_form %}
|
||||||
|
<section class="mt-6">
|
||||||
|
<h4 class="title is-4">{% translate "Comments" %}</h4>
|
||||||
|
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="media box">
|
||||||
|
<div class="media-content">
|
||||||
|
<p>
|
||||||
|
<strong class="mr-2">{{ comment.nickname }}</strong>
|
||||||
|
<time datetime="{{ comment.date }}" title="{{ comment.date }}">
|
||||||
|
<small>{{ comment.date|naturaltime }}</small>
|
||||||
|
</time>
|
||||||
|
<br>
|
||||||
|
{{ comment.content }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if comment_form %}
|
||||||
|
<form method="POST">
|
||||||
|
<h5 class="title is-5">{% translate "Post a comment" %}</h5>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% render_honeypot_field "website" %}
|
||||||
|
|
||||||
|
{% for field in comment_form %}
|
||||||
|
<div class="field is-horizontal">
|
||||||
|
<div class="field-label is-normal">
|
||||||
|
<label class="label">
|
||||||
|
{{ field.label_tag }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="field-body">
|
||||||
|
<div class="field">
|
||||||
|
<p class="control is-expanded">{{ field }}</p>
|
||||||
|
{% if field.errors %}
|
||||||
|
<p class="help is-danger">{{ field.errors }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if field.help_text %}
|
||||||
|
<p class="help">{{ field.help_text|safe }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div class="has-text-right">
|
||||||
|
<button type="reset" class="button is-danger">{% translate "Reset" %}</button>
|
||||||
|
<button type="submit" class="button is-success">{% translate "Post comment" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
24
aircox/templates/aircox/program_form.html
Normal file
24
aircox/templates/aircox/program_form.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{% extends "aircox/basepage_detail.html" %}
|
||||||
|
{% load static i18n humanize honeypot aircox %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block top-nav-tools %}
|
||||||
|
<a class="navbar-item" href="{% url 'program-detail' object.slug %}"
|
||||||
|
target="new">
|
||||||
|
<span class="icon is-small">
|
||||||
|
<i class="fa fa-eye"></i>
|
||||||
|
</span>
|
||||||
|
<span>{% translate "View" %}</span>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<form method="post">{% csrf_token %}
|
||||||
|
<table>
|
||||||
|
{{ form.as_table }}
|
||||||
|
{% render_honeypot_field "website" %}
|
||||||
|
</table>
|
||||||
|
<br/>
|
||||||
|
<input type="submit" value="Update" class="button is-success">
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
20
aircox/templates/registration/login.html
Normal file
20
aircox/templates/registration/login.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "aircox/base.html" %}
|
||||||
|
{% load i18n aircox %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
|
||||||
|
<h2>{% trans "Log in" %}</h2>
|
||||||
|
<br/>
|
||||||
|
<form method="post" action="{% url 'login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<table>
|
||||||
|
{{ form.as_table }}
|
||||||
|
</table>
|
||||||
|
<br/>
|
||||||
|
<button type="submit">{% trans "Log in" %}</button>
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ block.super }}
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -30,10 +30,13 @@ def do_get_tracks(obj):
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag(name="has_perm", takes_context=True)
|
@register.simple_tag(name="has_perm", takes_context=True)
|
||||||
def do_has_perm(context, obj, perm, user=None):
|
def do_has_perm(context, obj, perm, user=None, simple=False):
|
||||||
"""Return True if ``user.has_perm('[APP].[perm]_[MODEL]')``"""
|
"""Return True if ``user.has_perm('[APP].[perm]_[MODEL]')``"""
|
||||||
if user is None:
|
if user is None:
|
||||||
user = context["request"].user
|
user = context["request"].user
|
||||||
|
if simple:
|
||||||
|
return user.has_perm("aircox.{}".format(perm))
|
||||||
|
else:
|
||||||
return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name))
|
return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -206,29 +206,48 @@ def monitor():
|
||||||
yield sound_monitor.SoundMonitor()
|
yield sound_monitor.SoundMonitor()
|
||||||
|
|
||||||
|
|
||||||
class SoundMonitor:
|
class TestSoundMonitor:
|
||||||
|
@pytest.mark.django_db
|
||||||
def test_report(self, monitor, program, logger):
|
def test_report(self, monitor, program, logger):
|
||||||
monitor.report(program, "component", "content", logger=logger)
|
monitor.report(program, "component", "content", logger=logger)
|
||||||
msg = f"{program}, component: content"
|
msg = f"{program}, component: content"
|
||||||
assert logger._trace("info", args=True) == (msg,)
|
assert logger._trace("info", args=True) == (msg,)
|
||||||
|
|
||||||
def test_scan(self, monitor, program, logger):
|
@pytest.mark.django_db
|
||||||
|
def test_scan(self, monitor, programs, logger):
|
||||||
interface = Interface(None, {"scan_for_program": None})
|
interface = Interface(None, {"scan_for_program": None})
|
||||||
monitor.scan_for_program = interface.scan_for_program
|
monitor.scan_for_program = interface.scan_for_program
|
||||||
dirs = monitor.scan(logger)
|
dirs = monitor.scan(logger)
|
||||||
|
|
||||||
assert logger._traces("info") == (
|
assert logger._traces("info") == tuple(
|
||||||
"scan all programs...",
|
[
|
||||||
f"#{program.id} {program.title}",
|
(("scan all programs...",), {}),
|
||||||
)
|
]
|
||||||
assert dirs == [program.abspath]
|
+ [
|
||||||
assert interface._traces("scan_for_program") == (
|
((f"#{program.id} {program.title}",), {})
|
||||||
((program, settings.SOUND_ARCHIVES_SUBDIR), {"logger": logger})(
|
for program in programs
|
||||||
(program, settings.SOUND_EXCERPTS_SUBDIR), {"logger": logger}
|
]
|
||||||
)
|
)
|
||||||
|
assert dirs == [program.abspath for program in programs]
|
||||||
|
traces = tuple(
|
||||||
|
[
|
||||||
|
[
|
||||||
|
(
|
||||||
|
(program, settings.SOUND_ARCHIVES_SUBDIR),
|
||||||
|
{"logger": logger, "type": Sound.TYPE_ARCHIVE},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(program, settings.SOUND_EXCERPTS_SUBDIR),
|
||||||
|
{"logger": logger, "type": Sound.TYPE_EXCERPT},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for program in programs
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
traces_flat = tuple([item for sublist in traces for item in sublist])
|
||||||
|
assert interface._traces("scan_for_program") == traces_flat
|
||||||
|
|
||||||
def test_monitor(self, monitor, monitor_interfaces, logger):
|
def broken_test_monitor(self, monitor, monitor_interfaces, logger):
|
||||||
def sleep(*args, **kwargs):
|
def sleep(*args, **kwargs):
|
||||||
monitor.stop()
|
monitor.stop()
|
||||||
|
|
||||||
|
|
23
aircox/tests/management/test_sounds_monitor.py
Normal file
23
aircox/tests/management/test_sounds_monitor.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import pytest
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
wav = (
|
||||||
|
b"RIFF$\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x02\x00D\xac\x00\x00"
|
||||||
|
b"\x10\xb1\x02\x00\x04\x00\x10\x00data\x00\x00\x00\x00"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_adding_a_sound(programs, fs):
|
||||||
|
p0 = programs[0]
|
||||||
|
assert len(p0.sound_set.all()) == 0
|
||||||
|
|
||||||
|
s0 = os.path.join(
|
||||||
|
settings.PROJECT_ROOT, "static/media/%s/archives/sound.wav" % p0.path
|
||||||
|
)
|
||||||
|
fs.create_file(s0, contents=wav)
|
||||||
|
call_command("sounds_monitor", "-s")
|
||||||
|
assert len(p0.sound_set.all()) == 1
|
|
@ -18,20 +18,19 @@ def test_user_cannot_change_program_or_episode(user, client, program):
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_group_can_change_program(user, client, program):
|
def test_group_can_change_program(user, client, program):
|
||||||
program_editors = program.editors
|
assert program.editors in Group.objects.all()
|
||||||
assert program_editors in Group.objects.all()
|
|
||||||
assert not user.has_perm("aircox.%s" % program.change_permission_codename)
|
assert not user.has_perm("aircox.%s" % program.change_permission_codename)
|
||||||
user.groups.add(program_editors)
|
user.groups.add(program.editors)
|
||||||
user = User.objects.get(pk=user.pk) # reload user in order to have permissions set
|
user = User.objects.get(pk=user.pk) # reload user in order to have permissions set
|
||||||
assert program_editors in user.groups.all()
|
assert program.editors in user.groups.all()
|
||||||
assert user.has_perm("aircox.%s" % program.change_permission_codename)
|
assert user.has_perm("aircox.%s" % program.change_permission_codename)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db()
|
@pytest.mark.django_db()
|
||||||
def test_group_change_program(user, client, program):
|
def test_group_change_program(user, client, program):
|
||||||
client.force_login(user)
|
client.force_login(user)
|
||||||
response = client.get(reverse("program-edit", kwargs={"slug": program.slug}))
|
response = client.get(reverse("program-edit", kwargs={"pk": program.pk}))
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
user.groups.add(program.editors)
|
user.groups.add(program.editors)
|
||||||
response = client.get(reverse("program-edit", kwargs={"slug": program.slug}))
|
response = client.get(reverse("program-edit", kwargs={"pk": program.pk}))
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
22
aircox/tests/test_profile.py
Normal file
22
aircox/tests/test_profile.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_authenticate(user, client, program):
|
||||||
|
r = client.get(reverse("login"))
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert b"id_username" in r.content
|
||||||
|
r = client.post(reverse("login"), kwargs={"username": "foo", "password": "bar"})
|
||||||
|
assert b"errorlist" in r.content
|
||||||
|
assert client.login(username="user1", password="bar")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_profile_programs(user, client, program):
|
||||||
|
client.force_login(user)
|
||||||
|
r = client.get(reverse("profile"))
|
||||||
|
assert program.title not in r.content.decode("utf-8")
|
||||||
|
user.groups.add(program.editors)
|
||||||
|
r = client.get(reverse("profile"))
|
||||||
|
assert program.title in r.content.decode("utf-8")
|
40
aircox/tests/test_program.py
Normal file
40
aircox/tests/test_program.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
from aircox.models import Program
|
||||||
|
|
||||||
|
|
||||||
|
png_content = (
|
||||||
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
|
||||||
|
+ b"\x00\x00\x00\x0cIDATx\x9cc`\xf8\xcf\x00\x00\x02\x02\x01\x00{\t\x81x\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_edit_program(user, client, program):
|
||||||
|
client.force_login(user)
|
||||||
|
response = client.get(reverse("program-detail", kwargs={"slug": program.slug}))
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b"fa-pen" not in response.content
|
||||||
|
user.groups.add(program.editors)
|
||||||
|
response = client.get(reverse("program-detail", kwargs={"slug": program.slug}))
|
||||||
|
assert b"fa-pen" in response.content
|
||||||
|
assert b"foobar" not in response.content
|
||||||
|
response = client.post(reverse("program-edit", kwargs={"pk": program.pk}), {"content": "foobar"})
|
||||||
|
response = client.get(reverse("program-detail", kwargs={"slug": program.slug}))
|
||||||
|
assert b"foobar" in response.content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db()
|
||||||
|
def test_add_cover(user, client, program):
|
||||||
|
assert program.cover is None
|
||||||
|
user.groups.add(program.editors)
|
||||||
|
client.force_login(user)
|
||||||
|
cover = SimpleUploadedFile("cover1.png", png_content, content_type="image/png")
|
||||||
|
r = client.post(
|
||||||
|
reverse("program-edit", kwargs={"pk": program.pk}), {"content": "foobar", "new_cover": cover}, follow=True
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
p = Program.objects.get(pk=program.pk)
|
||||||
|
assert "cover1.png" in p.cover.url
|
|
@ -54,13 +54,11 @@ class TestBaseView:
|
||||||
context = base_view.get_context_data()
|
context = base_view.get_context_data()
|
||||||
assert context == {
|
assert context == {
|
||||||
"view": base_view,
|
"view": base_view,
|
||||||
"station": station,
|
|
||||||
"page": None, # get_page() returns None
|
"page": None, # get_page() returns None
|
||||||
"has_sidebar": base_view.has_sidebar,
|
"has_sidebar": base_view.has_sidebar,
|
||||||
"has_filters": False,
|
"has_filters": False,
|
||||||
"sidebar_object_list": published_pages[: base_view.list_count],
|
"sidebar_object_list": published_pages[: base_view.list_count],
|
||||||
"sidebar_list_url": base_view.get_sidebar_url(),
|
"sidebar_list_url": base_view.get_sidebar_url(),
|
||||||
"audio_streams": station.streams,
|
|
||||||
"model": base_view.model,
|
"model": base_view.model,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -93,7 +93,7 @@ urls = [
|
||||||
name="program-detail",
|
name="program-detail",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
_("program/<slug:slug>/edit/"),
|
_("program/<pk>/edit/"),
|
||||||
views.ProgramUpdateView.as_view(),
|
views.ProgramUpdateView.as_view(),
|
||||||
name="program-edit",
|
name="program-edit",
|
||||||
),
|
),
|
||||||
|
@ -117,4 +117,6 @@ urls = [
|
||||||
views.errors.NoStationErrorView.as_view(),
|
views.errors.NoStationErrorView.as_view(),
|
||||||
name="errors-no-station",
|
name="errors-no-station",
|
||||||
),
|
),
|
||||||
|
path("gestion/", views.profile, name="profile"),
|
||||||
|
path("accounts/profile/", views.profile, name="profile"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -11,6 +11,7 @@ from .page import (
|
||||||
PageDetailView,
|
PageDetailView,
|
||||||
PageListView,
|
PageListView,
|
||||||
)
|
)
|
||||||
|
from .profile import profile
|
||||||
from .program import (
|
from .program import (
|
||||||
ProgramDetailView,
|
ProgramDetailView,
|
||||||
ProgramListView,
|
ProgramListView,
|
||||||
|
@ -36,6 +37,7 @@ __all__ = (
|
||||||
"BasePageListView",
|
"BasePageListView",
|
||||||
"PageDetailView",
|
"PageDetailView",
|
||||||
"PageListView",
|
"PageListView",
|
||||||
|
"profile",
|
||||||
"ProgramDetailView",
|
"ProgramDetailView",
|
||||||
"ProgramListView",
|
"ProgramListView",
|
||||||
"ProgramPageDetailView",
|
"ProgramPageDetailView",
|
||||||
|
|
|
@ -33,7 +33,6 @@ class BaseView(TemplateResponseMixin, ContextMixin):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
kwargs.setdefault("station", self.station)
|
|
||||||
kwargs.setdefault("page", self.get_page())
|
kwargs.setdefault("page", self.get_page())
|
||||||
kwargs.setdefault("has_filters", self.has_filters)
|
kwargs.setdefault("has_filters", self.has_filters)
|
||||||
|
|
||||||
|
@ -44,9 +43,6 @@ class BaseView(TemplateResponseMixin, ContextMixin):
|
||||||
kwargs["sidebar_object_list"] = sidebar_object_list[: self.list_count]
|
kwargs["sidebar_object_list"] = sidebar_object_list[: self.list_count]
|
||||||
kwargs["sidebar_list_url"] = self.get_sidebar_url()
|
kwargs["sidebar_list_url"] = self.get_sidebar_url()
|
||||||
|
|
||||||
if "audio_streams" not in kwargs:
|
|
||||||
kwargs["audio_streams"] = self.station.streams
|
|
||||||
|
|
||||||
if "model" not in kwargs:
|
if "model" not in kwargs:
|
||||||
model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object)
|
model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object)
|
||||||
kwargs["model"] = model
|
kwargs["model"] = model
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
from django.views.generic.edit import UpdateView
|
||||||
from honeypot.decorators import check_honeypot
|
from honeypot.decorators import check_honeypot
|
||||||
|
|
||||||
from ..filters import PageFilters
|
from ..filters import PageFilters
|
||||||
|
@ -140,5 +141,8 @@ class PageDetailView(BasePageDetailView):
|
||||||
return self.get(request, *args, **kwargs)
|
return self.get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PageUpdateView(PageDetailView):
|
class PageUpdateView(BaseView, UpdateView):
|
||||||
pass
|
context_object_name = "page"
|
||||||
|
|
||||||
|
def get_page(self):
|
||||||
|
return self.object
|
||||||
|
|
15
aircox/views/profile.py
Normal file
15
aircox/views/profile.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
|
||||||
|
from aircox.models import Program
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def profile(request):
|
||||||
|
programs = []
|
||||||
|
ugroups = request.user.groups.all()
|
||||||
|
for p in Program.objects.all():
|
||||||
|
if p.editors in ugroups:
|
||||||
|
programs.append(p)
|
||||||
|
context = {"user": request.user, "programs": programs}
|
||||||
|
return TemplateResponse(request, "accounts/profile.html", context)
|
|
@ -1,6 +1,8 @@
|
||||||
|
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||||
|
from django.forms import ModelForm, ImageField
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
from filer.models.imagemodels import Image
|
||||||
|
|
||||||
from ..models import Page, Program, StaticPage
|
from ..models import Page, Program, StaticPage
|
||||||
from .mixins import ParentMixin
|
from .mixins import ParentMixin
|
||||||
|
@ -25,12 +27,31 @@ class BaseProgramMixin:
|
||||||
class ProgramDetailView(BaseProgramMixin, PageDetailView):
|
class ProgramDetailView(BaseProgramMixin, PageDetailView):
|
||||||
model = Program
|
model = Program
|
||||||
|
|
||||||
|
def get_template_names(self):
|
||||||
|
return super().get_template_names() + ["aircox/program_detail.html"]
|
||||||
|
|
||||||
def get_sidebar_queryset(self):
|
def get_sidebar_queryset(self):
|
||||||
return super().get_sidebar_queryset().filter(parent=self.program)
|
return super().get_sidebar_queryset().filter(parent=self.program)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramForm(ModelForm):
|
||||||
|
new_cover = ImageField(required=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Program
|
||||||
|
fields = ["content"]
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
file_obj = self.cleaned_data["new_cover"]
|
||||||
|
if file_obj:
|
||||||
|
obj, _ = Image.objects.get_or_create(original_filename=file_obj.name, file=file_obj)
|
||||||
|
self.instance.cover = obj
|
||||||
|
super().save(commit=commit)
|
||||||
|
|
||||||
|
|
||||||
class ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
class ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
||||||
model = Program
|
model = Program
|
||||||
|
form_class = ProgramForm
|
||||||
|
|
||||||
def get_sidebar_queryset(self):
|
def get_sidebar_queryset(self):
|
||||||
return super().get_sidebar_queryset().filter(parent=self.program)
|
return super().get_sidebar_queryset().filter(parent=self.program)
|
||||||
|
@ -39,6 +60,9 @@ class ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
|
||||||
program = self.get_object()
|
program = self.get_object()
|
||||||
return self.request.user.has_perm("aircox.%s" % program.change_permission_codename)
|
return self.request.user.has_perm("aircox.%s" % program.change_permission_codename)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse("program-detail", kwargs={"slug": self.get_object().slug})
|
||||||
|
|
||||||
|
|
||||||
class ProgramListView(PageListView):
|
class ProgramListView(PageListView):
|
||||||
model = Program
|
model = Program
|
||||||
|
|
|
@ -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
|
|
|
@ -237,6 +237,7 @@ TEMPLATES = [
|
||||||
"django.template.context_processors.static",
|
"django.template.context_processors.static",
|
||||||
"django.template.context_processors.tz",
|
"django.template.context_processors.tz",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
"aircox.context_processors.station",
|
||||||
),
|
),
|
||||||
"loaders": (
|
"loaders": (
|
||||||
"django.template.loaders.filesystem.Loader",
|
"django.template.loaders.filesystem.Loader",
|
||||||
|
@ -248,3 +249,5 @@ TEMPLATES = [
|
||||||
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "instance.wsgi.application"
|
WSGI_APPLICATION = "instance.wsgi.application"
|
||||||
|
|
||||||
|
LOGOUT_REDIRECT_URL = "/"
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -23,6 +23,7 @@ import aircox.urls
|
||||||
|
|
||||||
urlpatterns = aircox.urls.urls + [
|
urlpatterns = aircox.urls.urls + [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
path("ckeditor/", include("ckeditor_uploader.urls")),
|
path("ckeditor/", include("ckeditor_uploader.urls")),
|
||||||
path("filer/", include("filer.urls")),
|
path("filer/", include("filer.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
@ -17,5 +17,5 @@ dateutils~=0.6
|
||||||
mutagen~=1.45
|
mutagen~=1.45
|
||||||
Pillow~=9.0
|
Pillow~=9.0
|
||||||
psutil~=5.9
|
psutil~=5.9
|
||||||
PyYAML==5.4
|
PyYAML==6.0.1
|
||||||
watchdog~=2.1
|
watchdog~=2.1
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
pytest~=7.2
|
pytest~=7.2
|
||||||
pytest-django~=4.5
|
pytest-django~=4.5
|
||||||
model_bakery~=1.10
|
model_bakery~=1.10
|
||||||
|
pyfakefs~=5.2
|
||||||
|
|
Loading…
Reference in New Issue
Block a user