This commit is contained in:
bkfox 2024-04-19 15:06:23 +02:00
parent 1d321a0de6
commit 07d72d799d
60 changed files with 503 additions and 306 deletions

View File

@ -233,12 +233,12 @@ class SoundMonitor:
if not program.ensure_dir(subdir): if not program.ensure_dir(subdir):
return return
subdir = os.path.join(program.abspath, subdir) abs_subdir = os.path.join(program.abspath, subdir)
sounds = [] sounds = []
# sounds in directory # sounds in directory
for path in os.listdir(subdir): for path in os.listdir(abs_subdir):
path = os.path.join(subdir, path) path = os.path.join(abs_subdir, path)
if not path.endswith(settings.SOUND_FILE_EXT): if not path.endswith(settings.SOUND_FILE_EXT):
continue continue
@ -247,7 +247,7 @@ class SoundMonitor:
sounds.append(sound_file.sound.pk) sounds.append(sound_file.sound.pk)
# sounds in db & unchecked # sounds in db & unchecked
sounds = Sound.objects.filter(file__startswith=subdir).exclude(pk__in=sounds) sounds = Sound.objects.filter(file__startswith=program.path).exclude(pk__in=sounds)
self.check_sounds(sounds, program=program) self.check_sounds(sounds, program=program)
def check_sounds(self, qs, **sync_kwargs): def check_sounds(self, qs, **sync_kwargs):

View File

@ -1,102 +0,0 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
__all__ = ("CommentForm", "PageForm", "ProgramForm", "EpisodeForm", "SoundForm", "EpisodeSoundFormSet", "TrackFormSet")
class CommentForm(forms.ModelForm):
nickname = forms.CharField()
email = forms.EmailField(required=False)
content = forms.CharField(widget=forms.Textarea())
nickname.widget.attrs.update({"class": "input"})
email.widget.attrs.update({"class": "input"})
content.widget.attrs.update({"class": "textarea"})
class Meta:
model = models.Comment
fields = ["nickname", "email", "content"]
class ImageForm(forms.Form):
file = forms.ImageField()
class PageForm(forms.ModelForm):
class Meta:
fields = ("title", "category", "status", "cover", "content")
model = models.Page
class ChildPageForm(forms.ModelForm):
class Meta:
fields = ("title", "status", "cover", "content")
model = models.Page
class ProgramForm(PageForm):
class Meta:
fields = PageForm.Meta.fields
model = models.Program
class EpisodeForm(PageForm):
class Meta:
model = models.Episode
fields = ChildPageForm.Meta.fields
class SoundForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"]
class SoundCreateForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"]
widgets = {"program": forms.HiddenInput()}
TrackFormSet = modelformset_factory(
models.Track,
fields=[
"position",
"episode",
"artist",
"title",
"tags",
],
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
can_delete=True,
extra=0,
)
"""Track formset used in EpisodeUpdateView."""
EpisodeSoundFormSet = modelformset_factory(
models.EpisodeSound,
fields=(
"position",
"episode",
"sound",
"broadcast",
),
widgets={
"broadcast": forms.CheckboxInput(),
"episode": forms.HiddenInput(),
# "sound": forms.HiddenInput(),
"position": forms.HiddenInput(),
},
can_delete=True,
extra=0,
)

23
aircox/forms/__init__.py Normal file
View File

@ -0,0 +1,23 @@
from . import widgets
from .auth import UserGroupFormSet
from .episode import EpisodeForm, EpisodeSoundFormSet
from .program import ProgramForm
from .page import CommentForm, ImageForm, PageForm, ChildPageForm
from .sound import SoundForm, SoundCreateForm
__all__ = (
widgets,
# ---- forms
EpisodeForm,
EpisodeSoundFormSet,
ProgramForm,
CommentForm,
ImageForm,
PageForm,
ChildPageForm,
SoundForm,
SoundCreateForm,
UserGroupFormSet,
)

20
aircox/forms/auth.py Normal file
View File

@ -0,0 +1,20 @@
from django import forms
from django.forms.models import modelformset_factory
from django.contrib.auth.models import User
from aircox.forms import widgets
__all__ = ("UserGroupFormSet",)
UserGroupFormSet = modelformset_factory(
User.groups.through,
fields=("group", "user"),
widgets={
"group": forms.HiddenInput(),
"user": widgets.VueAutoComplete("api:usergroup-autocomplete", lookup="username"),
},
extra=0,
can_delete=True,
)

34
aircox/forms/episode.py Normal file
View File

@ -0,0 +1,34 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
from .page import ChildPageForm
__all__ = ("EpisodeForm", "EpisodeSoundFormSet")
class EpisodeForm(ChildPageForm):
class Meta:
model = models.Episode
fields = ChildPageForm.Meta.fields
EpisodeSoundFormSet = modelformset_factory(
models.EpisodeSound,
fields=(
"position",
"episode",
"sound",
"broadcast",
),
widgets={
"broadcast": forms.CheckboxInput(),
"episode": forms.HiddenInput(),
# "sound": forms.HiddenInput(),
"position": forms.HiddenInput(),
},
can_delete=True,
extra=0,
)
"""Formset used in EpisodeUpdateView."""

37
aircox/forms/page.py Normal file
View File

@ -0,0 +1,37 @@
from django import forms
from aircox import models
__all__ = ("CommentForm", "ImageForm", "PageForm", "ChildPageForm")
class CommentForm(forms.ModelForm):
nickname = forms.CharField()
email = forms.EmailField(required=False)
content = forms.CharField(widget=forms.Textarea())
nickname.widget.attrs.update({"class": "input"})
email.widget.attrs.update({"class": "input"})
content.widget.attrs.update({"class": "textarea"})
class Meta:
model = models.Comment
fields = ["nickname", "email", "content"]
class ImageForm(forms.Form):
file = forms.ImageField()
class PageForm(forms.ModelForm):
class Meta:
fields = ("title", "category", "status", "cover", "content")
model = models.Page
class ChildPageForm(forms.ModelForm):
class Meta:
fields = ("title", "status", "cover", "content")
model = models.Page

11
aircox/forms/program.py Normal file
View File

@ -0,0 +1,11 @@
from aircox import models
from .page import PageForm
__all__ = ("ProgramForm",)
class ProgramForm(PageForm):
class Meta:
fields = PageForm.Meta.fields
model = models.Program

26
aircox/forms/sound.py Normal file
View File

@ -0,0 +1,26 @@
from django import forms
from aircox import models
__all__ = (
"SoundForm",
"SoundCreateForm",
)
class SoundForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "duration", "is_public", "is_downloadable"]
class SoundCreateForm(forms.ModelForm):
"""SoundForm used in EpisodeUpdateView."""
class Meta:
model = models.Sound
fields = ["name", "program", "file", "broadcast", "is_public", "is_downloadable"]
widgets = {"program": forms.HiddenInput()}

23
aircox/forms/track.py Normal file
View File

@ -0,0 +1,23 @@
from django import forms
from django.forms.models import modelformset_factory
from aircox import models
__all__ = ("TrackFormSet",)
TrackFormSet = modelformset_factory(
models.Track,
fields=[
"position",
"episode",
"artist",
"title",
"tags",
],
widgets={"episode": forms.HiddenInput(), "position": forms.HiddenInput()},
can_delete=True,
extra=0,
)
"""Track formset used in EpisodeUpdateView."""

View File

@ -4,14 +4,6 @@ from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
def init_groups_and_permissions(app, schema_editor):
from aircox.permissions import program_permissions
Program = app.get_model("aircox", "Program")
for program in Program.objects.all():
program_permissions.init(program)
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("auth", "0012_alter_user_first_name_max_length"), ("auth", "0012_alter_user_first_name_max_length"),
@ -25,10 +17,9 @@ class Migration(migrations.Migration):
field=models.ForeignKey( field=models.ForeignKey(
blank=True, blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.SET_NULL,
to="auth.group", to="auth.group",
verbose_name="editors", verbose_name="editors",
), ),
), ),
migrations.RunPython(init_groups_and_permissions),
] ]

View File

@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("filer", "0017_image__transparent"), ("filer", "0017_image__transparent"),
("aircox", "0021_alter_schedule_timezone"), ("aircox", "0022_set_group_ownership"),
] ]
operations = [ operations = [

View File

@ -10,7 +10,7 @@ sounds_info = {}
def get_sounds_info(apps, schema_editor): def get_sounds_info(apps, schema_editor):
Sound = apps.get_model("aircox", "Sound") Sound = apps.get_model("aircox", "Sound")
objs = Sound.objects.filter(episode__isnull=False).values( objs = Sound.objects.filter().values(
"pk", "pk",
"episode_id", "episode_id",
"position", "position",
@ -36,7 +36,7 @@ def restore_sounds_info(apps, schema_editor):
sound.broadcast = info["type"] == TYPE_ARCHIVE sound.broadcast = info["type"] == TYPE_ARCHIVE
sound.is_removed = info["type"] == TYPE_REMOVED sound.is_removed = info["type"] == TYPE_REMOVED
sounds.append(sound) sounds.append(sound)
if not sound.is_removed: if not sound.is_removed and info["episode_id"]:
obj = EpisodeSound( obj = EpisodeSound(
sound=sound, sound=sound,
episode_id=info["episode_id"], episode_id=info["episode_id"],

View File

@ -33,11 +33,6 @@ def _restore_for_objs(objs):
return updated return updated
def set_group_ownership(*args):
for program in Program.objects.all():
program.set_group_ownership()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("aircox", "0026_alter_sound_options_remove_sound_episode_and_more"), ("aircox", "0026_alter_sound_options_remove_sound_episode_and_more"),

View File

@ -107,7 +107,10 @@ class File(models.Model):
def file_updated(self): def file_updated(self):
"""Return True when file has been updated on filesystem.""" """Return True when file has been updated on filesystem."""
return self.mtime != self.get_mtime() or self.is_removed != (not self.file_exists()) exists = self.file_exists()
if self.is_removed != (not exists):
return True
return exists and self.mtime != self.get_mtime()
def file_exists(self): def file_exists(self):
"""Return true if the file still exists.""" """Return true if the file still exists."""
@ -130,7 +133,7 @@ class File(models.Model):
name = name.replace("_", " ").strip() name = name.replace("_", " ").strip()
is_removed = not self.file_exists() is_removed = not self.file_exists()
mtime = self.get_mtime() mtime = (not is_removed and self.get_mtime()) or None
changed = is_removed != self.is_removed or mtime != self.mtime or name != self.name changed = is_removed != self.is_removed or mtime != self.mtime or name != self.name
self.name, self.is_removed, self.mtime = name, is_removed, mtime self.name, self.is_removed, self.mtime = name, is_removed, mtime

View File

@ -63,7 +63,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_group = models.ForeignKey(Group, models.CASCADE, blank=True, null=True, verbose_name=_("editors")) editors_group = models.ForeignKey(Group, models.CASCADE, verbose_name=_("editors"))
objects = ProgramQuerySet.as_manager() objects = ProgramQuerySet.as_manager()
detail_url_name = "program-detail" detail_url_name = "program-detail"

View File

@ -24,7 +24,7 @@ class SoundQuerySet(FileQuerySet):
def broadcast(self): def broadcast(self):
"""Return sounds that are archives.""" """Return sounds that are archives."""
return self.filter(broadcast=True) return self.filter(broadcast=True, is_removed=False)
def playlist(self, order_by="file"): def playlist(self, order_by="file"):
"""Return files absolute paths as a flat list (exclude sound without """Return files absolute paths as a flat list (exclude sound without

View File

@ -7,7 +7,7 @@ from django.contrib.contenttypes.models import ContentType
from .models import Program from .models import Program
__all__ = ("PagePermissions", "program_permissions") __all__ = ("PagePermissions", "program")
class PagePermissions: class PagePermissions:
@ -30,7 +30,6 @@ class PagePermissions:
"""Return True wether if user can edit Program or its children.""" """Return True wether if user can edit Program or its children."""
from .models.page import ChildPage from .models.page import ChildPage
breakpoint()
if isinstance(obj, ChildPage): if isinstance(obj, ChildPage):
obj = obj.parent_subclass obj = obj.parent_subclass
@ -43,20 +42,23 @@ class PagePermissions:
perm = self.perms_codename_format.format(self=self, perm=perm) perm = self.perms_codename_format.format(self=self, perm=perm)
return user.has_perm(perm) return user.has_perm(perm)
# TODO: bulk init def init(self, obj, model=None):
def init(self, obj):
"""Initialize permissions for the provided obj.""" """Initialize permissions for the provided obj."""
updated = False
created_groups = [] created_groups = []
# init groups # init groups
for infos in self.groups: for infos in self.groups:
group = getattr(obj, infos["field"]) group = getattr(obj, infos["field"])
if obj.pk == 12417:
breakpoint()
if not group: if not group:
group, created = self.init_group(obj, infos) group, created = self.init_group(obj, infos)
setattr(obj, infos["field"], group.pk) setattr(obj, infos["field"], group.pk)
updated = True
created and created_groups.append((group, infos)) created and created_groups.append((group, infos))
if created_groups: if updated:
obj.save() obj.save()
# init perms # init perms
@ -79,4 +81,4 @@ class PagePermissions:
group.permissions.add(perm) group.permissions.add(perm)
program_permissions = PagePermissions(Program) program = PagePermissions(Program)

View File

@ -1,3 +1,4 @@
from . import auth
from .admin import TrackSerializer, UserSettingsSerializer from .admin import TrackSerializer, UserSettingsSerializer
from .episode import EpisodeSoundSerializer, EpisodeSerializer from .episode import EpisodeSoundSerializer, EpisodeSerializer
from .log import LogInfo, LogInfoSerializer from .log import LogInfo, LogInfoSerializer
@ -5,6 +6,7 @@ from .page import CommentSerializer
from .sound import SoundSerializer from .sound import SoundSerializer
__all__ = ( __all__ = (
"auth",
"CommentSerializer", "CommentSerializer",
"LogInfo", "LogInfo",
"LogInfoSerializer", "LogInfoSerializer",

View File

@ -10,6 +10,7 @@ Usefull context:
{% endcomment %} {% endcomment %}
<html> <html>
<head> <head>
{% block head %}
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="application-name" content="aircox" /> <meta name="application-name" content="aircox" />
<meta name="description" content="{{ site.description }}" /> <meta name="description" content="{{ site.description }}" />
@ -27,17 +28,17 @@ Usefull context:
{% endblock %} {% endblock %}
<title> <title>
{% block head_title %} {% block head-title %}
{% if page and page.title %}{{ page.title }} &mdash; {{ station.name }} {% if page and page.title %}{{ page.title }} &mdash;
{% else %}{{ station.name }}
{% endif %} {% endif %}
{{ station.name }}
{% endblock %} {% endblock %}
</title> </title>
{% block head_extra %}{% endblock %} {% endblock %}
</head> </head>
<body {% if request.is_mobile %}class="mobile"{% endif %}> <body {% if request.is_mobile %}class="mobile"{% endif %}>
{% block body-head %}{% endblock %} {% block body %}
<script id="init-script"> <script id="init-script">
window.addEventListener('load', function() { window.addEventListener('load', function() {
{% block init-scripts %} {% block init-scripts %}
@ -65,7 +66,7 @@ Usefull context:
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
{% include "./dashboard/widgets/nav.html" %} {% include "./widgets/nav.html" %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
@ -115,7 +116,6 @@ Usefull context:
{% endblock %} {% endblock %}
</span> </span>
{% endspaceless %} {% endspaceless %}
</span>
</div> </div>
{% endblock %} {% endblock %}
</div> </div>
@ -157,5 +157,7 @@ Usefull context:
{% block player-container %} {% block player-container %}
<div id="player">{% include "aircox/widgets/player.html" %}</div> <div id="player">{% include "aircox/widgets/player.html" %}</div>
{% endblock %} {% endblock %}
{% endblock %}
</body> </body>
</html> </html>

View File

@ -1,10 +0,0 @@
{% extends "aircox/base.html" %}
{% comment %}Display detail of a BasePage{% endcomment %}
{% block head_title %}
{% block title %}{{ page.title }}{% endblock %}
&mdash;
{{ station.name }}
{% endblock %}
{% block header %}{% if page %}{{ block.super }}{% endif %}{% endblock %}

View File

@ -1,16 +1,8 @@
{% extends "./base.html" %} {% extends "./public.html" %}
{% comment %}Display a list of BasePages{% endcomment %} {% comment %}Display a list of BasePages{% endcomment %}
{% load i18n aircox %} {% load i18n aircox %}
{% block head_title %}
{% block title %}
{{ block.super }}
{% endblock %}
&mdash;
{{ station.name }}
{% endblock %}
{% block main %} {% block main %}
{{ block.super }} {{ block.super }}

View File

@ -8,7 +8,8 @@
{% endblock %} {% endblock %}
{% block head-title %} {% block head-title %}
{% block title %}{{ block.super }}{% endblock %} {% block title %}
&mdash; {% if page and page.title %}{{ page.title }} &mdash;{% endif %}
{{ block.super }} {% endblock %}
{{ station.name }}
{% endblock %} {% endblock %}

View File

@ -29,11 +29,11 @@ sound-delete-url="{% url "api:sound-detail" pk=123 %}"
{% for field in sound_form %} {% for field in sound_form %}
{% with field.name as name %} {% with field.name as name %}
{% if name in "program" %} {% if name in "program" %}
{% include "./form_field.html" with value=field.initial field=field.field hidden=True %} {% include "aircox/forms/form_field.html" with value=field.initial field=field.field hidden=True %}
{% elif name != "file" %} {% elif name != "file" %}
<div class="field is-horizontal"> <div class="field is-horizontal">
<label class="label mr-3">{{ field.label }}</label> <label class="label mr-3">{{ field.label }}</label>
{% include "./form_field.html" with value=field.initial field=field.field %} {% include "aircox/forms/form_field.html" with value=field.initial field=field.field %}
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}

View File

@ -1,7 +1,7 @@
{% extends "./page_form.html" %} {% extends "./page_form.html" %}
{% load static i18n humanize honeypot aircox %} {% load static i18n humanize honeypot aircox %}
{% block page_form %} {% block page-form %}
<a-episode :page="{title: &quot;{{ object.title }}&quot;, podcasts: {{ object.sounds|json }}}"> <a-episode :page="{title: &quot;{{ object.title }}&quot;, podcasts: {{ object.sounds|json }}}">
<template v-slot="{podcasts,page}"> <template v-slot="{podcasts,page}">
{{ block.super }} {{ block.super }}

View File

@ -18,7 +18,7 @@
{% endblock %} {% endblock %}
{% block content-container %} {% block content %}
<article class="message is-danger"> <article class="message is-danger">
<div class="message-header"> <div class="message-header">
<p>{% block error_title %}{% trans "An error occurred" %}{% endblock %}</p> <p>{% block error_title %}{% trans "An error occurred" %}{% endblock %}</p>

View File

@ -0,0 +1,25 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":value" attribute
- vbind: if True, use ":value" instead of "value"
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.widget.is_hidden or hidden %}
<input type="hidden" name="{{ name }}" value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" name="{{ name }}" {% if value %}checked{% endif %}>
{% elif field|is_select %}
<select name="{{ name }}" class="select" value="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" name="{{ name }}" value="{{ value|default:"" }}">
{% endif %}

View File

@ -0,0 +1,54 @@
{% comment %}
Base template for list editor based on formsets (tracklist_editor, playlist_editor).
Context:
- tag_id: id of parent component
- tag: vue component tag (a-playlist-editor, etc.)
- related_field: field name that target object
- object: related object
- formset: formset used to render the list editor
- formset_data: formset data
{% endcomment %}
{% load aircox aircox_admin static i18n %}
{% with formset.form.base_fields as fields %}
{% block outer %}
<div id="{{ tag_id }}">
{{ formset.non_form_errors }}
<!-- formset.management_form -->
<{{ tag|default:"a-form-set" }}
{% block tag-attrs %}
:form-data="{{ formset_data|json }}"
:labels="window.aircox.labels"
:init-data="{% formset_inline_data formset=formset %}"
:columns="[{% for n, f in fields.items %}{% if not f.widget.is_hidden %}'{{ n }}',{% endif %}{% endfor %} ]"
settings-url="{% url "api:user-settings" %}"
data-prefix="{{ formset.prefix }}-"
{% endblock %}>
{% block inner %}
<template #rows-header-head>
{% block rows-header-head %}
<th style="max-width:2em" title="{{ fields.position.help_text }}"
aria-description="{{ fields.position.help_text }}">
<span class="icon">
<i class="fa fa-arrow-down-1-9"></i>
</span>
</th>
{% endblock %}
</template>
{% for name, field in fields.items %}
{% if not field.widget.is_hidden and not field.is_readonly %}
<template v-slot:control-{{ name }}="{item,cell,value,attr,emit,inputName}">
{% block row-control %}
{% include "./v_form_field.html" with value="item.data."|add:name name="inputName" %}
{% endblock %}
</template>
{% endif %}
{% endfor %}
{% endblock %}
</{{ tag }}>
</div>
{% endblock %}
{% endwith %}

View File

@ -0,0 +1,24 @@
{% comment %}
Render a form field instance as field (to be used when no model instance is provided). Value is binded as vue, class to Bulma
Context:
- name: field name
- field: form field
- value: input ":v-model" attribute
- hidden: if True, hidden field
{% endcomment %}
{% load aircox %}
{% if field.widget.is_hidden or hidden %}
<input type="hidden" :name="{{ name }}" :value="{{ value|default:"" }}">
{% elif field|is_checkbox %}
<input type="checkbox" class="checkbox" :name="{{ name }}" v-model="{{ value }}">
{% elif field|is_select %}
<select :name="{{ name }}" class="select" v-model="{{ value|default:"" }}">
{% for value, label in field.widget.choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
{% else %}
<input type="text" class="input" :name="{{ name }}" v-model="{{ value|default:"" }}">
{% endif %}

View File

@ -1,4 +1,4 @@
{% extends "aircox/base.html" %} {% extends "./public.html" %}
{% load i18n aircox %} {% load i18n aircox %}
{% block head_title %}{{ station.name }}{% endblock %} {% block head_title %}{{ station.name }}{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends "aircox/basepage_detail.html" %} {% extends "aircox/public.html" %}
{% load static i18n humanize honeypot aircox %} {% load static i18n humanize honeypot aircox %}
{% comment %} {% comment %}
Base template used to display a Page Base template used to display a Page

View File

@ -1,4 +1,4 @@
{% extends "./page_detail.html" %} {% extends "./dashboard/base.html" %}
{% load static aircox_admin i18n %} {% load static aircox_admin i18n %}
{% block assets %} {% block assets %}
@ -20,6 +20,7 @@ aircox.labels = {% inline_labels %}
</div> </div>
{% endblock %} {% endblock %}
{% block content-container %} {% block content-container %}
<a-select-file ref="cover-select" <a-select-file ref="cover-select"
:labels="window.aircox.labels" :labels="window.aircox.labels"
@ -54,7 +55,7 @@ aircox.labels = {% inline_labels %}
<section class="container"> <section class="container">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% block page_form %} {% block page-form %}
{% csrf_token %} {% csrf_token %}
{% for field in form %} {% for field in form %}
{% if field.name == "cover" %} {% if field.name == "cover" %}
@ -69,7 +70,7 @@ aircox.labels = {% inline_labels %}
{% elif field.name == "content" %} {% elif field.name == "content" %}
<textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea> <textarea name="{{ field.name }}" class="is-fullwidth height-25">{{ field.value|striptags|safe }}</textarea>
{% else %} {% else %}
{% include "./dashboard/widgets/form_field.html" with field=field.field name=field.name value=field.initial %} {% include "aircox/forms/form_field.html" with field=field.field name=field.name value=field.initial %}
{% endif %} {% endif %}
</div> </div>
<p class="help">{{ field.help_text }}</p> <p class="help">{{ field.help_text }}</p>

View File

@ -1,4 +1,4 @@
{% extends "aircox/page_detail.html" %} {% extends "./page_form.html" %}
{% load static i18n humanize honeypot aircox %} {% load static i18n humanize honeypot aircox %}
@ -6,34 +6,13 @@
{{ form.media }} {{ form.media }}
{% endblock %} {% endblock %}
{% block init-scripts %} {% block page-form %}
{% endblock %} //////
{{ block.super }}
{% block comments %} {% if editors_formset %}
{% endblock %} <hr/>
<h2 class="title is-2">{% translate "Editors" %}</h2>
{% block content-container %} {% include "./widgets/usergroup_formset.html" with formset=editors_formset formset_data=editors_formset_data tag_id="usergroup_formset" %}
<section class="container"> {% endif %}
<div>
<form method="post" enctype="multipart/form-data">{% csrf_token %}
{% csrf_token %}
{% for field in form %}
<div class="field is-horizontal">
<label class="label">{{ field.label }}</label>
<div class="control">{{ field }}</div>
</div>
{% 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 %}
{% endfor %}
<div class="has-text-right">
<button type="submit" class="button">{% translate "Update" %}</button>
</div>
</form>
</div>
</section>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,17 @@
{% extends base_template|default:"./base.html" %}
{% comment %}
Override is a trick here: it allows to change title at two different different
places inside the page: inside `<title>` tag, and inside the page
content.
{% endcomment %}
{% block head-title %}
{% block title %}
{% if page and page.title %}{{ page.title }} &mdash;{% endif %}
{% endblock %}
{{ station.name }}
{% endblock %}
{% block header %}{% if page %}{{ block.super }}{% endif %}{% endblock %}

View File

@ -0,0 +1,4 @@
<a-autocomplete
url="{{url}}"
name="{{ widget.name }}"{% if widget.value != None %} model-value="{{ widget.value|stringformat:'s' }}"{% endif %}
{% include "django/forms/widgets/attrs.html" %} />

View File

@ -25,31 +25,12 @@
{% endblock %} {% endblock %}
{% block actions %} {% block content %}
{% if object.sound_set.public.count %} {% if not object.content %}
<button class="button" @click="player.playButtonClick($event)" {% with object.parent.content as content %}
data-sounds="{{ object.podcasts|json }}"> {{ block.super }}
<span class="icon is-small"> {% endwith %}
<span class="fas fa-play"></span> {% else %}
</span> {{ block.super }}
</button>
{% endif %}
{% endblock %}
{% block actions %}
{% has_perm page object.program.change_permission_codename simple=True as can_edit %}
{% if can_edit %}
<a class="button" href="{% url 'episode-edit' object.pk %}" target="_self">
<span class="icon is-small"><i class="fas fa-pen" alt="{% trans 'edit' %}"></i></span>
</a>
{% endif %}
{% if object.sound_set.public.count %}
<button class="button" @click="player.playButtonClick($event)"
data-sounds="{{ object.podcasts|json }}">
<span class="icon is-small">
<span class="fas fa-play"></span>
</span>
</button>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "aircox/forms/formset.html" %}
{% load aircox %}
{% block row-control %}
{% if name == 'user' %}
{% form_field field name value %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock %}

View File

@ -14,6 +14,12 @@ random.seed()
register = template.Library() register = template.Library()
@register.simple_tag(name="form_field")
def form_field(field, name=None, value=None, **kwargs):
name = name or field.name
return field.widget.render(name=name, value=value, **kwargs)
@register.filter(name="admin_url") @register.filter(name="admin_url")
def admin_url(obj, action): def admin_url(obj, action):
meta = obj._meta meta = obj._meta

View File

@ -25,6 +25,7 @@ router.register("images", viewsets.ImageViewSet, basename="image")
router.register("sound", viewsets.SoundViewSet, basename="sound") router.register("sound", viewsets.SoundViewSet, basename="sound")
router.register("track", viewsets.TrackROViewSet, basename="track") router.register("track", viewsets.TrackROViewSet, basename="track")
router.register("comment", viewsets.CommentViewSet, basename="comment") router.register("comment", viewsets.CommentViewSet, basename="comment")
router.register("usergroup", viewsets.UserGroupViewSet, basename="usergroup")
api = [ api = [

View File

@ -1,5 +1,5 @@
from django.db.models import Q from django.db.models import Q
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
@ -12,9 +12,13 @@ from .log import LogListView
__all__ = ("DashboardBaseView", "DashboardView", "StatisticsView") __all__ = ("DashboardBaseView", "DashboardView", "StatisticsView")
class DashboardBaseView(LoginRequiredMixin, BaseView): class DashboardBaseView(LoginRequiredMixin, UserPassesTestMixin, BaseView):
title = _("Dashboard") title = _("Dashboard")
def test_func(self):
user = self.request.user
return user.is_staff or user.is_superuser
class DashboardView(DashboardBaseView, TemplateView): class DashboardView(DashboardBaseView, TemplateView):
template_name = "aircox/dashboard/dashboard.html" template_name = "aircox/dashboard/dashboard.html"

View File

@ -52,7 +52,7 @@ class EpisodeUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
def test_func(self): def test_func(self):
obj = self.get_object() obj = self.get_object()
return permissions.program_permissions.can(self.request.user, "update", obj) return permissions.program.can(self.request.user, "update", obj)
def get_tracklist_queryset(self, episode): def get_tracklist_queryset(self, episode):
return Track.objects.filter(episode=episode).order_by("position") return Track.objects.filter(episode=episode).order_by("position")

View File

@ -194,13 +194,9 @@ class PageDetailView(BasePageDetailView):
class PageUpdateView(BaseView, UpdateView): class PageUpdateView(BaseView, UpdateView):
context_object_name = "page" context_object_name = "page"
template_name = "aircox/page_form.html"
def get_page(self): def get_page(self):
return self.object return self.object
def get_success_url(self): def get_success_url(self):
return self.request.path return self.request.path
def get_comment_form(self):
return None

View File

@ -1,9 +1,11 @@
import random import random
from django.contrib.auth.models import User
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
from django.urls import reverse from django.urls import reverse
from aircox import models, forms, permissions from aircox import models, forms, permissions
from .mixins import VueFormDataMixin
from .page import PageDetailView, PageListView, PageUpdateView from .page import PageDetailView, PageListView, PageUpdateView
__all__ = ( __all__ = (
@ -51,13 +53,39 @@ class ProgramListView(PageListView):
return super().get_queryset().order_by("title") return super().get_queryset().order_by("title")
class ProgramUpdateView(UserPassesTestMixin, PageUpdateView): class ProgramUpdateView(UserPassesTestMixin, VueFormDataMixin, PageUpdateView):
model = models.Program model = models.Program
form_class = forms.ProgramForm form_class = forms.ProgramForm
queryset = models.Program.objects.select_related("editors_group")
def test_func(self): def test_func(self):
obj = self.get_object() obj = self.get_object()
return permissions.program_permissions.can(self.request.user, "update", obj) return permissions.program.can(self.request.user, "update", obj)
def get_success_url(self): def get_editors_queryset(self, program):
return reverse("program-detail", kwargs={"slug": self.get_object().slug}) # TODO: provide username in formset initials
return User.groups.through.objects.filter(group_id=program.editors_group_id).order_by("user__username")
def get_editors_formset(self, program, **kwargs):
return forms.UserGroupFormSet(
**{
**kwargs,
"prefix": "editors",
"queryset": self.get_editors_queryset(program),
"initial": {
"group": program.editors_group_id,
},
}
)
def get_context_data(self, editors_formset=None, **kwargs):
# TODO: use group and permission system
if self.request.user.is_superuser:
if editors_formset is None:
editors_formset = self.get_editors_formset(self.object)
kwargs["editors_formset_data"] = self.get_formset_data(
editors_formset, {"group": self.object.editors_group_id}
)
context = super().get_context_data(editors_formset=editors_formset, **kwargs)
return context

View File

@ -1,3 +1,4 @@
from django.contrib.auth.models import User
from django_filters import rest_framework as drf_filters from django_filters import rest_framework as drf_filters
from rest_framework import status, viewsets, parsers, permissions from rest_framework import status, viewsets, parsers, permissions
from rest_framework.decorators import action from rest_framework.decorators import action
@ -8,14 +9,37 @@ from filer.models.imagemodels import Image
from . import models, forms, filters, serializers from . import models, forms, filters, serializers
from .views import BaseAPIView from .views import BaseAPIView
__all__ = ( __all__ = (
"ImageViewSet", "ImageViewSet",
"SoundViewSet", "SoundViewSet",
"TrackROViewSet", "TrackROViewSet",
"UserGroupViewSet",
"UserSettingsViewSet", "UserSettingsViewSet",
) )
class AutocompleteMixin:
"""Based on provided filterset and serializer, add an "autocomplete" action
to the viewset.
Url ``GET`` parameters:
- `field` (many): if provided, only return provided field names
- filterset's lookups.
Return a list of values if ``field`` is provided, result of `list()` otherwise.
"""
@action(name="autocomplete", detail=False)
def autocomplete(self, request):
field = request.GET.get("field", None)
if field:
queryset = self.filter_queryset(self.get_queryset())
values = queryset.values_list(field, flat=True).distinct()
return Response(values[:10])
return self.list(request)
class ImageViewSet(viewsets.ModelViewSet): class ImageViewSet(viewsets.ModelViewSet):
parsers = (parsers.MultiPartParser,) parsers = (parsers.MultiPartParser,)
permissions = (permissions.IsAuthenticatedOrReadOnly,) permissions = (permissions.IsAuthenticatedOrReadOnly,)
@ -55,7 +79,7 @@ class SoundViewSet(BaseAPIView, viewsets.ModelViewSet):
return query return query
class TrackROViewSet(viewsets.ReadOnlyModelViewSet): class TrackROViewSet(AutocompleteMixin, viewsets.ReadOnlyModelViewSet):
"""Track viewset used for auto completion.""" """Track viewset used for auto completion."""
serializer_class = serializers.admin.TrackSerializer serializer_class = serializers.admin.TrackSerializer
@ -64,15 +88,6 @@ class TrackROViewSet(viewsets.ReadOnlyModelViewSet):
filterset_class = filters.TrackFilterSet filterset_class = filters.TrackFilterSet
queryset = models.Track.objects.all() queryset = models.Track.objects.all()
@action(name="autocomplete", detail=False)
def autocomplete(self, request):
field = request.GET.get("field", None)
if field:
queryset = self.filter_queryset(self.get_queryset())
values = queryset.values_list(field, flat=True).distinct()
return Response(values[:10])
return self.list(request)
class CommentViewSet(viewsets.ModelViewSet): class CommentViewSet(viewsets.ModelViewSet):
serializer_class = serializers.CommentSerializer serializer_class = serializers.CommentSerializer
@ -81,13 +96,19 @@ class CommentViewSet(viewsets.ModelViewSet):
# --- admin # --- admin
class UserGroupViewSet(AutocompleteMixin, viewsets.ModelViewSet):
serializer_class = serializers.auth.UserGroupSerializer
permission_classes = (permissions.IsAdminUser,)
queryset = User.groups.through.objects.all().distinct().order_by("user__username")
class UserSettingsViewSet(viewsets.ViewSet): class UserSettingsViewSet(viewsets.ViewSet):
"""User's settings specific to aircox. """User's settings specific to aircox.
Allow only to create and edit user's own settings. Allow only to create and edit user's own settings.
""" """
serializer_class = serializers.admin.UserSettingsSerializer serializer_class = serializers.UserSettingsSerializer
permission_classes = (permissions.IsAuthenticated,) permission_classes = (permissions.IsAuthenticated,)
def get_serializer(self, instance=None, **kwargs): def get_serializer(self, instance=None, **kwargs):

View File

@ -3,6 +3,7 @@ import tzlocal
from aircox.utils import to_seconds from aircox.utils import to_seconds
from ..conf import settings
from .metadata import Metadata, Request from .metadata import Metadata, Request
@ -76,7 +77,7 @@ class PlaylistSource(Source):
self.program = program self.program = program
super().__init__(controller, id=id, **kwargs) super().__init__(controller, id=id, **kwargs)
self.path = os.path.join(self.station.path, f"{self.id}.m3u") self.path = settings.get_dir(self.station, f"{self.id}.m3u")
def get_sound_queryset(self): def get_sound_queryset(self):
"""Get playlist's sounds queryset.""" """Get playlist's sounds queryset."""
@ -88,6 +89,7 @@ class PlaylistSource(Source):
def write_playlist(self, playlist=[]): def write_playlist(self, playlist=[]):
"""Write playlist to file.""" """Write playlist to file."""
playlist = playlist or self.get_playlist()
os.makedirs(os.path.dirname(self.path), exist_ok=True) os.makedirs(os.path.dirname(self.path), exist_ok=True)
with open(self.path, "w") as file: with open(self.path, "w") as file:
file.write("\n".join(playlist or [])) file.write("\n".join(playlist or []))

View File

@ -95,6 +95,8 @@ class Streamer:
data = render_to_string( data = render_to_string(
self.template_name, self.template_name,
{ {
"dir": settings.get_dir(self.station),
"log_file": settings.get_dir(self.station, "liquidsoap.log"),
"station": self.station, "station": self.station,
"streamer": self, "streamer": self,
}, },

View File

@ -53,13 +53,13 @@ class SourceSerializer(MetadataSerializer):
class PlaylistSerializer(SourceSerializer): class PlaylistSerializer(SourceSerializer):
program = serializers.CharField(source="program.id") program = serializers.CharField(source="program.id")
url_name = "admin:api:streamer-playlist-detail" url_name = "streamer:api:streamer-playlist-detail"
class QueueSourceSerializer(SourceSerializer): class QueueSourceSerializer(SourceSerializer):
queue = serializers.ListField(child=RequestSerializer(), source="requests") queue = serializers.ListField(child=RequestSerializer(), source="requests")
url_name = "admin:api:streamer-queue-detail" url_name = "streamer:api:streamer-queue-detail"
class StreamerSerializer(BaseSerializer): class StreamerSerializer(BaseSerializer):
@ -69,7 +69,7 @@ class StreamerSerializer(BaseSerializer):
playlists = serializers.ListField(child=PlaylistSerializer()) playlists = serializers.ListField(child=PlaylistSerializer())
queues = serializers.ListField(child=QueueSourceSerializer()) queues = serializers.ListField(child=QueueSourceSerializer())
url_name = "admin:api:streamer-detail" url_name = "streamer:api:streamer-detail"
def get_url(self, obj, **kwargs): def get_url(self, obj, **kwargs):
kwargs["pk"] = obj.station.pk kwargs["pk"] = obj.station.pk

View File

@ -80,7 +80,7 @@ end
{% block config %} {% block config %}
set("server.socket", true) set("server.socket", true)
set("server.socket.path", "{{ streamer.socket_path }}") set("server.socket.path", "{{ streamer.socket_path }}")
set("log.file.path", "{{ station.path }}/liquidsoap.log") set("log.file.path", "{{ log_file }}")
{% endblock %} {% endblock %}
{% block config_extras %} {% block config_extras %}

View File

@ -1,16 +1,13 @@
{% extends "admin/base_site.html" %} {% extends "aircox/dashboard/base.html" %}
{% comment %}Admin tools used to manage the streamer.{% endcomment %}
{% load i18n static %} {% load i18n static %}
{% block init-scripts %}
aircox.init({apiUrl: "{% url "admin:api:streamer-list" %}"},
{config: window.StreamerApp})
{% endblock %}
{% block content %} {% block title %}{% translate "Streamer monitor" %}{% endblock %}
{% block content-container %}
{{ block.super }} {{ block.super }}
<div id="app"> <div class="container">
<a-streamer api-url="{% url "admin:api:streamer-list" %}"> <a-streamer api-url="{% url "streamer:api:streamer-list" %}">
<template v-slot="{streamer,streamers,sources,fetchStreamers,Sound}"> <template v-slot="{streamer,streamers,sources,fetchStreamers,Sound}">
<div class="navbar toolbar"> <div class="navbar toolbar">
<div class="navbar-start"> <div class="navbar-start">

View File

@ -1,33 +1,25 @@
from django.contrib import admin from django.contrib import admin
from django.utils.translation import gettext_lazy as _ from django.urls import include, path
from aircox.viewsets import SoundViewSet from aircox.viewsets import SoundViewSet
from . import viewsets from . import views, viewsets
from .views import StreamerAdminView
admin.site.route_view(
"tools/streamer",
StreamerAdminView.as_view(),
"tools-streamer",
label=_("Streamer Monitor"),
)
streamer_prefix = "streamer/(?P<station_pk>[0-9]+)/" __all__ = ("api", "urls")
prefix = "(?P<station_pk>[0-9]+)/"
router = admin.site.router router = admin.site.router
router.register( router.register(prefix + "playlist", viewsets.PlaylistSourceViewSet, basename="streamer-playlist")
streamer_prefix + "playlist", router.register(prefix + "queue", viewsets.QueueSourceViewSet, basename="streamer-queue")
viewsets.PlaylistSourceViewSet,
basename="streamer-playlist",
)
router.register(
streamer_prefix + "queue",
viewsets.QueueSourceViewSet,
basename="streamer-queue",
)
router.register("streamer", viewsets.StreamerViewSet, basename="streamer") router.register("streamer", viewsets.StreamerViewSet, basename="streamer")
router.register("sound", SoundViewSet, basename="sound") router.register("sound", SoundViewSet, basename="sound")
urls = [] api = router.urls
urls = [
path("api/", include((api, "aircox_streamer"), namespace="api")),
path("", views.StreamerView.as_view(), name="dashboard-streamer"),
]

View File

@ -1,13 +1,13 @@
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView from django.views.generic import TemplateView
from aircox.views.admin import AdminMixin from aircox.views.dashboard import DashboardBaseView
from .controllers import streamers from .controllers import streamers
class StreamerAdminView(AdminMixin, TemplateView): class StreamerView(DashboardBaseView, TemplateView):
template_name = "aircox_streamer/streamer.html" template_name = "aircox_streamer/streamer.html"
title = _("Streamer Monitor") title = _("Streamer")
streamers = streamers streamers = streamers
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):

View File

@ -43,6 +43,8 @@ class ControllerViewSet(viewsets.ViewSet):
if station_pk is None: if station_pk is None:
station_pk = self.request.station.pk station_pk = self.request.station.pk
self.streamers.fetch() self.streamers.fetch()
if station_pk is None:
return None
if station_pk not in self.streamers: if station_pk not in self.streamers:
raise Http404("station not found") raise Http404("station not found")
return self.streamers[station_pk] return self.streamers[station_pk]
@ -78,7 +80,7 @@ class StreamerViewSet(ControllerViewSet):
def dispatch(self, request, *args, pk=None, **kwargs): def dispatch(self, request, *args, pk=None, **kwargs):
if pk is not None: if pk is not None:
kwargs.setdefault("station_pk", pk) kwargs.setdefault("station_pk", pk)
self.streamer = self.get_streamer(request, **kwargs) self.streamer = self.get_streamer(**kwargs)
self.object = self.streamer self.object = self.streamer
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)

View File

@ -27,7 +27,8 @@
"eslint-plugin-vue": "^8.0.3", "eslint-plugin-vue": "^8.0.3",
"sass": "^1.49.9", "sass": "^1.49.9",
"sass-loader": "^12.6.0", "sass-loader": "^12.6.0",
"vue-cli": "^2.9.6" "vue-cli": "^2.9.6",
"webpack-cli": "^5.1.4"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

View File

@ -232,6 +232,8 @@ export default {
: fetch(url, Model.getOptions()).then(d => d.json()) : fetch(url, Model.getOptions()).then(d => d.json())
promise = promise.then(items => { promise = promise.then(items => {
if(items.results)
items = items.results
this.items = items.filter((i) => i) || [] this.items = items.filter((i) => i) || []
this.promise = null; this.promise = null;
this.move(0) this.move(0)

Binary file not shown.

View File

@ -184,9 +184,9 @@ THUMBNAIL_PROCESSORS = (
# Enabled applications # Enabled applications
INSTALLED_APPS = ( INSTALLED_APPS = (
"radiocampus", "radiocampus",
"aircox_streamer.apps.AircoxStreamerConfig",
"aircox.apps.AircoxConfig", "aircox.apps.AircoxConfig",
"aircox.apps.AircoxAdminConfig", "aircox.apps.AircoxAdminConfig",
"aircox_streamer.apps.AircoxStreamerConfig",
# Aircox dependencies # Aircox dependencies
"rest_framework", "rest_framework",
"django_filters", "django_filters",

View File

@ -22,16 +22,14 @@ from django.urls import include, path
import aircox.urls import aircox.urls
import aircox_streamer.urls import aircox_streamer.urls
urlpatterns = ( urlpatterns = [
aircox.urls.urls *aircox.urls.urls,
+ aircox_streamer.urls.urls path("streamer/", include((aircox_streamer.urls.urls, "aircox_streamer"), namespace="streamer")),
+ [ path("admin/", admin.site.urls),
path("admin/", admin.site.urls), path("accounts/", include("django.contrib.auth.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")), ]
]
)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static( urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + static(

View File

@ -1,7 +1,7 @@
{% extends "aircox/base.html" %} {% extends "aircox/base.html" %}
{% load static %} {% load static %}
{% block head_extra %} {% block assets %}
{{ block.super }} {{ block.super }}
<style> <style>
:root { :root {

View File

@ -1,10 +1,10 @@
Django~=4.1 Django~=5.0
djangorestframework~=3.13 djangorestframework~=3.14
django-model-utils>=4.2 django-model-utils>=4.3
django-filter~=22.1 django-filter~=22.1
django-content-editor~=6.3 django-content-editor~=6.3
django-filer~=2.2 django-filer~=3.1
django-honeypot~=1.0 django-honeypot~=1.0
django-taggit~=3.0 django-taggit~=3.0
django-admin-sortable2~=2.1 django-admin-sortable2~=2.1