Ajout de la conception de questions/réponses pour modérer des événements

This commit is contained in:
Jean-Marie Favreau 2024-04-04 22:20:41 +02:00
parent 02d30d0fda
commit 7a6cd5737c
16 changed files with 470 additions and 4 deletions

View File

@ -2,7 +2,7 @@ from django.forms import ModelForm, ValidationError, TextInput, Form, URLField,
from datetime import date
from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget
from .models import Event, BatchImportation, RecurrentImport, CategorisationRule
from .models import Event, BatchImportation, RecurrentImport, CategorisationRule, ModerationAnswer, ModerationQuestion
from django.utils.translation import gettext_lazy as _
from string import ascii_uppercase as auc
from .templatetags.utils_extra import int_to_abc
@ -235,3 +235,18 @@ class MergeDuplicates(Form):
return [auc.rfind(v[-1]) for v in value]
else:
return auc.rfind(value[-1])
class ModerationQuestionForm(ModelForm):
class Meta:
model = ModerationQuestion
fields = '__all__'
class ModerationAnswerForm(ModelForm):
class Meta:
model = ModerationAnswer
exclude = ['question']
widgets = {
'adds_tags': DynamicArrayWidgetTags(),
'removes_tags': DynamicArrayWidgetTags()
}

View File

@ -0,0 +1,34 @@
# Generated by Django 4.2.7 on 2024-04-01 14:28
from django.db import migrations, models
import django.db.models.deletion
import django_better_admin_arrayfield.models.fields
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0038_auto_20240331_1815'),
]
operations = [
migrations.CreateModel(
name='ModerationQuestion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('question', models.CharField(help_text='Text that will be shown to moderators', max_length=512, unique=True, verbose_name='Question')),
],
),
migrations.CreateModel(
name='ModerationAnswer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('answer', models.CharField(help_text='Text that will be shown to moderators', max_length=512, unique=True, verbose_name='Answer')),
('adds_tags', django_better_admin_arrayfield.models.fields.ArrayField(base_field=models.CharField(max_length=64), blank=True, help_text='A list of tags that will be added if you choose this answer.', null=True, size=None, verbose_name='Adds tags')),
('removes_tags', django_better_admin_arrayfield.models.fields.ArrayField(base_field=models.CharField(max_length=64), blank=True, help_text='A list of tags that will be removed if you choose this answer.', null=True, size=None, verbose_name='Removes tags')),
('description', models.ForeignKey(help_text='Associated question from moderation', on_delete=django.db.models.deletion.CASCADE, to='agenda_culturel.moderationquestion', verbose_name='Question')),
],
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.7 on 2024-04-03 17:24
from django.db import migrations
from django.contrib.auth.management import create_permissions
from django.contrib.auth.models import Group, Permission
def update_groups_permissions(apps, schema_editor):
all_perms = Permission.objects.all()
moderator_perms = [i for i in all_perms if i.content_type.app_label == 'agenda_culturel' and i.content_type.model in ['event', 'duplicatedevents']]
read_mod_perms = [i for i in moderator_perms if i.codename.startswith('view_')]
# set permissions for receptionists
qanda_perms = [i for i in all_perms if i.content_type.app_label == 'agenda_culturel' and i.content_type.model in ['moderationquestion', 'moderationanswer']]
Group.objects.get(name="Q&A Manager").permissions.add(*qanda_perms)
Group.objects.get(name="Q&A Manager").permissions.add(*read_mod_perms)
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0039_moderationquestion_moderationanswer'),
]
operations = [
migrations.RunPython(update_groups_permissions),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-04-03 20:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0040_auto_20240403_1924'),
]
operations = [
migrations.RenameField(
model_name='moderationanswer',
old_name='description',
new_name='question',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.7 on 2024-04-03 21:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0041_rename_description_moderationanswer_question'),
]
operations = [
migrations.AlterField(
model_name='moderationanswer',
name='answer',
field=models.CharField(help_text='Text that will be shown to moderators', max_length=512, verbose_name='Answer'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.7 on 2024-04-03 21:14
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('agenda_culturel', '0042_alter_moderationanswer_answer'),
]
operations = [
migrations.AlterField(
model_name='moderationanswer',
name='question',
field=models.ForeignKey(help_text='Associated question from moderation', on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='agenda_culturel.moderationquestion', verbose_name='Question'),
),
]

View File

@ -851,3 +851,27 @@ class CategorisationRule(models.Model):
return True
return False
class ModerationQuestion(models.Model):
question = models.CharField(verbose_name=_('Question'), help_text=_('Text that will be shown to moderators'), max_length=512, unique=True)
def __str__(self):
char_limit = 30
return (self.question[:char_limit] + "...") if char_limit < len(self.question) else self.question
def get_absolute_url(self):
return reverse("view_mquestion", kwargs={"pk": self.pk})
class ModerationAnswer(models.Model):
question = models.ForeignKey(ModerationQuestion, related_name="answers", verbose_name=_('Question'), help_text=_('Associated question from moderation'), on_delete=models.CASCADE)
answer = models.CharField(verbose_name=_('Answer'), help_text=_('Text that will be shown to moderators'), max_length=512)
adds_tags = ArrayField(models.CharField(max_length=64), verbose_name=_('Adds tags'), help_text=_("A list of tags that will be added if you choose this answer."), blank=True, null=True)
removes_tags = ArrayField(models.CharField(max_length=64), verbose_name=_('Removes tags'), help_text=_("A list of tags that will be removed if you choose this answer."), blank=True, null=True)

View File

@ -0,0 +1,19 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Supprimer la réponse #{{ object.pk }}{% endblock %}
{% block content %}
<h1>Suppression de la réponse de modération {{ object.pk }}</h1>
<form method="post">{% csrf_token %}
<p>Êtes-vous sûr·e de vouloir supprimer la réponse de modération #{{object.pk}} «&nbsp;{{ object.answer }}&nbsp;» associée à la question «&nbsp;{{ object.question.question }}&nbsp;»&nbsp;?
</p>
{{ form }}
<div class="grid">
<a href="{% if request.META.HTTP_REFERER %}{{ request.META.HTTP_REFERER }}{% else %}{{ object.get_absolute_url }}{% endif %}" role="button" class="secondary">Annuler</a>
<input type="submit" value="Confirmer">
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,31 @@
{% extends "agenda_culturel/page.html" %}
{% load static %}
{% block title %}{% if form.instance.pk %}Modification{% else %}Création{% endif %} d'une réponse de modération{% endblock %}
{% block entete_header %}
<script>window.CKEDITOR_BASEPATH = '/static/ckeditor/ckeditor/';</script>
<script src="/static/admin/js/vendor/jquery/jquery.js"></script>
<script src="/static/admin/js/jquery.init.js"></script>
<link href="{% static 'css/django_better_admin_arrayfield.min.css' %}" type="text/css" media="all" rel="stylesheet">
<script src="{% static 'js/django_better_admin_arrayfield.min.js' %}"></script>
<script src="{% static 'js/adjust_datetimes.js' %}"></script>
{% endblock %}
{% block content %}
<h1>{% if form.instance.pk %}Modification{% else %}Création{% endif %} d'une réponse de modération</h1>
<p>{% if form.instance.pk %}Modifier{% else %}Ajouter{% endif %} une réponse à la question « {{ question }} »</p>
<article>
<form method="post">{% csrf_token %}
{{ form.as_p }}
<div class="grid">
<a href="{% if request.META.HTTP_REFERER %}{{ request.META.HTTP_REFERER }}{% else %}{% url 'view_mquestion' question.pk %}{% endif %}" role="button" class="secondary">Annuler</a>
<input type="submit" value="Envoyer">
</div>
</form>
</article>
{% endblock %}

View File

@ -0,0 +1,19 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Supprimer la question #{{ object.pk }}{% endblock %}
{% block content %}
<h1>Suppression de la question de modération {{ object.pk }}</h1>
<form method="post">{% csrf_token %}
<p>Êtes-vous sûr·e de vouloir supprimer la question de modération #{{object.pk}} «&nbsp;{{ object.question }}&nbsp;» ainsi que les réponses associées&nbsp;?
</p>
{{ form }}
<div class="grid">
<a href="{% if request.META.HTTP_REFERER %}{{ request.META.HTTP_REFERER }}{% else %}{{ object.get_absolute_url }}{% endif %}" role="button" class="secondary">Annuler</a>
<input type="submit" value="Confirmer">
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,67 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Question de modération #{{ object.pk }}{% endblock %}
{% load tag_extra %}
{% load utils_extra %}
{% load cat_extra %}
{% block entete_header %}
{% css_categories %}
{% endblock %}
{% block content %}
<div class="grid two-columns">
<article>
<header>
<a href="{% url 'view_mquestions' %}" role="button">&lt; Retour</a>
<div class="slide-buttons">
<a href="{% url 'delete_mquestion' object.pk %}" role="button">Supprimer</a>
<a href="{% url 'edit_mquestion' object.pk %}" role="button">Modifier</a>
<a href="{% url 'add_manswer' object.pk %}" role="button">Ajouter une réponse {% picto_from_name "plus-circle" %}</a>
</div>
<h1>Question de modération #{{ object.pk }}</h1>
<p>{{ object.question }}</p>
</header>
{% if object.answers %}
{% for answer in object.answers.all %}
<article>
<div class="slide-buttons">
<a href="{% url 'edit_manswer' object.pk answer.pk %}" role="button">Modifier</a>
<a href="{% url 'delete_manswer' object.pk answer.pk %}" role="button">Supprimer</a>
</div>
<header>
<h4><strong>Réponse #{{ answer.pk }}&nbsp;:</strong> «&nbsp;{{ answer.answer }}&nbsp;»</h4>
{% if answer.adds_tags %}
<p>Cette réponse ajoute les étiquettes suivantes à l'événement&nbsp;:
{% for tag in answer.adds_tags %}
{{ tag | tag_button }}
{% endfor %}
</p>
{% else %}
<p><em>Cette réponse n'ajoute pas d'étiquette à l'événement.</em></p>
{% endif %}
{% if answer.removes_tags %}
<p>Cette réponse supprimer les étiquettes suivantes à l'événement&nbsp;:
{% for tag in answer.removes_tags %}
{{ tag | tag_button }}
{% endfor %}
</p>
{% else %}
<p><em>Cette réponse ne supprime pas d'étiquette à l'événement.</em></p>
{% endif %}
</header>
</article>
{% endfor %}
{% else %}
Il n'y a pas encore de réponse associée à cette question.
{% endif %}
</article>
{% include "agenda_culturel/side-nav.html" with current="moderationquestions" %}
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends "agenda_culturel/page.html" %}
{% load static %}
{% block title %}
{% if form.instance.pk %}Modification{% else %}Création{% endif %} d'une question de modération
{% endblock %}
{% block content %}
<h1>{% if form.instance.pk %}Modification{% else %}Création{% endif %} d'une question de modération</h1>
<article>
<form method="post">{% csrf_token %}
{{ form.as_p }}
<div class="grid">
<a href="{% if request.META.HTTP_REFERER %}{{ request.META.HTTP_REFERER }}{% else %}{{ object.get_absolute_url }}{% endif %}" role="button" class="secondary">Annuler</a>
<input type="submit" value="Envoyer">
</div>
</form>
</article>
{% endblock %}

View File

@ -0,0 +1,65 @@
{% extends "agenda_culturel/page.html" %}
{% block title %}Questions de modération{% endblock %}
{% load utils_extra %}
{% load cat_extra %}
{% block entete_header %}
{% css_categories %}
{% endblock %}
{% block content %}
<div class="grid two-columns">
<article>
<header>
<a class="slide-buttons" href="{% url 'add_mquestion'%}" role="button">Ajouter {% picto_from_name "plus-circle" %}</a>
<h1>Questions de modération</h1>
</header>
{% if object_list %}
{% for question in object_list %}
<article>
<header>
<a class="slide-buttons" href="{{ question.get_absolute_url }}" role="button">Détails...</a>
<h2>Question #{{ question.pk }}&nbsp;: {{ question.question }}</h2>
<p>{% if question.answers %}
<p>réponses possibles&nbsp;:</p>
<ul>
{% for answer in question.answers.all %}
<li>{{ answer.answer }}</li>
{% endfor %}
</ul>
{% else %}
<p><em>aucune réponse définie</em></p>
{% endif %}
</p>
</header>
</article>
{% endfor %}
{% else %}
<p>Il n'y a aucune question définie.</p>
{% endif %}
<footer>
<span>
{% if page_obj.has_previous %}
<a href="?page=1" role="button">&laquo; premier</a>
<a href="?page={{ page_obj.previous_page_number }}" role="button">précédent</a>
{% endif %}
<span>
Page {{ page_obj.number }} sur {{ page_obj.paginator.num_pages }}
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" role="button">suivant</a>
<a href="?page={{ page_obj.paginator.num_pages }}" role="button">dernier &raquo;</a>
{% endif %}
</span>
</footer>
</article>
{% include "agenda_culturel/side-nav.html" with current="moderationquestions" %}
</div>
{% endblock %}

View File

@ -43,6 +43,14 @@
</ul>
</nav>
{% endif %}
{% if perms.agenda_culturel.view_moderationquestion %}
<h3>Paramétrage de la modération</h3>
<nav>
<ul>
<li><a {% if current == "moderationquestions" %}class="selected" {% endif %}href="{% url 'view_mquestions' %}">Questions de modération</a></li>
</ul>
</nav>
{% endif %}
{% if user.is_staff %}
<h3>Configuration interne</h3>
<nav>

View File

@ -55,6 +55,14 @@ urlpatterns = [
path("duplicates/<int:pk>", DuplicatedEventsDetailView.as_view(), name="view_duplicate"),
path("duplicates/<int:pk>/fix", fix_duplicate, name="fix_duplicate"),
path("duplicates/<int:pk>/merge", merge_duplicate, name="merge_duplicate"),
path("mquestions/", ModerationQuestionListView.as_view(), name="view_mquestions"),
path("mquestions/add", ModerationQuestionCreateView.as_view(), name="add_mquestion"),
path("mquestions/<int:pk>/", ModerationQuestionDetailView.as_view(), name="view_mquestion"),
path("mquestions/<int:pk>/edit", ModerationQuestionUpdateView.as_view(), name="edit_mquestion"),
path("mquestions/<int:pk>/delete", ModerationQuestionDeleteView.as_view(), name="delete_mquestion"),
path("mquestions/<int:qpk>/answers/add", ModerationAnswerCreateView.as_view(), name="add_manswer"),
path("mquestions/<int:qpk>/answers/<int:pk>/edit", ModerationAnswerUpdateView.as_view(), name="edit_manswer"),
path("mquestions/<int:qpk>/answers/<int:pk>/delete", ModerationAnswerDeleteView.as_view(), name="delete_manswer"),
]
if settings.DEBUG:

View File

@ -11,9 +11,9 @@ from django.http import HttpResponseRedirect
from django.urls import reverse
import urllib
from .forms import EventSubmissionForm, EventForm, BatchImportationForm, FixDuplicates, SelectEventInList, MergeDuplicates, RecurrentImportForm, CategorisationRuleImportForm
from .forms import EventSubmissionForm, EventForm, BatchImportationForm, FixDuplicates, SelectEventInList, MergeDuplicates, RecurrentImportForm, CategorisationRuleImportForm, ModerationQuestionForm, ModerationAnswerForm
from .models import Event, Category, StaticContent, ContactMessage, BatchImportation, DuplicatedEvents, RecurrentImport, CategorisationRule, remove_accents
from .models import Event, Category, StaticContent, ContactMessage, BatchImportation, DuplicatedEvents, RecurrentImport, CategorisationRule, remove_accents, ModerationQuestion, ModerationAnswer
from django.utils import timezone
from enum import StrEnum
from datetime import date, timedelta
@ -880,4 +880,75 @@ def apply_categorisation_rules(request):
return HttpResponseRedirect(reverse_lazy("categorisation_rules"))
#########################
## Moderation Q&A
#########################
class ModerationQuestionListView(PermissionRequiredMixin, ListView):
model = ModerationQuestion
paginate_by = 10
permission_required = ("agenda_culturel.view_moderationquestion")
class ModerationQuestionCreateView(SuccessMessageMixin, PermissionRequiredMixin, CreateView):
model = ModerationQuestion
permission_required = ("agenda_culturel.add_moderationquestion")
def get_success_url(self):
return reverse_lazy('view_mquestion', kwargs={'pk': self.object.pk})
form_class = ModerationQuestionForm
success_message = _('The moderation question has been created with success.')
class ModerationQuestionDetailView(PermissionRequiredMixin, DetailView):
model = ModerationQuestion
permission_required = ("agenda_culturel.view_moderationquestion", "agenda_culturel.view_moderationanswer")
class ModerationQuestionUpdateView(PermissionRequiredMixin, UpdateView):
model = ModerationQuestion
fields = ['question']
permission_required = ("agenda_culturel.change_moderationquestion")
class ModerationQuestionDeleteView(PermissionRequiredMixin, DeleteView):
model = ModerationQuestion
permission_required = ("agenda_culturel.delete_moderationquestion")
success_url = reverse_lazy('view_mquestions')
class ModerationAnswerCreateView(PermissionRequiredMixin, SuccessMessageMixin, CreateView):
model = ModerationAnswer
permission_required = ("agenda_culturel.add_answerquestion")
form_class = ModerationAnswerForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['question'] = get_object_or_404(ModerationQuestion, pk=self.kwargs['qpk'])
return context
def form_valid(self, form):
form.instance.question = ModerationQuestion.objects.get(pk=self.kwargs['qpk'])
return super().form_valid(form)
def get_success_url(self):
return reverse_lazy('view_mquestion', kwargs={'pk': self.kwargs['qpk']})
class ModerationAnswerUpdateView(PermissionRequiredMixin, UpdateView):
model = ModerationAnswer
fields = ['answer', 'adds_tags', 'removes_tags']
permission_required = ("agenda_culturel.change_answerquestion")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['question'] = get_object_or_404(ModerationQuestion, pk=self.kwargs['qpk'])
return context
def get_success_url(self):
return reverse_lazy('view_mquestion', kwargs={'pk': self.kwargs['qpk']})
class ModerationAnswerDeleteView(PermissionRequiredMixin, DeleteView):
model = ModerationAnswer
permission_required = ("agenda_culturel.delete_answerquestion")