feat: add error message page; improve admin ui; add missing test files

This commit is contained in:
bkfox 2023-09-12 21:00:44 +02:00
parent a0468899b0
commit 876e4cdfa7
28 changed files with 1242 additions and 438 deletions

View File

@ -3,7 +3,7 @@ from django.urls import include, path, reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .models import Comment, Diffusion, Program from . import models
from .views.admin import StatisticsView from .views.admin import StatisticsView
__all__ = ["AdminSite"] __all__ = ["AdminSite"]
@ -26,17 +26,20 @@ class AdminSite(admin.AdminSite):
context.update( context.update(
{ {
# all programs # all programs
"programs": Program.objects.active() "programs": models.Program.objects.active()
.values("pk", "title") .values("pk", "title")
.order_by("title"), .order_by("title"),
# today's diffusions # today's diffusions
"diffusions": Diffusion.objects.date() "diffusions": models.Diffusion.objects.date()
.order_by("start") .order_by("start")
.select_related("episode"), .select_related("episode"),
# TODO: only for dashboard # TODO: only for dashboard
# last comments # last comments
"comments": Comment.objects.order_by("-date").select_related( "comments": models.Comment.objects.order_by(
"page" "-date"
).select_related("page")[0:10],
"latests": models.Page.objects.select_subclasses().order_by(
"-pub_date"
)[0:10], )[0:10],
} }
) )

View File

@ -0,0 +1,60 @@
from datetime import datetime, time
import logging
from django.db import transaction
from django.utils import timezone as tz
from aircox.models import Diffusion, Schedule
logger = logging.getLogger("aircox.commands")
__all__ = ("DiffusionMonitor",)
class DiffusionMonitor:
"""Handle generation and update of Diffusion instances."""
date = None
def __init__(self, date):
self.date = date or date.today()
def update(self):
episodes, diffusions = [], []
for schedule in Schedule.objects.filter(
program__active=True, initial__isnull=True
):
eps, diffs = schedule.diffusions_of_month(self.date)
if eps:
episodes += eps
if diffs:
diffusions += diffs
logger.info(
"[update] %s: %d episodes, %d diffusions and reruns",
str(schedule),
len(eps),
len(diffs),
)
with transaction.atomic():
logger.info(
"[update] save %d episodes and %d diffusions",
len(episodes),
len(diffusions),
)
for episode in episodes:
episode.save()
for diffusion in diffusions:
# force episode id's update
diffusion.episode = diffusion.episode
diffusion.save()
def clean(self):
qs = Diffusion.objects.filter(
type=Diffusion.TYPE_UNCONFIRMED,
start__lt=tz.make_aware(datetime.combine(self.date, time.min)),
)
logger.info("[clean] %d diffusions will be removed", qs.count())
qs.delete()

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ from argparse import RawTextHelpFormatter
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone as tz from django.utils import timezone as tz
from aircox.controllers.diffusions import Diffusions from aircox.controllers.diffusion_monitor import DiffusionMonitor
logger = logging.getLogger("aircox.commands") logger = logging.getLogger("aircox.commands")
@ -70,7 +70,7 @@ class Command(BaseCommand):
date += tz.timedelta(days=28) date += tz.timedelta(days=28)
date = date.replace(day=1) date = date.replace(day=1)
actions = Diffusions(date) actions = DiffusionMonitor(date)
if options.get("update"): if options.get("update"):
actions.update() actions.update()
if options.get("clean"): if options.get("clean"):

View File

@ -278,6 +278,16 @@ class Comment(models.Model):
date = models.DateTimeField(auto_now_add=True) date = models.DateTimeField(auto_now_add=True)
content = models.TextField(_("content"), max_length=1024) content = models.TextField(_("content"), max_length=1024)
item_template_name = "aircox/widgets/comment_item.html"
@cached_property
def parent(self):
"""Return Page as its subclass."""
return Page.objects.select_subclasses().filter(id=self.page_id).first()
def get_absolute_url(self):
return self.parent.get_absolute_url()
class Meta: class Meta:
verbose_name = _("Comment") verbose_name = _("Comment")
verbose_name_plural = _("Comments") verbose_name_plural = _("Comments")

View File

@ -65,7 +65,10 @@
<div class="navbar-start"> <div class="navbar-start">
{# Today's diffusions #} {# Today's diffusions #}
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<span class="navbar-link">{% translate "Today" %}</span> <span class="icon-text navbar-link">
<span class="icon"><i class="fa-regular fa-calendar-days"></i></span>
<span>{% translate "Today" %}</span>
</span>
<div class="navbar-dropdown is-boxed"> <div class="navbar-dropdown is-boxed">
{% for diffusion in diffusions %} {% for diffusion in diffusions %}
<a class="navbar-item {% if diffusion.is_now %}has-background-primary{% endif %}" href="{% url "admin:aircox_episode_change" diffusion.episode.pk %}"> <a class="navbar-item {% if diffusion.is_now %}has-background-primary{% endif %}" href="{% url "admin:aircox_episode_change" diffusion.episode.pk %}">
@ -76,23 +79,12 @@
</div> </div>
</div> </div>
{# Programs #}
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="{% url "admin:aircox_program_changelist" %}">{% translate "Programs" %}</a>
<div class="navbar-dropdown is-boxed">
<input type="text" onkeyup="aircox.filter_menu(event)"
placeholder="{% translate "Search" %}" class="navbar-item input" />
<hr class="navbar-divider"/>
{% for program in programs %}
<a class="navbar-item" href="{% url "admin:aircox_program_change" program.pk %}">
{{ program.title }}</a>
{% endfor %}
</div>
</div>
{# Articles #} {# Articles #}
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="{% url "admin:aircox_article_changelist" %}">{% translate "Articles" %}</a> <a class="icon-text navbar-link" href="{% url "admin:aircox_article_changelist" %}">
<span class="icon"><i class="fa fa-newspaper"></i></span>
<span>{% translate "Articles" %}</span>
</a>
<div class="navbar-dropdown is-boxed"> <div class="navbar-dropdown is-boxed">
<input type="text" onkeyup="aircox.filter_menu(event)" <input type="text" onkeyup="aircox.filter_menu(event)"
placeholder="{% translate "Search" %}" class="navbar-item input" /> placeholder="{% translate "Search" %}" class="navbar-item input" />
@ -104,9 +96,29 @@
</div> </div>
</div> </div>
{# Programs #}
<div class="navbar-item has-dropdown is-hoverable">
<a class="icon-text navbar-link" href="{% url "admin:aircox_program_changelist" %}">
<span class="icon"><i class="fa fa-folder"></i></span>
<span>{% translate "Programs" %}</span>
</a>
<div class="navbar-dropdown is-boxed">
<input type="text" onkeyup="aircox.filter_menu(event)"
placeholder="{% translate "Search" %}" class="navbar-item input" />
<hr class="navbar-divider"/>
{% for program in programs %}
<a class="navbar-item" href="{% url "admin:aircox_program_change" program.pk %}">
{{ program.title }}</a>
{% endfor %}
</div>
</div>
{# Episodes #} {# Episodes #}
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link" href="{% url "admin:aircox_episode_changelist" %}">{% translate "Episodes" %}</a> <a class="icon-text navbar-link" href="{% url "admin:aircox_episode_changelist" %}">
<span class="icon"><i class="fa fa-calendar-check"></i></span>
<span>{% translate "Episodes" %}</span>
</a>
<div class="navbar-dropdown is-boxed"> <div class="navbar-dropdown is-boxed">
<input type="text" onkeyup="aircox.filter_menu(event)" <input type="text" onkeyup="aircox.filter_menu(event)"
placeholder="{% translate "Search" %}" class="navbar-item input" /> placeholder="{% translate "Search" %}" class="navbar-item input" />
@ -121,7 +133,10 @@
<div class="navbar-end"> <div class="navbar-end">
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a href="#" class="navbar-link">{% translate "Tools" %}</a> <a href="#" class="icon-text navbar-link">
<span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span>
<span>{% translate "Tools" %}</span>
</a>
<div class="navbar-dropdown is-boxed is-right"> <div class="navbar-dropdown is-boxed is-right">
{% get_admin_tools as admin_tools %} {% get_admin_tools as admin_tools %}
{% for label, url in admin_tools %} {% for label, url in admin_tools %}
@ -131,8 +146,9 @@
</div> </div>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a href="{% url "admin:auth_user_change" user.pk %}" class="navbar-link"> <a href="{% url "admin:auth_user_change" user.pk %}" class="icon-text navbar-link">
{% firstof user.get_short_name user.get_username %} <span class="icon"><i class="fa fa-user"></i></span>
<span>{% firstof user.get_short_name user.get_username %}</span>
</a> </a>
<div class="navbar-dropdown is-boxed is-right"> <div class="navbar-dropdown is-boxed is-right">
{% block userlinks %} {% block userlinks %}
@ -179,16 +195,18 @@
<!-- Content --> <!-- Content -->
<div id="app"> <div id="app">
<div id="content" class="{% block coltype %}colM{% endblock %}"> {% block app %}
{% block pretitle %}{% endblock %} <div id="content" class="{% block coltype %}colM{% endblock %}">
{% block content_title %}{% if title %}<h1 class="title is-3">{{ title }}</h1>{% endif %}{% endblock %} {% block pretitle %}{% endblock %}
{% block content %} {% block content_title %}{% if title %}<h1 class="title is-3">{{ title }}</h1>{% endif %}{% endblock %}
{% block object-tools %}{% endblock %} {% block content %}
{{ content }} {% block object-tools %}{% endblock %}
{{ content }}
{% endblock %}
{% block sidebar %}{% endblock %}
<br class="clear">
</div>
{% endblock %} {% endblock %}
{% block sidebar %}{% endblock %}
<br class="clear">
</div>
</div> </div>
<!-- END Content --> <!-- END Content -->
@ -197,7 +215,9 @@
<!-- END Container --> <!-- END Container -->
{% block player %} {% block player %}
{% if request.station %}
<div id="player">{% include "aircox/widgets/player.html" %}</div> <div id="player">{% include "aircox/widgets/player.html" %}</div>
{% endif %}
{% endblock %} {% endblock %}
</body> </body>

View File

@ -2,92 +2,92 @@
{% load i18n thumbnail %} {% load i18n thumbnail %}
{% block messages %} {% block app %}
{{ block.super }}
<div class="section"> <div class="section">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<h1 class="title is-4">{% translate "Today" %}</h1> <div class="box">
<table class="table is-fullwidth is-striped"> <h1 class="title icon-text is-4">
<tbody> <span class="icon"><i class="fa-regular fa-calendar-days"></i></span>
{% for diffusion in diffusions %} <span>{% translate "Today" %}</span>
{% with episode=diffusion.episode %} </h1>
<tr {% if diffusion.is_now %}class="is-selected"{% endif %}> {% if diffusions %}
<td>{{ diffusion.start|time }} - {{ diffusion.end|time }}</td> <table class="table is-fullwidth is-striped">
<td><img src="{% thumbnail episode.cover 64x64 crop %}"/></td> <tbody>
<td> {% for diffusion in diffusions %}
<a href="{% url "admin:aircox_episode_change" episode.pk %}">{{ episode.title }}</a> {% with episode=diffusion.episode %}
&nbsp; <tr {% if diffusion.is_now %}class="is-selected"{% endif %}>
{% if diffusion.type == diffusion.TYPE_ON_AIR %} <td>{{ diffusion.start|time }} - {{ diffusion.end|time }}</td>
<span class="tag is-info"> <td><img src="{% thumbnail episode.cover 64x64 crop %}"/></td>
<span class="icon is-small"> <td>
{% if diffusion.is_live %} <a href="{% url "admin:aircox_episode_change" episode.pk %}">{{ episode.title }}</a>
<i class="fa fa-microphone"
title="{% translate "Live diffusion" %}"></i>
{% else %}
<i class="fa fa-music"
title="{% translate "Differed diffusion" %}"></i>
{% endif %}
</span>
&nbsp; &nbsp;
{{ diffusion.get_type_display }} {% if diffusion.type == diffusion.TYPE_ON_AIR %}
</span> <span class="tag is-info">
{% elif diffusion.type == diffusion.TYPE_CANCEL %} <span class="icon is-small">
<span class="tag is-danger"> {% if diffusion.is_live %}
{{ diffusion.get_type_display }}</span> <i class="fa fa-microphone"
{% elif diffusion.type == diffusion.TYPE_UNCONFIRMED %} title="{% translate "Live diffusion" %}"></i>
<span class="tag is-warning"> {% else %}
{{ diffusion.get_type_display }}</span> <i class="fa fa-music"
{% endif %} title="{% translate "Differed diffusion" %}"></i>
</td> {% endif %}
</tr> </span>
{% endwith %} &nbsp;
{% endfor %} {{ diffusion.get_type_display }}
</tbody> </span>
</table> {% elif diffusion.type == diffusion.TYPE_CANCEL %}
</div> <span class="tag is-danger">
<div class="column"> {{ diffusion.get_type_display }}</span>
<h1 class="title is-4">{% translate "Latest comments" %}</h1> {% elif diffusion.type == diffusion.TYPE_UNCONFIRMED %}
<table class="table is-fullwidth is-striped"> <span class="tag is-warning">
{% for comment in comments %} {{ diffusion.get_type_display }}</span>
{% with page=comment.page %} {% endif %}
<tr> </td>
<th> </tr>
{{ page.title }} {% endwith %}
</a> {% endfor %}
| </tbody>
<span title="{{ comment.email }}">{{ comment.nickname }}</span> </table>
&mdash; {% else %}
<span>{{ comment.date }}</span> <div class="block has-text-centered">
<span class="float-right"> {% trans "No diffusion is scheduled for today." %}
<a href="{% url "admin:aircox_comment_change" comment.pk %}" </div>
title="{% translate "Edit comment" %}" {% endif %}
aria-label="{% translate "Edit comment" %}"> </div>
<span class="fa fa-edit"></span>
</a> <div class="box">
<a class="has-text-danger" <h1 class="title is-4 icon-text">
title="{% translate "Delete comment" %}" <span class="icon"><i class="fa-regular fa-comments"></i></span>
aria-label="{% translate "Delete comment" %}" <span>{% translate "Latest comments" %}</span>
href="{% url "admin:aircox_comment_delete" comment.pk %}"> </h1>
<span class="fa fa-trash-alt"></span> {% if comments %}
</a> {% include "aircox/widgets/page_list.html" with object_list=comments with_title=True %}
</th> <div class="has-text-centered">
</tr> <a href="{% url "admin:aircox_comment_changelist" %}" class="float-center">{% translate "All comments" %}</a>
<tr> </div>
<td colspan="2"> {% else %}
{{ comment.content|slice:"0:128" }} <p class="block has-text-centered">{% trans "No comment posted yet" %}</p>
</td> {% endif %}
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
<div class="has-text-centered">
<a href="{% url "admin:aircox_comment_changelist" %}" class="float-center">{% translate "All comments" %}</a>
</div> </div>
</div> </div>
<div class="column"> <div class="column">
<div class="box">
<h1 class="title is-4 icon-text">
<span class="icon"><i class="fa-regular fa-newspaper"></i></span>
<span>{% translate "Latest publications" %}</span>
</h1>
{% if latests %}
{% include "aircox/widgets/page_list.html" with object_list=latests no_actions=True %}
{% endif %}
</div>
<div class="box">
<h1 class="title is-4 icon-text">
<span class="icon"><i class="fa fa-screwdriver-wrench"></i></span>
<span>{% translate "Administration" %}</span>
</h1>
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -48,6 +48,7 @@ Usefull context:
}) })
</script> </script>
<div id="app"> <div id="app">
{% block top-nav-container %}
<nav class="navbar has-shadow" role="navigation" aria-label="main navigation"> <nav class="navbar has-shadow" role="navigation" aria-label="main navigation">
<div class="container"> <div class="container">
<div class="navbar-brand"> <div class="navbar-brand">
@ -84,6 +85,7 @@ Usefull context:
</div> </div>
</div> </div>
</nav> </nav>
{% endblock %}
<div class="container"> <div class="container">
<div class="columns is-desktop"> <div class="columns is-desktop">
@ -161,6 +163,8 @@ Usefull context:
<hr> <hr>
</div> </div>
{% block player-container %}
<div id="player">{% include "aircox/widgets/player.html" %}</div> <div id="player">{% include "aircox/widgets/player.html" %}</div>
{% endblock %}
</body> </body>
</html> </html>

View File

@ -0,0 +1,30 @@
{% extends "aircox/base.html" %}
{% load i18n %}
{% block top-nav-container %}
{% if request.station %}{{ block.super }}{% endif %}
{% endblock %}
{% block player-container %}
{% if request.station %}{{ block.super }}{% endif %}
{% endblock %}
{% block head_title %}
{% block title %}{% trans "An error occurred..." %}{% endblock %}
{% if request.station %}
&mdash;
{{ station.name }}
{% endif %}
{% endblock %}
{% block content %}
<article class="message is-danger">
<div class="message-header">
<p>{% block error_title %}{% trans "An error occurred" %}{% endblock %}</p>
</div>
<div class="message-body">
{% block error_content %}{% endblock %}
</div>
</article>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "aircox/errors/base.html" %}
{% load i18n %}
{% block error_title %}{% trans "No station is configured" %}{% endblock %}
{% block error_content %}
{% blocktranslate %}It seems there is no station configured for this website:{% endblocktranslate %}
<ul>
<li>
{% trans "If you are the website administrator, please connect to administration interface." %}
<a href="{% url "admin:login" %}">
{% trans "Go to admin" %}
<span class="icon"><i class="fa fa-external-link"></i></span>
</a>
</li>
<li>
{% trans "If you are a visitor, please contact your favorite radio" %}
<span class="icon text-danger"><i class="fa fa-heart"></i></span>
</li>
</ul>
{% endblock %}

View File

@ -66,6 +66,8 @@ Context variables:
{% endif %} {% endif %}
</div> </div>
{% if not no_actions %}
{% block actions %}{% endblock %} {% block actions %}{% endblock %}
{% endif %}
</article> </article>
{% endif %} {% endif %}

View File

@ -0,0 +1,41 @@
{% load i18n %}
<article class="media item {% block css %}{% endblock%}">
<div class="media-content">
{% if request.user.is_staff %}
<span class="float-right">
<a href="{% url "admin:aircox_comment_change" object.pk %}"
title="{% trans "Edit comment" %}"
aria-label="{% trans "Edit comment" %}">
<span class="fa fa-edit"></span>
</a>
<a class="has-text-danger"
title="{% trans "Delete comment" %}"
aria-label="{% trans "Delete comment" %}"
href="{% url "admin:aircox_comment_delete" object.pk %}">
<span class="fa fa-trash-alt"></span>
</a>
</span>
{% endif %}
{% if with_title %}
<h5 class="title is-5 has-text-weight-normal">
{% block title %}
<a href="{{ object.get_absolute_url }}">{{ object.parent.title }}</a>
{% endblock %}
</h5>
{% endif %}
<div class="subtitle is-6 has-text-weight-light">
{% block subtitle %}
{% if request.user.is_staff %}
<a href="mailto:{{ object.email }}">{{ object.nickname }}</a>
{% else %}
{{ object.nickname }}
{% endif %}
{% endblock %}
&mdash;
{{ object.date }}
</div>
<div class="headline">
{% block headline %}{{ object.content }}{% endblock %}
</div>
</div>
</article>

View File

View File

@ -0,0 +1,56 @@
from datetime import date, datetime, timedelta, time
from django.utils import timezone as tz
import pytest
from aircox import models
from aircox.controllers import diffusion_monitor
now = date.today()
@pytest.fixture
def monitor(logger):
logger = logger._imeta.clone().inject(diffusion_monitor, "logger")
yield diffusion_monitor.DiffusionMonitor(date=now)
logger.release()
class TestDiffusion:
@pytest.mark.django_db
def test_update(self, monitor, schedules, sched_initials, logger):
monitor.update()
diffusions = models.Diffusion.objects.filter(
schedule__in=sched_initials
)
by_date = {}
for diff in diffusions:
assert diff.episode_id
by_date.setdefault(diff.schedule_id, set()).add(
(diff.start, diff.end)
)
for schedule in sched_initials:
if schedule.pk not in by_date:
continue
_, items = schedule.diffusions_of_month(now)
assert all(
(item.start, item.end) in by_date[schedule.pk]
for item in items
)
@pytest.mark.django_db
def test_clean(self, monitor, episode):
start = tz.make_aware(
datetime.combine(monitor.date - timedelta(days=1), time(10, 20))
)
diff = models.Diffusion(
type=models.Diffusion.TYPE_UNCONFIRMED,
episode=episode,
start=start,
end=start + timedelta(minutes=30),
)
diff.save()
monitor.clean()
assert not models.Diffusion.objects.filter(pk=diff.pk).first()

View File

@ -0,0 +1,110 @@
from datetime import timedelta
from django.contrib.auth.models import User, Group
from django.utils import timezone as tz
import pytest
from model_bakery import baker
from aircox.conf import settings
from aircox.models import signals, Diffusion, Episode
@pytest.fixture
def sched(program, sched_initials):
return next(r for r in sched_initials if r.program == program)
@pytest.fixture
def eps_diffs(program, sched):
eps = baker.make(Episode, program=program, _quantity=3)
diffs = []
for ep in eps:
diffs += baker.make(
Diffusion,
start=tz.now() + timedelta(days=10),
end=tz.now() + timedelta(days=10, hours=1),
schedule=sched,
episode=ep,
_quantity=3,
)
return eps, diffs
@pytest.mark.django_db
def test_user_default_groups():
user = User(username="test")
user.save()
default_groups = settings.DEFAULT_USER_GROUPS
groups = Group.objects.filter(name__in=default_groups.keys())
assert groups.exists()
assert all(
set(group.permissions.all().values_list("codename", flat=True))
== set(default_groups[group.name])
for group in groups
)
user_groups = set(user.groups.all().values_list("name", flat=True))
assert set(default_groups.keys()) == user_groups
@pytest.mark.django_db
def test_user_default_groups_skip_on_superuser():
user = User(username="test", is_superuser=True)
user.save()
assert list(user.groups.all()) == []
@pytest.mark.django_db
def test_page_post_save(program, episodes):
episodes = [r for r in episodes if r.program == program]
for episode in episodes:
episode.cover = None
Episode.objects.bulk_update(episodes, ["cover"])
# TODO: cover must be an fk to Image
# program.cover = "dummy/cover.png"
# program.save()
# query = Episode.objects.filter(program=program) \
# .values_list("cover", flat=True)
# assert all(r == program.cover for r in query)
@pytest.mark.django_db
def test_program_post_save(program, eps_diffs):
eps, diffs = eps_diffs
program.active = False
program.save()
eps_ids = [r.id for r in eps]
diff_ids = [r.id for r in diffs]
assert not Episode.objects.filter(id__in=eps_ids).exists()
assert not Diffusion.objects.filter(id__in=diff_ids).exists()
@pytest.mark.django_db
def test_schedule_pre_save(sched_initials):
sched = sched_initials[0]
signals.schedule_pre_save(None, sched)
assert getattr(sched, "_initial")
@pytest.mark.django_db
def test_schedule_post_save():
pass
@pytest.mark.django_db
def test_schedule_pre_delete(sched, eps_diffs):
eps, diffs = eps_diffs
signals.schedule_pre_delete(None, sched)
assert not Episode.objects.filter(id__in=(r.id for r in eps)).exists()
assert not Diffusion.objects.filter(id__in=(r.id for r in diffs)).exists()
@pytest.mark.django_db
def test_diffusion_post_delete(eps_diffs):
eps = eps_diffs[0][0]
Diffusion.objects.filter(
id__in=[r.id for r in eps.diffusion_set.all()]
).delete()
assert Episode.objects.filter(id=eps.id).first() is None

View File

@ -0,0 +1,48 @@
import pytest
from django.utils import timezone as tz
from django.conf import settings
from aircox.middleware import AircoxMiddleware
from .conftest import req_factory
settings.ALLOWED_HOSTS = list(settings.ALLOWED_HOSTS) + ["unknown-host"]
@pytest.fixture
def middleware():
return AircoxMiddleware(lambda r: r)
class TestAircoxMiddleware:
@pytest.mark.django_db
def test_get_station(self, middleware, station, sub_station):
req = req_factory.get("/tmp/test", headers={"host": sub_station.hosts})
assert middleware.get_station(req) == sub_station
req = req_factory.get("/tmp/test", headers={"host": station.hosts})
assert middleware.get_station(req) == station
@pytest.mark.django_db
def test_get_station_use_default(self, middleware, station, stations):
req = req_factory.get("/tmp/test", headers={"host": "unknown-host"})
assert middleware.get_station(req) == station
@pytest.mark.django_db
def test_init_timezone(self, middleware):
req = req_factory.get("/tmp/test")
req.session = {middleware.timezone_session_key: "Europe/Brussels"}
middleware.init_timezone(req)
current_tz = tz.get_current_timezone()
assert current_tz.key == "Europe/Brussels"
@pytest.mark.django_db
def test_init_timezone_wrong_timezone(self, middleware):
req = req_factory.get("/tmp/test")
req.session = {middleware.timezone_session_key: "Oceania/Arlon"}
middleware.init_timezone(req)
current_tz = tz.get_current_timezone()
assert current_tz.key != "Oceania/Arlon"

View File

@ -0,0 +1,45 @@
from datetime import date, datetime, timedelta
import pytest
from aircox import utils
def test_redirect():
with pytest.raises(utils.Redirect):
utils.redirect("/redirect")
def test_str_to_date():
result = utils.str_to_date("2023-01-10", "-")
assert result == date(2023, 1, 10)
def test_cast_date():
val = datetime(2023, 1, 12)
result = utils.cast_date(val)
assert isinstance(result, date)
assert result == val.date()
def test_date_or_default():
result = utils.date_or_default(None, date)
assert isinstance(result, date)
assert result == date.today()
def test_to_timedelta():
val = datetime(2023, 1, 10, hour=20, minute=10, second=1)
assert utils.to_timedelta(val) == timedelta(
hours=20, minutes=10, seconds=1
)
def test_to_seconds():
val = datetime(2023, 1, 10, hour=20, minute=10, second=1)
assert utils.to_seconds(val) == 20 * 3600 + 10 * 60 + 1
def test_seconds_to_time():
val = 20 * 3600 + 10 * 60 + 1
result = utils.seconds_to_time(val)
assert (result.hour, result.minute, result.second) == (20, 10, 1)

View File

View File

@ -0,0 +1,40 @@
import pytest
from model_bakery import baker
from aircox import models
class FakeView:
context = None
kwargs = {}
def ___init__(self):
self.kwargs = {}
def get(self, *args, **kwargs):
pass
def get_queryset(self):
return self.queryset
def get_context_data(self, **kwargs):
return kwargs
@pytest.fixture
def published_pages():
return baker.make(
models.Page, status=models.StaticPage.STATUS_PUBLISHED, _quantity=3
)
@pytest.fixture
def unpublished_pages():
return baker.make(
models.Page, status=models.StaticPage.STATUS_DRAFT, _quantity=3
)
@pytest.fixture
def pages(published_pages, unpublished_pages):
return published_pages + unpublished_pages

View File

@ -0,0 +1,76 @@
import pytest
from django.urls import reverse
from aircox import models
from aircox.test import Interface
from aircox.views import base
from .conftest import FakeView
@pytest.fixture
def fake_request(station):
return Interface(station=station)
@pytest.fixture
def base_view(fake_request):
class View(base.BaseView, FakeView):
model = models.Page
request = fake_request
return View()
@pytest.fixture
def base_api_view(fake_request):
class View(base.BaseAPIView, FakeView):
model = models.Program
queryset = models.Program.objects.all()
request = fake_request
return View()
class TestBaseView:
@pytest.mark.django_db
def test_station(self, base_view, station):
assert base_view.station == station
@pytest.mark.django_db
def test_get_sidebar_queryset(self, base_view, pages, published_pages):
query = base_view.get_sidebar_queryset().values_list("id", flat=True)
page_ids = {r.id for r in published_pages}
assert set(query) == page_ids
@pytest.mark.django_db
def test_get_sidebar_url(self, base_view):
assert base_view.get_sidebar_url() == reverse("page-list")
@pytest.mark.django_db
def test_get_context_data(self, base_view, station, published_pages):
base_view.has_sidebar = True
base_view.get_sidebar_queryset = lambda: published_pages
context = base_view.get_context_data()
assert context == {
"view": base_view,
"station": station,
"page": None, # get_page() returns None
"has_sidebar": base_view.has_sidebar,
"has_filters": False,
"sidebar_object_list": published_pages[: base_view.list_count],
"sidebar_list_url": base_view.get_sidebar_url(),
"audio_streams": station.streams,
"model": base_view.model,
}
class TestBaseAPIView:
@pytest.mark.django_db
def test_station(self, base_api_view, station):
assert base_api_view.station == station
@pytest.mark.django_db
def test_get_queryset(self, base_api_view, station, programs):
query = base_api_view.get_queryset()
assert set(query.values_list("station", flat=True)) == {station.id}

View File

@ -0,0 +1,166 @@
from datetime import date
from django.http import Http404
from model_bakery import baker
import pytest
from aircox import filters, models
from aircox.views import mixins
from aircox.test import Interface
from aircox.tests.conftest import req_factory
from .conftest import FakeView
today = date.today()
@pytest.fixture
def redirect_interface():
iface = Interface.inject(mixins, "redirect", {"__call__": "redirect"})
yield iface
iface._irelease()
@pytest.fixture
def date_mixin():
class Mixin(mixins.GetDateMixin, FakeView):
pass
return Mixin()
@pytest.fixture
def parent_mixin():
class Mixin(mixins.ParentMixin, FakeView):
parent_model = models.Program
return Mixin()
@pytest.fixture
def attach_mixin():
class Mixin(mixins.AttachedToMixin, FakeView):
attach_to_value = models.StaticPage.ATTACH_TO_HOME
return Mixin()
@pytest.fixture
def filters_mixin():
class Mixin(mixins.FiltersMixin, FakeView):
filterset_class = filters.PageFilters
queryset = models.Page.objects.all()
return Mixin()
class TestGetDateMixin:
req = req_factory.get("/test", {"date": today.strftime("%Y-%m-%d")})
def test_get_date(self, date_mixin):
date_mixin.request = self.req
assert date_mixin.get_date() == today
def test_get_date_from_kwargs(self, date_mixin):
date_mixin.request = req_factory.get("/test")
date_mixin.kwargs = {"date": today}
assert date_mixin.get_date() == today
def test_get_date_none_provided(self, date_mixin):
date_mixin.request = req_factory.get("/test")
assert date_mixin.get_date() is None
def test_get_redirect(self, date_mixin, redirect_interface):
date_mixin.redirect_date_url = "redirect_date_url"
date_mixin.request = self.req
assert date_mixin.get() == "redirect"
assert redirect_interface._trace() == (
(date_mixin.redirect_date_url,),
{"date": today.strftime("%Y/%m/%d")},
)
def test_get_calls_get_date(self, date_mixin):
date_mixin.get_date = lambda: today
date_mixin.get()
assert date_mixin.date == today
class TestParentMixin:
req = req_factory.get("/test")
@pytest.mark.django_db
def test_get_parent(self, parent_mixin, program):
parent = parent_mixin.get_parent(self.req, parent_slug=program.slug)
assert parent.pk == program.pk
@pytest.mark.django_db
def test_get_parent_raises_404(self, parent_mixin):
with pytest.raises(Http404):
parent_mixin.get_parent(
self.req, parent_slug="parent-invalid-slug"
)
def test_get_parent_not_parent_model(self, parent_mixin):
parent_mixin.parent_model = None
assert parent_mixin.get_parent(self.req) is None
def test_get_parent_not_parent_url_kwargs(self, parent_mixin):
assert parent_mixin.get_parent(self.req) is None
def test_get_calls_parent(self, parent_mixin):
parent = "parent object"
parent_mixin.get_parent = lambda *_, **kw: parent
parent_mixin.get(self.req)
assert parent_mixin.parent == parent
@pytest.mark.django_db
def test_get_queryset_with_parent(self, parent_mixin, program, episodes):
parent_mixin.queryset = models.Episode.objects.all()
parent_mixin.parent = program
episodes_id = {r.id for r in episodes if r.parent_id == program.id}
query = parent_mixin.get_queryset().values_list("id", flat=True)
assert set(query) == episodes_id
def test_get_context_data_with_parent(self, parent_mixin):
parent_mixin.parent = Interface(cover="parent-cover")
context = parent_mixin.get_context_data()
assert context["cover"] == "parent-cover"
class TestAttachedToMixin:
@pytest.mark.django_db
def test_get_page_with_attach_to(self, attach_mixin):
page = baker.make(
models.StaticPage,
attach_to=attach_mixin.attach_to_value,
status=models.StaticPage.STATUS_PUBLISHED,
)
assert attach_mixin.get_page() == page
class TestFiltersMixin:
req = req_factory.get("/test", {"data": True, "page": "page"})
@pytest.mark.django_db
def test_get_filterset(self, filters_mixin):
filterset = filters_mixin.get_filterset({}, models.Page.objects.all())
assert isinstance(filterset, filters_mixin.filterset_class)
@pytest.mark.django_db
def test_get_queryset(self, filters_mixin):
filterset = Interface(qs="filterset-qs")
filters_mixin.request = self.req
filters_mixin.get_filterset = lambda *_, **__: filterset
assert filters_mixin.get_queryset() == filterset.qs
def test_get_context_data_valid_filterset(self, filters_mixin):
filterset = Interface(
None,
{"is_valid": True},
qs="filterset-qs",
form=Interface(cleaned_data="cleaned_data"),
)
filters_mixin.request = self.req
context = filters_mixin.get_context_data(filterset=filterset)
assert context["filterset_data"] == "cleaned_data"
assert dict(context["get_params"]) == {"data": ["True"]}

View File

@ -109,4 +109,9 @@ urls = [
views.ProgramPageListView.as_view(), views.ProgramPageListView.as_view(),
name="program-page-list", name="program-page-list",
), ),
path(
"errors/no-station",
views.errors.NoStationErrorView.as_view(),
name="errors-no-station",
),
] ]

View File

@ -1,4 +1,4 @@
from . import admin from . import admin, errors
from .article import ArticleDetailView, ArticleListView from .article import ArticleDetailView, ArticleListView
from .base import BaseAPIView, BaseView from .base import BaseAPIView, BaseView
from .diffusion import DiffusionListView from .diffusion import DiffusionListView
@ -20,6 +20,7 @@ from .program import (
__all__ = ( __all__ = (
"admin", "admin",
"errors",
"ArticleDetailView", "ArticleDetailView",
"ArticleListView", "ArticleListView",
"BaseAPIView", "BaseAPIView",

View File

@ -1,3 +1,4 @@
from django.http import HttpResponseRedirect
from django.urls import reverse from django.urls import reverse
from django.views.generic.base import ContextMixin, TemplateResponseMixin from django.views.generic.base import ContextMixin, TemplateResponseMixin
@ -60,6 +61,11 @@ class BaseView(TemplateResponseMixin, ContextMixin):
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)
def dispatch(self, *args, **kwargs):
if not self.request.station:
return HttpResponseRedirect(reverse("errors-no-station"))
return super().dispatch(*args, **kwargs)
# FIXME: rename to sth like [Base]?StationAPIView/Mixin # FIXME: rename to sth like [Base]?StationAPIView/Mixin
class BaseAPIView: class BaseAPIView:

8
aircox/views/errors.py Normal file
View File

@ -0,0 +1,8 @@
from django.views.generic.base import TemplateView
__all__ = ("NoStationErrorView",)
class NoStationErrorView(TemplateView):
template_name = "aircox/errors/no_station.html"

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-05-21 14:30+0000\n" "POT-Creation-Date: 2023-09-12 18:48+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,18 +18,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: aircox_streamer/controllers.py:75
msgid "playing"
msgstr "en cours de lecture"
#: aircox_streamer/controllers.py:77
msgid "paused"
msgstr "pause"
#: aircox_streamer/controllers.py:79
msgid "stopped"
msgstr "arrêt"
#: aircox_streamer/templates/aircox_streamer/source_item.html:18 #: aircox_streamer/templates/aircox_streamer/source_item.html:18
msgid "Edit related program" msgid "Edit related program"
msgstr "Éditer le programme correspondant" msgstr "Éditer le programme correspondant"
@ -103,6 +91,15 @@ msgstr "Recharger"
msgid "Select a station" msgid "Select a station"
msgstr "Sélectionner une station" msgstr "Sélectionner une station"
#: aircox_streamer/urls.py:10 aircox_streamer/views.py:9 #: aircox_streamer/urls.py:13 aircox_streamer/views.py:10
msgid "Streamer Monitor" msgid "Streamer Monitor"
msgstr "Moniteur de stream" msgstr "Moniteur de stream"
#~ msgid "playing"
#~ msgstr "en cours de lecture"
#~ msgid "paused"
#~ msgstr "pause"
#~ msgid "stopped"
#~ msgstr "arrêt"