diff --git a/aircox/conf.py b/aircox/conf.py
index c54f8be..c66bd8d 100755
--- a/aircox/conf.py
+++ b/aircox/conf.py
@@ -86,8 +86,8 @@ class Settings(BaseSettings):
# TODO include content_type in order to avoid clash with potential
# extra applications
# aircox
- "change_program",
- "change_episode",
+ "view_program",
+ "view_episode",
"change_diffusion",
"add_comment",
"change_comment",
diff --git a/aircox/context_processors/__init__.py b/aircox/context_processors/__init__.py
new file mode 100644
index 0000000..e79119a
--- /dev/null
+++ b/aircox/context_processors/__init__.py
@@ -0,0 +1,3 @@
+def station(request):
+ station = request.station
+ return {"station": station, "audio_streams": station.streams}
diff --git a/aircox/migrations/0015_program_editors.py b/aircox/migrations/0015_program_editors.py
new file mode 100644
index 0000000..9a3964f
--- /dev/null
+++ b/aircox/migrations/0015_program_editors.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/aircox/models/program.py b/aircox/models/program.py
index 7a4fd16..7a6e6f3 100644
--- a/aircox/models/program.py
+++ b/aircox/models/program.py
@@ -3,6 +3,8 @@ import os
import shutil
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.models import F
from django.db.models.functions import Concat, Substr
@@ -58,6 +60,7 @@ class Program(Page):
default=True,
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()
detail_url_name = "program-detail"
@@ -80,6 +83,14 @@ class Program(Page):
def excerpts_path(self):
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):
super().__init__(*kargs, **kwargs)
if self.slug:
@@ -109,6 +120,18 @@ class Program(Page):
os.makedirs(path, exist_ok=True)
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:
verbose_name = _("Program")
verbose_name_plural = _("Programs")
@@ -134,6 +157,9 @@ class Program(Page):
shutil.move(abspath, self.abspath)
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):
def station(self, station=None, id=None):
diff --git a/aircox/templates/accounts/profile.html b/aircox/templates/accounts/profile.html
new file mode 100644
index 0000000..127f463
--- /dev/null
+++ b/aircox/templates/accounts/profile.html
@@ -0,0 +1,15 @@
+{% extends "aircox/base.html" %}
+{% load i18n aircox %}
+
+{% block head_title %}
+ {% block title %}{{ user.username }}{% endblock %}
+{% endblock %}
+
+{% block main %}
+
Mes émissions
+
+{% endblock %}
diff --git a/aircox/templates/aircox/base.html b/aircox/templates/aircox/base.html
index 097b4c9..0306d2a 100644
--- a/aircox/templates/aircox/base.html
+++ b/aircox/templates/aircox/base.html
@@ -68,6 +68,7 @@ Usefull context:
{% block top-nav-tools %}
{% endblock %}
+
{% block top-nav-end %}
{% endblock %}
+
+ {% if user.is_authenticated %}
+
+ {% endif %}
diff --git a/aircox/templates/aircox/program_detail.html b/aircox/templates/aircox/program_detail.html
index 267067b..fb2f154 100644
--- a/aircox/templates/aircox/program_detail.html
+++ b/aircox/templates/aircox/program_detail.html
@@ -1,67 +1,90 @@
-{% extends "aircox/page_detail.html" %}
-{% comment %}Detail page of a show{% endcomment %}
-{% load i18n %}
+{% extends "aircox/basepage_detail.html" %}
+{% load static i18n humanize honeypot aircox %}
+{% comment %}
+Base template used to display a Page
-{% include "aircox/program_sidebar.html" %}
+Context:
+- page: page
+- parent: parent page
+{% endcomment %}
-
-{% block header_nav %}
+{% block header_crumbs %}
+{{ block.super }}
+{% if page.category %}
+{% if parent %} / {% endif %} {{ page.category.title }}
+{% endif %}
{% endblock %}
+{% block top-nav-tools %}
+{% has_perm page page.change_permission_codename simple=True as can_edit %}
+{% if can_edit %}
+
+
+
+
+ {% translate "Edit" %}
+
+{% endif %}
+{% endblock %}
-{% block content %}
+{% block main %}
{{ block.super }}
-
-{% with has_headline=False %}
-{% if articles %}
-
- {% translate "Articles" %}
- {% for object in articles %}
- {% include "aircox/widgets/page_item.html" %}
+{% block comments %}
+{% if comments or comment_form %}
+
+ {% translate "Comments" %}
+
+ {% for comment in comments %}
+
{% endfor %}
-
-
+ {% if comment_form %}
+
+ {% endif %}
{% endif %}
-{% endwith %}
{% endblock %}
-
-{% block sidebar %}
-
- {% translate "Diffusions" %}
- {% 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 %}
-
- —
-
- {% endwith %}
- {% endwith %}
-
- {% if schedule.initial %}
- {% with schedule.initial.date as date %}
-
- ({% translate "Rerun" %})
-
- {% endwith %}
- {% endif %}
-
-
- {% endfor %}
-
-{{ block.super }}
{% endblock %}
diff --git a/aircox/templates/aircox/program_form.html b/aircox/templates/aircox/program_form.html
new file mode 100644
index 0000000..b9417b1
--- /dev/null
+++ b/aircox/templates/aircox/program_form.html
@@ -0,0 +1,24 @@
+{% extends "aircox/basepage_detail.html" %}
+{% load static i18n humanize honeypot aircox %}
+
+
+{% block top-nav-tools %}
+
+
+
+
+ {% translate "View" %}
+
+{% endblock %}
+
+{% block main %}
+
+{% endblock %}
diff --git a/aircox/templates/registration/login.html b/aircox/templates/registration/login.html
new file mode 100644
index 0000000..179b47a
--- /dev/null
+++ b/aircox/templates/registration/login.html
@@ -0,0 +1,20 @@
+{% extends "aircox/base.html" %}
+{% load i18n aircox %}
+
+{% block main %}
+
+{% trans "Log in" %}
+
+
+
+{{ block.super }}
+
+{% endblock %}
diff --git a/aircox/templatetags/aircox.py b/aircox/templatetags/aircox.py
index 285d451..81f2842 100644
--- a/aircox/templatetags/aircox.py
+++ b/aircox/templatetags/aircox.py
@@ -30,11 +30,14 @@ def do_get_tracks(obj):
@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]')``"""
if user is None:
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")
diff --git a/aircox/tests/conftest.py b/aircox/tests/conftest.py
index caf5564..a02a67f 100644
--- a/aircox/tests/conftest.py
+++ b/aircox/tests/conftest.py
@@ -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)]
models.Track.objects.bulk_create(items)
return items
+
+
+@pytest.fixture
+def user():
+ return User.objects.create_user(username="user1", password="bar")
diff --git a/aircox/tests/test_permissions.py b/aircox/tests/test_permissions.py
new file mode 100644
index 0000000..a561f33
--- /dev/null
+++ b/aircox/tests/test_permissions.py
@@ -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
diff --git a/aircox/tests/test_profile.py b/aircox/tests/test_profile.py
new file mode 100644
index 0000000..11129c7
--- /dev/null
+++ b/aircox/tests/test_profile.py
@@ -0,0 +1,22 @@
+import pytest
+from django.urls import reverse
+
+
+@pytest.mark.django_db()
+def test_authenticate(user, client, program):
+ r = client.get(reverse("login"))
+ assert r.status_code == 200
+ assert b"id_username" in r.content
+ r = client.post(reverse("login"), kwargs={"username": "foo", "password": "bar"})
+ assert b"errorlist" in r.content
+ assert client.login(username="user1", password="bar")
+
+
+@pytest.mark.django_db()
+def test_profile_programs(user, client, program):
+ client.force_login(user)
+ r = client.get(reverse("profile"))
+ assert program.title not in r.content.decode("utf-8")
+ user.groups.add(program.editors)
+ r = client.get(reverse("profile"))
+ assert program.title in r.content.decode("utf-8")
diff --git a/aircox/tests/test_program.py b/aircox/tests/test_program.py
new file mode 100644
index 0000000..4fc94e9
--- /dev/null
+++ b/aircox/tests/test_program.py
@@ -0,0 +1,40 @@
+import pytest
+from django.urls import reverse
+from django.core.files.uploadedfile import SimpleUploadedFile
+
+from aircox.models import Program
+
+
+png_content = (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
+ + b"\x00\x00\x00\x0cIDATx\x9cc`\xf8\xcf\x00\x00\x02\x02\x01\x00{\t\x81x\x00\x00\x00\x00IEND\xaeB`\x82"
+)
+
+
+@pytest.mark.django_db()
+def test_edit_program(user, client, program):
+ client.force_login(user)
+ response = client.get(reverse("program-detail", kwargs={"slug": program.slug}))
+ assert response.status_code == 200
+ assert b"fa-pen" not in response.content
+ user.groups.add(program.editors)
+ response = client.get(reverse("program-detail", kwargs={"slug": program.slug}))
+ assert b"fa-pen" in response.content
+ assert b"foobar" not in response.content
+ response = client.post(reverse("program-edit", kwargs={"pk": program.pk}), {"content": "foobar"})
+ response = client.get(reverse("program-detail", kwargs={"slug": program.slug}))
+ assert b"foobar" in response.content
+
+
+@pytest.mark.django_db()
+def test_add_cover(user, client, program):
+ assert program.cover is None
+ user.groups.add(program.editors)
+ client.force_login(user)
+ cover = SimpleUploadedFile("cover1.png", png_content, content_type="image/png")
+ r = client.post(
+ reverse("program-edit", kwargs={"pk": program.pk}), {"content": "foobar", "new_cover": cover}, follow=True
+ )
+ assert r.status_code == 200
+ p = Program.objects.get(pk=program.pk)
+ assert "cover1.png" in p.cover.url
diff --git a/aircox/tests/views/test_base.py b/aircox/tests/views/test_base.py
index 4be01a7..9c3c013 100644
--- a/aircox/tests/views/test_base.py
+++ b/aircox/tests/views/test_base.py
@@ -54,13 +54,11 @@ class TestBaseView:
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,
}
diff --git a/aircox/urls.py b/aircox/urls.py
index 0ad7df6..3c6741c 100755
--- a/aircox/urls.py
+++ b/aircox/urls.py
@@ -92,6 +92,11 @@ urls = [
views.ProgramDetailView.as_view(),
name="program-detail",
),
+ path(
+ _("program//edit/"),
+ views.ProgramUpdateView.as_view(),
+ name="program-edit",
+ ),
path(
_("programs//episodes/"),
views.EpisodeListView.as_view(),
@@ -112,4 +117,6 @@ urls = [
views.errors.NoStationErrorView.as_view(),
name="errors-no-station",
),
+ path("gestion/", views.profile, name="profile"),
+ path("accounts/profile/", views.profile, name="profile"),
]
diff --git a/aircox/views/__init__.py b/aircox/views/__init__.py
index e4d9d4a..2d30211 100644
--- a/aircox/views/__init__.py
+++ b/aircox/views/__init__.py
@@ -11,11 +11,13 @@ from .page import (
PageDetailView,
PageListView,
)
+from .profile import profile
from .program import (
ProgramDetailView,
ProgramListView,
ProgramPageDetailView,
ProgramPageListView,
+ ProgramUpdateView,
)
__all__ = (
@@ -35,8 +37,10 @@ __all__ = (
"BasePageListView",
"PageDetailView",
"PageListView",
+ "profile",
"ProgramDetailView",
"ProgramListView",
"ProgramPageDetailView",
"ProgramPageListView",
+ "ProgramUpdateView",
)
diff --git a/aircox/views/base.py b/aircox/views/base.py
index 6e6d597..c971dcc 100644
--- a/aircox/views/base.py
+++ b/aircox/views/base.py
@@ -33,7 +33,6 @@ class BaseView(TemplateResponseMixin, ContextMixin):
return None
def get_context_data(self, **kwargs):
- kwargs.setdefault("station", self.station)
kwargs.setdefault("page", self.get_page())
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_list_url"] = self.get_sidebar_url()
- if "audio_streams" not in kwargs:
- kwargs["audio_streams"] = self.station.streams
-
if "model" not in kwargs:
model = getattr(self, "model", None) or hasattr(self, "object") and type(self.object)
kwargs["model"] = model
diff --git a/aircox/views/page.py b/aircox/views/page.py
index 7a0ef42..6f43d40 100644
--- a/aircox/views/page.py
+++ b/aircox/views/page.py
@@ -1,6 +1,7 @@
from django.http import Http404, HttpResponse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
+from django.views.generic.edit import UpdateView
from honeypot.decorators import check_honeypot
from ..filters import PageFilters
@@ -138,3 +139,10 @@ class PageDetailView(BasePageDetailView):
comment.page = self.object
comment.save()
return self.get(request, *args, **kwargs)
+
+
+class PageUpdateView(BaseView, UpdateView):
+ context_object_name = "page"
+
+ def get_page(self):
+ return self.object
diff --git a/aircox/views/profile.py b/aircox/views/profile.py
new file mode 100644
index 0000000..4e002fa
--- /dev/null
+++ b/aircox/views/profile.py
@@ -0,0 +1,15 @@
+from django.contrib.auth.decorators import login_required
+from django.template.response import TemplateResponse
+
+from aircox.models import Program
+
+
+@login_required
+def profile(request):
+ programs = []
+ ugroups = request.user.groups.all()
+ for p in Program.objects.all():
+ if p.editors in ugroups:
+ programs.append(p)
+ context = {"user": request.user, "programs": programs}
+ return TemplateResponse(request, "accounts/profile.html", context)
diff --git a/aircox/views/program.py b/aircox/views/program.py
index fb36d7d..56e27cf 100644
--- a/aircox/views/program.py
+++ b/aircox/views/program.py
@@ -1,8 +1,12 @@
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.forms import ModelForm, ImageField
from django.urls import reverse
+from filer.models.imagemodels import Image
+
from ..models import Page, Program, StaticPage
from .mixins import ParentMixin
-from .page import PageDetailView, PageListView
+from .page import PageDetailView, PageListView, PageUpdateView
__all__ = ["ProgramPageDetailView", "ProgramDetailView", "ProgramPageListView"]
@@ -23,10 +27,43 @@ class BaseProgramMixin:
class ProgramDetailView(BaseProgramMixin, PageDetailView):
model = Program
+ def get_template_names(self):
+ return super().get_template_names() + ["aircox/program_detail.html"]
+
def get_sidebar_queryset(self):
return super().get_sidebar_queryset().filter(parent=self.program)
+class ProgramForm(ModelForm):
+ new_cover = ImageField(required=False)
+
+ class Meta:
+ model = Program
+ fields = ["content"]
+
+ def save(self, commit=True):
+ file_obj = self.cleaned_data["new_cover"]
+ if file_obj:
+ obj, _ = Image.objects.get_or_create(original_filename=file_obj.name, file=file_obj)
+ self.instance.cover = obj
+ super().save(commit=commit)
+
+
+class ProgramUpdateView(UserPassesTestMixin, BaseProgramMixin, PageUpdateView):
+ model = Program
+ form_class = ProgramForm
+
+ 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)
+
+ def get_success_url(self):
+ return reverse("program-detail", kwargs={"slug": self.get_object().slug})
+
+
class ProgramListView(PageListView):
model = Program
attach_to_value = StaticPage.ATTACH_TO_PROGRAMS
diff --git a/instance/settings/base.py b/instance/settings/base.py
index d18dbd6..850e831 100755
--- a/instance/settings/base.py
+++ b/instance/settings/base.py
@@ -237,6 +237,7 @@ TEMPLATES = [
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
+ "aircox.context_processors.station",
),
"loaders": (
"django.template.loaders.filesystem.Loader",
@@ -248,3 +249,5 @@ TEMPLATES = [
WSGI_APPLICATION = "instance.wsgi.application"
+
+LOGOUT_REDIRECT_URL = "/"
diff --git a/instance/urls.py b/instance/urls.py
index af36db2..1acda1b 100755
--- a/instance/urls.py
+++ b/instance/urls.py
@@ -23,6 +23,7 @@ import aircox.urls
urlpatterns = aircox.urls.urls + [
path("admin/", admin.site.urls),
+ path("accounts/", include("django.contrib.auth.urls")),
path("ckeditor/", include("ckeditor_uploader.urls")),
path("filer/", include("filer.urls")),
]