wip-1.0-132 #133

Closed
ctactic wants to merge 8 commits from wip-1.0-132 into develop-1.0
22 changed files with 375 additions and 64 deletions

View File

@ -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",

View File

@ -0,0 +1,3 @@
def station(request):
station = request.station
return {"station": station, "audio_streams": station.streams}

View 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",
),
),
]

View File

@ -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 f"{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):

View File

@ -0,0 +1,14 @@
{% extends "aircox/base.html" %}
{% load i18n aircox %}
{% block main %}
<h2>Mes émissions</h2>
<ul>
{% for p in programs %}
<li><a href="{% url 'program-detail' slug=p.slug %}">{{ p.title }}</a></li>
{% endfor %}
</ul>
{% endblock %}

View File

@ -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,13 @@ Usefull context:
</form> </form>
</div> </div>
{% endblock %} {% endblock %}
{% if user.is_authenticated %}
<a>{{ user.username }}!</a> <a href="{% url 'logout' %}"><i class="fa fa-sign-out"></i></a>
{% else %}
<!-- <a href="{% url 'login' %}"><i class="fa fa-unlock"></i></a> -->
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -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>&nbsp;
<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>
&mdash;
<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 %}

View 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>&nbsp;
<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 %}

View File

@ -0,0 +1,19 @@
{% extends "aircox/base.html" %}
{% load i18n aircox %}
{% block main %}
<h2>{% trans "Log in" %}</h2>
<br/>
<form method="post">
{% csrf_token %}
<table>
{{ form.as_table }}
</table>
<br/>
<button type="submit">{% trans "Log in" %}</button>
</form>
{{ block.super }}
{% endblock %}

View File

@ -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")

View File

@ -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")

View 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

View 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

View File

@ -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,
} }

View File

@ -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(),
@ -112,4 +117,5 @@ urls = [
views.errors.NoStationErrorView.as_view(), views.errors.NoStationErrorView.as_view(),
name="errors-no-station", name="errors-no-station",
), ),
path("gestion/", views.gestion, name="gestion"),
] ]

View File

@ -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",
) )

View File

@ -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

15
aircox/views/gestion.py Normal file
View 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 gestion(request):
programs = []
ugroups = request.user.groups.all()
for p in Program.objects.all():
if p.editors in ugroups:
programs.append(p)
context = {"programs": programs}
return TemplateResponse(request, "accounts/gestion.html", context)

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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")),
] ]