Compare commits
	
		
			10 Commits
		
	
	
		
			64615089fe
			...
			99413ccfd8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 99413ccfd8 | |||
| 608725df99 | |||
| 0e5a5e8cc5 | |||
| 0716b8b063 | |||
| 8a361924e9 | |||
| b36d20a99d | |||
| d9645865c8 | |||
| 0221fc1ac2 | |||
| 89ae1d666b | |||
| 22784efe25 | 
							
								
								
									
										25
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								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,6 +14,11 @@ This project is distributed under GPL version 3. More information in the LICENSE
 | 
				
			|||||||
* **cms**: content management system.
 | 
					* **cms**: content management system.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## Architecture and concepts
 | 
				
			||||||
 | 
					Aircox is divided in two main modules:
 | 
				
			||||||
 | 
					* `aircox`: basics of Aircox (programs, diffusions, sounds, etc. management); interface for managing a website with Aircox elements (playlists, timetable, players on the website);
 | 
				
			||||||
 | 
					* `aircox_streamer`: interact with application to generate audio stream (LiquidSoap);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Development setup
 | 
					## Development setup
 | 
				
			||||||
Start installing a virtual environment :
 | 
					Start installing a virtual environment :
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -40,7 +44,21 @@ DJANGO_SETTINGS_MODULE=instance.settings.dev pytest
 | 
				
			|||||||
DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py runserver
 | 
					DJANGO_SETTINGS_MODULE=instance.settings.dev ./manage.py runserver
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Scripts
 | 
					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`:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -52,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):
 | 
				
			||||||
 | 
				
			|||||||
@ -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 %}
 | 
				
			||||||
 | 
					{% has_perm page page.change_permission_codename simple=True as can_edit %}
 | 
				
			||||||
 | 
					{% if can_edit %}
 | 
				
			||||||
 | 
					<a class="navbar-item" href="{% url 'program-edit' page.pk %}"
 | 
				
			||||||
 | 
					    target="new">
 | 
				
			||||||
 | 
					    <span class="icon is-small">
 | 
				
			||||||
 | 
					        <i class="fa fa-pen"></i>
 | 
				
			||||||
 | 
					    </span> 
 | 
				
			||||||
 | 
					    <span>{% translate "Edit" %}</span>
 | 
				
			||||||
 | 
					</a>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% block content %}
 | 
					{% block main %}
 | 
				
			||||||
{{ block.super }}
 | 
					{{ block.super }}
 | 
				
			||||||
<br>
 | 
					 | 
				
			||||||
{% with has_headline=False %}
 | 
					 | 
				
			||||||
{% if articles %}
 | 
					 | 
				
			||||||
<section>
 | 
					 | 
				
			||||||
    <h4 class="title is-4">{% translate "Articles" %}</h4>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {% for object in articles %}
 | 
					{% block comments %}
 | 
				
			||||||
    {% include "aircox/widgets/page_item.html" %}
 | 
					{% 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 %}
 | 
					    {% endfor %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <br>
 | 
					    {% if comment_form %}
 | 
				
			||||||
    <nav class="pagination is-centered">
 | 
					    <form method="POST">
 | 
				
			||||||
        <ul class="pagination-list">
 | 
					        <h5 class="title is-5">{% translate "Post a comment" %}</h5>
 | 
				
			||||||
            <li>
 | 
					        {% csrf_token %}
 | 
				
			||||||
                <a href="{% url "article-list" parent_slug=program.slug %}"
 | 
					        {% render_honeypot_field "website" %}
 | 
				
			||||||
                    class="pagination-link"
 | 
					
 | 
				
			||||||
                    aria-label="{% translate "Show all program's articles" %}">
 | 
					        {% for field in comment_form %}
 | 
				
			||||||
                    {% translate "More articles" %}
 | 
					        <div class="field is-horizontal">
 | 
				
			||||||
                </a>
 | 
					            <div class="field-label is-normal">
 | 
				
			||||||
            </li>
 | 
					                <label class="label">
 | 
				
			||||||
        </ul>
 | 
					                    {{ field.label_tag }}
 | 
				
			||||||
    </nav>
 | 
					                </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>
 | 
					</section>
 | 
				
			||||||
{% endif %}
 | 
					{% endif %}
 | 
				
			||||||
{% endwith %}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					 | 
				
			||||||
{% block sidebar %}
 | 
					 | 
				
			||||||
<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 }}
 | 
					 | 
				
			||||||
{% endblock %}
 | 
					{% endblock %}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										91
									
								
								aircox/templates/aircox/program_form.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								aircox/templates/aircox/program_form.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,91 @@
 | 
				
			|||||||
 | 
					{% extends "aircox/basepage_detail.html" %}
 | 
				
			||||||
 | 
					{% load static i18n humanize honeypot aircox %}
 | 
				
			||||||
 | 
					{% comment %}
 | 
				
			||||||
 | 
					Base template used to display a Page
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Context:
 | 
				
			||||||
 | 
					- page: page
 | 
				
			||||||
 | 
					- parent: parent page
 | 
				
			||||||
 | 
					{% endcomment %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% block header_crumbs %}
 | 
				
			||||||
 | 
					{{ block.super }}
 | 
				
			||||||
 | 
					{% if page.category %}
 | 
				
			||||||
 | 
					{% if parent %} / {% endif %} {{ page.category.title }}
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
 | 
					{% endblock %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{% 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 %}
 | 
				
			||||||
 | 
					        {{ form.as_p }}
 | 
				
			||||||
 | 
					        <input type="submit" value="Update" class="button is-success">
 | 
				
			||||||
 | 
					</form>
 | 
				
			||||||
 | 
					{{ 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 %}
 | 
				
			||||||
@ -30,11 +30,14 @@ 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
 | 
				
			||||||
    return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name))
 | 
					    if simple:
 | 
				
			||||||
 | 
					        return user.has_perm("aircox.{}".format(perm))
 | 
				
			||||||
 | 
					    else:
 | 
				
			||||||
 | 
					        return user.has_perm("{}.{}_{}".format(obj._meta.app_label, perm, obj._meta.model_name))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@register.filter(name="is_diffusion")
 | 
					@register.filter(name="is_diffusion")
 | 
				
			||||||
 | 
				
			|||||||
@ -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")
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										36
									
								
								aircox/tests/test_permissions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								aircox/tests/test_permissions.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,36 @@
 | 
				
			|||||||
 | 
					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):
 | 
				
			||||||
 | 
					    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={"pk": program.pk}))
 | 
				
			||||||
 | 
					    assert response.status_code == 403
 | 
				
			||||||
 | 
					    user.groups.add(program.editors)
 | 
				
			||||||
 | 
					    response = client.get(reverse("program-edit", kwargs={"pk": program.pk}))
 | 
				
			||||||
 | 
					    assert response.status_code == 200
 | 
				
			||||||
							
								
								
									
										17
									
								
								aircox/tests/test_program.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								aircox/tests/test_program.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					import pytest
 | 
				
			||||||
 | 
					from django.urls import reverse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@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
 | 
				
			||||||
@ -92,6 +92,11 @@ urls = [
 | 
				
			|||||||
        views.ProgramDetailView.as_view(),
 | 
					        views.ProgramDetailView.as_view(),
 | 
				
			||||||
        name="program-detail",
 | 
					        name="program-detail",
 | 
				
			||||||
    ),
 | 
					    ),
 | 
				
			||||||
 | 
					    path(
 | 
				
			||||||
 | 
					        _("program/<pk>/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(),
 | 
				
			||||||
 | 
				
			|||||||
@ -16,6 +16,7 @@ from .program import (
 | 
				
			|||||||
    ProgramListView,
 | 
					    ProgramListView,
 | 
				
			||||||
    ProgramPageDetailView,
 | 
					    ProgramPageDetailView,
 | 
				
			||||||
    ProgramPageListView,
 | 
					    ProgramPageListView,
 | 
				
			||||||
 | 
					    ProgramUpdateView,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__all__ = (
 | 
					__all__ = (
 | 
				
			||||||
@ -39,4 +40,5 @@ __all__ = (
 | 
				
			|||||||
    "ProgramListView",
 | 
					    "ProgramListView",
 | 
				
			||||||
    "ProgramPageDetailView",
 | 
					    "ProgramPageDetailView",
 | 
				
			||||||
    "ProgramPageListView",
 | 
					    "ProgramPageListView",
 | 
				
			||||||
 | 
					    "ProgramUpdateView",
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
				
			|||||||
@ -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
 | 
				
			||||||
@ -138,3 +139,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(BaseView, UpdateView):
 | 
				
			||||||
 | 
					    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"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -23,10 +25,25 @@ 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 ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
 | 
				
			||||||
 | 
					    model = Program
 | 
				
			||||||
 | 
					    fields = ["content"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_sidebar_queryset(self):
 | 
				
			||||||
 | 
					        return super().get_sidebar_queryset().filter(parent=self.program)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def test_func(self):
 | 
				
			||||||
 | 
					        program = self.get_object()
 | 
				
			||||||
 | 
					        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"),
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user