Skip to content
Snippets Groups Projects
Commit 39d359a0 authored by Nik | Klampfradler's avatar Nik | Klampfradler
Browse files

Merge branch '29-allow-sorting-of-faq-sections-and-questions' into 'master'

Resolve "Allow sorting of FAQ Sections and Questions"

Closes #29

See merge request !42
parents 94c059fa 9ca54598
No related branches found
No related tags found
1 merge request!42Resolve "Allow sorting of FAQ Sections and Questions"
Pipeline #11812 canceled
Showing with 911 additions and 227 deletions
......@@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _
from django_select2.forms import ModelSelect2Widget
from .models import IssueCategory
from .models import FAQQuestion, FAQSection, IssueCategory
class FAQForm(forms.Form):
......@@ -12,6 +12,18 @@ class FAQForm(forms.Form):
question = forms.CharField(widget=forms.Textarea(), label=_("Your question"), required=True)
FAQOrderFormSet = forms.modelformset_factory(
FAQSection, can_order=True, extra=0, fields="__all__"
) # noqa
FAQOrderFormSet.ordering_widget = forms.widgets.HiddenInput
class FAQQuestionForm(forms.ModelForm):
class Meta:
model = FAQQuestion
fields = ("question_text", "answer_text", "show", "icon", "section")
class IssueForm(forms.Form):
"""Form used to allow users to report an issue."""
......
......@@ -41,6 +41,14 @@ MENUS = {
("aleksis.core.util.predicates.permission_validator", "hjelp.view_faq",),
],
},
{
"name": _("Manage FAQ"),
"url": "order_faq",
"icon": "low_priority",
"validators": [
("aleksis.core.util.predicates.permission_validator", "hjelp.change_faq",),
],
},
],
}
]
......
# Generated by Django 3.1.4 on 2020-12-21 17:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hjelp', '0002_change_max_length'),
]
operations = [
migrations.AddField(
model_name='faqsection',
name='position',
field=models.PositiveIntegerField(default=1, verbose_name='Order'),
),
migrations.AddField(
model_name='faqsection',
name='show',
field=models.BooleanField(default=False, verbose_name='Show'),
),
]
......@@ -25,12 +25,21 @@ class FAQSection(ExtensibleModel):
max_length=50, blank=True, default="question_answer", choices=ICONS, verbose_name=_("Icon"),
)
show = models.BooleanField(verbose_name=_("Show"), default=False)
position = models.PositiveIntegerField(verbose_name=_("Order"), default=1, blank=True)
def __str__(self):
return self.name
class Meta:
verbose_name = _("FAQ section")
verbose_name_plural = _("FAQ sections")
ordering = ["position"]
@property
def visible_questions(self):
return self.questions.filter(show=True)
class FAQQuestion(ExtensibleModel):
......
......@@ -20,6 +20,12 @@ view_faq_predicate = is_site_preference_set("hjelp", "public_faq") | (
)
add_perm("hjelp.view_faq", view_faq_predicate)
# Change FAQ
change_faq_predicate = has_person & (
has_global_perm("hjelp.change_faqsection") | has_global_perm("hjelp.change_faqquestion")
)
add_perm("hjelp.change_faq", change_faq_predicate)
# Ask FAQ question
ask_faq_predicate = has_person & has_global_perm("hjelp.ask_faq")
add_perm("hjelp.ask_faq", ask_faq_predicate)
......
......@@ -88,3 +88,48 @@
align-items: center;
justify-content: space-between;
}
.handle {
cursor: grab;
user-select: none;
}
.outer-handle {
vertical-align: middle;
height: 100%;
position: absolute;
}
.handle-container {
float: left;
position: relative;
}
.card-panel.disabled {
background-color: #e6e6e6;
}
.collection-item.disabled {
background-color: #aaaaaa61;
color: #888;
}
.question-container {
display: flex;
justify-content: space-between;
align-items: stretch;
}
.start-icons {
display: inline;
}
.main-question {
display: inline;
flex-grow: 10;
padding-left: 10px;
}
.end-button {
display: inline;
}
function refreshOrder() {
$("#sections .form-container").each(function (i) {
const order = i + 1;
let id = $(this).attr("data-order-field");
console.log(id);
$("#" + id).val(order);
})
}
function refreshQuestions() {
let questions = $(".question-sortable")
questions.each(function (i) {
let id = $(this).attr("data-pk");
$(questions[i]).find(".question-container").each(function (i) {
let section = $(this).find("input[name=question-sections\\[\\]]")[0];
$(section).val(id);
})
})
}
$(document).ready(function () {
$('#sections').sortable({
handle: '.outer-handle',
animation: 150,
onEnd: refreshOrder
});
$('.question-sortable').sortable({
handle: '.inner-handle',
group: 'questions',
animation: 150,
onEnd: refreshQuestions
});
});
......@@ -7,33 +7,34 @@
{% block content %}
{% for section in sections %}
<section>
<h4>
<i class="material-icons {{ section.icon_color }}-text">{{ section.icon }}</i> {{ section.name }}
</h4>
<section>
<h4>
<i class="material-icons {% firstof section.icon_color "black" %}-text">{{ section.icon }}</i>
{{ section.name }}
</h4>
<ul class="collapsible">
{% for question in section.questions.all %}
{% if question.show %}
<li>
<div class="collapsible-header">
<i class="material-icons">
{% if question.icon %}
{{ question.icon }}
{% else %}
question_answer
{% endif %}
</i>
{{ question.question_text }}
</div>
<div class="collapsible-body">
{{ question.answer_text|add_class_to_el:"ul, browser-default"|safe }}
</div>
</li>
{% endif %}
{% endfor %}
</ul>
</section>
<ul class="collapsible">
{% for question in section.visible_questions %}
<li>
<div class="collapsible-header">
<i class="material-icons">
{% firstof question.icon "question_answer" %}
</i>
{{ question.question_text }}
</div>
<div class="collapsible-body">
{{ question.answer_text|add_class_to_el:"ul, browser-default"|safe }}
</div>
</li>
{% empty %}
<li>
<div class="collapsible-header">
{% trans "There are no questions in this section." %}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
......
{% extends 'core/base.html' %}
{% load i18n material_form %}
{% block page_title %}{{ title }}{% endblock page_title %}
{% block browser_title %}{{ title }}{% endblock browser_title %}
{% block content %}
<form action="" method="POST">
{% csrf_token %}
{{ form.media }}
{% form form=form layout=layout %}{% endform %}
{% include "core/partials/save_button.html" %}
</form>
{% endblock content %}
{% extends 'core/base.html' %}
{% load i18n material_form static %}
{% block page_title %}{% trans "Manage FAQ" %}{% endblock page_title %}
{% block browser_title %}{% trans "Manage FAQ" %}{% endblock browser_title %}
{% block extra_head %}
<link rel="stylesheet" href="{% static "css/hjelp/hjelp.css" %}">
{% endblock extra_head %}
{% block content %}
{# Sections #}
<a href="{% url "create_faq_section" %}" class="btn primary-color">
<i class="material-icons left">create_new_folder</i> {% trans "Add FAQ section" %}
</a>
{# Questions #}
<a href="{% url "create_faq_question" %}" class="btn secondary-color">
<i class="material-icons left">playlist_add</i> {% trans "Add FAQ question" %}
</a>
<form method="post">
{% csrf_token %}
{{ form.management_form }}
<div id="sections">
{% for faq_form in form %}
<div class="card-panel {% if not faq_form.instance.show %}disabled{% endif %}">
<div class="row">
<div class="handle-container">
<i class="material-icons handle outer-handle small">drag_handle</i>
</div>
<div class="form-container col s11 push-s1" data-order-field="{{ faq_form.ORDER.auto_id }}">
<div class="row">
<div class="col s8 l10">
{% form form=faq_form layout=layout %}{% endform %}
</div>
<div class="col s4 l2">
<a href="{% url "delete_faq_section" faq_form.instance.pk %}"
class="btn-flat red-text waves-effect right">
<i class="material-icons left">delete</i>
{% trans "Delete" %}
</a>
</div>
</div>
<div class="collection question-sortable" data-pk="{{ faq_form.instance.id }}">
{% for question in faq_form.instance.questions.all %}
<div class="collection-item
question-container
{% if not question.show %}disabled{% endif %}
">
<input type="hidden" name="question-ids[]" value="{{ question.id }}">
<input type="hidden" name="question-sections[]" value="{{ question.section.id }}">
<div class="start-icons">
<i class="material-icons handle inner-handle black-text">drag_handle</i>
<i class="material-icons grey-text text-lighten-25">
{{ question.show|yesno:"visibility,visibility_off" }}
</i>
</div>
<div class="main-question">
{{ question.question_text }}
</div>
<div class="end-button">
<a href="{% url "update_faq_question" question.pk %}"
class="btn-flat primary-color-text waves-effect">
<i class="material-icons left">edit</i>
{% trans "Edit" %}
</a>
<a href="{% url "delete_faq_question" question.pk %}"
class="btn-flat red-text waves-effect">
<i class="material-icons left">delete</i>
{% trans "Delete" %}
</a>
</div>
</div>
{% empty %}
<div class="collection-item">
{% trans "There are no questions in this section." %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% include "core/partials/save_button.html" %}
</form>
<script src="{% static "js/hjelp/order_faq.js" %}"></script>
{% endblock content %}
......@@ -7,6 +7,16 @@ urlpatterns = [
path("feedback/", views.feedback, name="feedback"),
path("faq/", views.faq, name="faq"),
path("faq/ask/", views.ask_faq, name="ask_faq"),
path("faq/order/", views.OrderFAQ.as_view(), name="order_faq"),
path("faq/section/create/", views.CreateFAQSection.as_view(), name="create_faq_section"),
path("faq/section/<pk>/delete/", views.DeleteFAQSection.as_view(), name="delete_faq_section"),
path("faq/question/create/", views.CreateFAQQuestion.as_view(), name="create_faq_question"),
path(
"faq/question/<pk>/update/", views.UpdateFAQQuestion.as_view(), name="update_faq_question"
),
path(
"faq/question/<pk>/delete/", views.DeleteFAQQuestion.as_view(), name="delete_faq_question"
),
path(
"issues/get_next_properties/",
views.issues_get_next_properties,
......
from django.http import JsonResponse
from django.shortcuts import render
from typing import Any, Dict
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin as GlobalPermissionRequiredMixin
from django.forms.forms import BaseForm
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect, render
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.generic import FormView
from material import Layout, Row
from rules.contrib.views import permission_required
from templated_email import send_templated_mail
from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
from aleksis.core.models import Activity
from aleksis.core.util.core_helpers import get_site_preferences
from .forms import FAQForm, FeedbackForm, IssueForm
from .forms import FAQForm, FAQOrderFormSet, FAQQuestionForm, FeedbackForm, IssueForm
from .models import FAQQuestion, FAQSection, IssueCategory
......@@ -17,12 +26,119 @@ from .models import FAQQuestion, FAQSection, IssueCategory
def faq(request):
"""Show the FAQ page."""
context = {
"questions": FAQQuestion.objects.filter(show=True),
"sections": FAQSection.objects.all(),
"sections": FAQSection.objects.filter(show=True),
}
return render(request, "hjelp/faq.html", context)
class OrderFAQ(GlobalPermissionRequiredMixin, FormView):
queryset = FAQSection.objects.all()
template_name = "hjelp/order_faq.html"
form_class = FAQOrderFormSet
success_url = "#"
permission_required = "hjelp.change_faq"
success_message = _("The FAQ was updated successfully.")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["layout"] = Layout("name", "icon", "show")
return context
def form_valid(self, form):
for individual_form in form.forms:
pos = individual_form.cleaned_data["ORDER"]
individual_form.cleaned_data["position"] = pos
individual_form.instance.position = pos
individual_form.instance.save()
questions_and_sections = zip(
self.request.POST.getlist("question-ids[]"),
self.request.POST.getlist("question-sections[]"),
)
for question, section in questions_and_sections:
q = FAQQuestion.objects.get(pk=question)
q.section = FAQSection.objects.get(pk=section)
q.save()
messages.success(self.request, self.success_message)
return super().form_valid(form)
class CreateFAQSection(GlobalPermissionRequiredMixin, AdvancedCreateView):
model = FAQSection
template_name = "hjelp/hjelp_crud_views.html"
success_message = _("The FAQ section was created successfully!")
fields = ("name", "icon", "show")
permission_required = "hjelp.change_faq"
def form_valid(self, form: BaseForm) -> HttpResponse:
super().form_valid(form)
messages.success(self.request, self.success_message)
return redirect("order_faq")
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["title"] = _("Create FAQ section")
context["layout"] = Layout(Row("name"), Row("icon"), Row("show"))
return context
class DeleteFAQSection(GlobalPermissionRequiredMixin, AdvancedDeleteView):
model = FAQSection
template_name = "core/pages/delete.html"
success_message = _("The FAQ section was deleted successfully.")
success_url = reverse_lazy("order_faq")
permission_required = "hjelp.change_faq"
class CreateFAQQuestion(GlobalPermissionRequiredMixin, AdvancedCreateView):
form_class = FAQQuestionForm
template_name = "hjelp/hjelp_crud_views.html"
success_message = _("The FAQ question was created successfully.")
permission_required = "hjelp.change_faq"
def form_valid(self, form: BaseForm) -> HttpResponse:
super().form_valid(form)
messages.success(self.request, self.success_message)
return redirect("order_faq")
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["title"] = _("Create FAQ question")
context["layout"] = Layout(
Row("question_text"), Row("icon", "section"), Row("show"), Row("answer_text")
)
return context
class UpdateFAQQuestion(GlobalPermissionRequiredMixin, AdvancedEditView):
model = FAQQuestion
form_class = FAQQuestionForm
template_name = "hjelp/hjelp_crud_views.html"
success_message = _("The FAQ question was edited successfully.")
success_url = reverse_lazy("order_faq")
permission_required = "hjelp.change_faq"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["title"] = _("Edit FAQ question")
context["layout"] = Layout(
Row("question_text"), Row("icon", "show", "section"), Row("answer_text")
)
return context
class DeleteFAQQuestion(GlobalPermissionRequiredMixin, AdvancedDeleteView):
model = FAQQuestion
template_name = "core/pages/delete.html"
success_message = _("The FAQ question was deleted successfully.")
success_url = reverse_lazy("order_faq")
permission_required = "hjelp.change_faq"
@never_cache
@permission_required("hjelp.ask_faq")
def ask_faq(request):
......
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment