diff --git a/faqs/__init__.py b/faqs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/faqs/forms.py b/faqs/forms.py
new file mode 100644
index 0000000..3c8aa41
--- /dev/null
+++ b/faqs/forms.py
@@ -0,0 +1,28 @@
+from translated_fields import language_code_formfield_callback
+
+from django import forms
+
+from .models import Faq
+
+
+class FaqForm(forms.ModelForm):
+ formfield_callback = language_code_formfield_callback
+
+ class Meta:
+ model = Faq
+ fields = [
+ *Faq.title.fields,
+ "anchor",
+ *Faq.description.fields,
+ *Faq.content.fields,
+ ]
+ widgets = {
+ "description_en": forms.Textarea(
+ attrs={"rows": 4, "class": "is-family-monospace"}
+ ),
+ "description_fr": forms.Textarea(
+ attrs={"rows": 4, "class": "is-family-monospace"}
+ ),
+ "content_en": forms.Textarea(attrs={"class": "is-family-monospace"}),
+ "content_fr": forms.Textarea(attrs={"class": "is-family-monospace"}),
+ }
diff --git a/faqs/migrations/0001_initial.py b/faqs/migrations/0001_initial.py
new file mode 100644
index 0000000..a326379
--- /dev/null
+++ b/faqs/migrations/0001_initial.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.2.4 on 2021-06-15 08:34
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Faq",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title_fr", models.CharField(max_length=255, verbose_name="titre")),
+ (
+ "title_en",
+ models.CharField(blank=True, max_length=255, verbose_name="titre"),
+ ),
+ ("description_fr", models.TextField(verbose_name="description")),
+ (
+ "description_en",
+ models.TextField(blank=True, verbose_name="description"),
+ ),
+ ("content_fr", models.TextField(blank=True, verbose_name="contenu")),
+ ("content_en", models.TextField(blank=True, verbose_name="contenu")),
+ (
+ "last_modified",
+ models.DateField(auto_now=True, verbose_name="mise à jour"),
+ ),
+ ("anchor", models.CharField(max_length=20, verbose_name="ancre")),
+ (
+ "author",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="faqs",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "permissions": [("is_author", "Can create faqs")],
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="faq",
+ constraint=models.UniqueConstraint(
+ fields=("anchor",), name="unique_faq_anchor"
+ ),
+ ),
+ ]
diff --git a/faqs/migrations/__init__.py b/faqs/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/faqs/mixins.py b/faqs/mixins.py
new file mode 100644
index 0000000..8a09157
--- /dev/null
+++ b/faqs/mixins.py
@@ -0,0 +1,14 @@
+from django.contrib.auth.mixins import PermissionRequiredMixin
+
+
+class AdminOnlyMixin(PermissionRequiredMixin):
+ """Restreint l'accès aux admins"""
+
+ permission_required = "faqs.is_author"
+
+
+class CreatorOnlyMixin(AdminOnlyMixin):
+ """Restreint l'accès à l'auteur"""
+
+ def get_queryset(self):
+ return super().get_queryset().filter(author=self.request.user)
diff --git a/faqs/models.py b/faqs/models.py
new file mode 100644
index 0000000..f972c61
--- /dev/null
+++ b/faqs/models.py
@@ -0,0 +1,32 @@
+from translated_fields import TranslatedFieldWithFallback
+
+from django.contrib.auth import get_user_model
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+User = get_user_model()
+
+
+class Faq(models.Model):
+ title = TranslatedFieldWithFallback(
+ models.CharField(_("titre"), blank=False, max_length=255)
+ )
+ description = TranslatedFieldWithFallback(
+ models.TextField(_("description"), blank=False)
+ )
+ content = TranslatedFieldWithFallback(models.TextField(_("contenu"), blank=True))
+
+ author = models.ForeignKey(
+ User, related_name="faqs", null=True, on_delete=models.SET_NULL
+ )
+ last_modified = models.DateField(_("mise à jour"), auto_now=True)
+
+ anchor = models.CharField(_("ancre"), max_length=20)
+
+ class Meta:
+ permissions = [
+ ("is_author", "Can create faqs"),
+ ]
+ constraints = [
+ models.UniqueConstraint(fields=["anchor"], name="unique_faq_anchor")
+ ]
diff --git a/faqs/templates/faqs/faq.html b/faqs/templates/faqs/faq.html
new file mode 100644
index 0000000..79d5c5c
--- /dev/null
+++ b/faqs/templates/faqs/faq.html
@@ -0,0 +1,43 @@
+{% extends "base.html" %}
+{% load i18n markdown %}
+
+
+{% block content %}
+
+
+ {# Titre de la FAQ #}
+
+
{{ faq.title }}
+
+
+
+ {# Date de dernière modification #}
+
+ {% blocktrans with maj=faq.last_modified|date:'d/m/Y' %}Mis à jour le {{ maj }}{% endblocktrans %}
+
+
+ {# Lien vers la page d'édition #}
+ {% if faq.author == user %}
+
+ {% endif %}
+
+
+
+
+{# Description #}
+
+
{{ faq.description|markdown|safe }}
+
+
+{# Contenu #}
+
+ {{ faq.content|markdown|safe }}
+
+
+{% endblock %}
diff --git a/faqs/templates/faqs/faq_create.html b/faqs/templates/faqs/faq_create.html
new file mode 100644
index 0000000..c1c52b4
--- /dev/null
+++ b/faqs/templates/faqs/faq_create.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+
+{% block content %}
+
+{% for error in form.non_field_errors %}
+
+ {{ error }}
+
+{% endfor %}
+
+{% trans "Nouvelle FAQ" %}
+
+
+{% url 'faq.list' as r_url %}
+{% include "forms/common-form.html" with c_size="is-9" errors=False %}
+
+{% endblock %}
+
diff --git a/faqs/templates/faqs/faq_edit.html b/faqs/templates/faqs/faq_edit.html
new file mode 100644
index 0000000..4b31145
--- /dev/null
+++ b/faqs/templates/faqs/faq_edit.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% load i18n %}
+
+
+{% block content %}
+
+{% for error in form.non_field_errors %}
+
+ {{ error }}
+
+{% endfor %}
+
+{% trans "Modification de la FAQ" %}
+
+
+{% url 'faq.view' faq.anchor as r_url %}
+{% include "forms/common-form.html" with c_size="is-9" errors=False %}
+
+{% endblock %}
+
diff --git a/faqs/templates/faqs/faq_list.html b/faqs/templates/faqs/faq_list.html
new file mode 100644
index 0000000..5eefaf0
--- /dev/null
+++ b/faqs/templates/faqs/faq_list.html
@@ -0,0 +1,48 @@
+{% extends "base.html" %}
+{% load i18n markdown %}
+
+
+{% block content %}
+
+
+
+
+
{% trans "Liste des FAQ" %}
+
+
+
+ {% if perms.faqs.is_author %}
+
+ {% endif %}
+
+
+
+{% for f in faq_list %}
+
+
+
+ {% if f.description %}
+
+
+ {{ f.description|markdown|safe }}
+
+
+ {% endif %}
+
+{% if not forloop.last %}
+
+{% endif %}
+{% endfor %}
+
+{% endblock %}
diff --git a/faqs/urls.py b/faqs/urls.py
new file mode 100644
index 0000000..e89febf
--- /dev/null
+++ b/faqs/urls.py
@@ -0,0 +1,12 @@
+from django.urls import path
+
+from . import views
+
+urlpatterns = [
+ # Admin views
+ path("create", views.FaqCreateView.as_view(), name="faq.create"),
+ path("edit/", views.FaqEditView.as_view(), name="faq.edit"),
+ # Public views
+ path("", views.FaqListView.as_view(), name="faq.list"),
+ path("view/", views.FaqView.as_view(), name="faq.view"),
+]
diff --git a/faqs/views.py b/faqs/views.py
new file mode 100644
index 0000000..7857e12
--- /dev/null
+++ b/faqs/views.py
@@ -0,0 +1,54 @@
+from django.contrib.messages.views import SuccessMessageMixin
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from django.views.generic import CreateView, DetailView, ListView, UpdateView
+
+from .forms import FaqForm
+from .mixins import AdminOnlyMixin, CreatorOnlyMixin
+from .models import Faq
+
+# #############################################################################
+# Administration Views
+# #############################################################################
+
+
+class FaqCreateView(AdminOnlyMixin, SuccessMessageMixin, CreateView):
+ model = Faq
+ form_class = FaqForm
+ success_message = _("Faq créée avec succès !")
+ template_name = "faqs/faq_create.html"
+
+ def get_success_url(self):
+ return reverse("faq.view", args=[self.object.anchor])
+
+ def form_valid(self, form):
+ form.instance.author = self.request.user
+
+ return super().form_valid(form)
+
+
+class FaqEditView(CreatorOnlyMixin, SuccessMessageMixin, UpdateView):
+ model = Faq
+ form_class = FaqForm
+ slug_field = "anchor"
+ success_message = _("Faq modifiée avec succès !")
+ template_name = "faqs/faq_edit.html"
+
+ def get_success_url(self):
+ return reverse("faq.view", args=[self.object.anchor])
+
+
+# #############################################################################
+# Public Views
+# #############################################################################
+
+
+class FaqListView(ListView):
+ model = Faq
+ template_name = "faqs/faq_list.html"
+
+
+class FaqView(DetailView):
+ model = Faq
+ template_name = "faqs/faq.html"
+ slug_field = "anchor"
diff --git a/kadenios/settings/common.py b/kadenios/settings/common.py
index bbbb328..6143d80 100644
--- a/kadenios/settings/common.py
+++ b/kadenios/settings/common.py
@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"kadenios.apps.IgnoreSrcStaticFilesConfig",
"shared",
"elections",
+ "faqs",
"authens",
]
diff --git a/kadenios/urls.py b/kadenios/urls.py
index 4c81a39..e8ace9b 100644
--- a/kadenios/urls.py
+++ b/kadenios/urls.py
@@ -8,6 +8,7 @@ urlpatterns = [
path("", HomeView.as_view(), name="kadenios"),
path("admin/", admin.site.urls),
path("elections/", include("elections.urls")),
+ path("faqs/", include("faqs.urls")),
path("auth/", include("shared.auth.urls")),
path("authens/", include("authens.urls")),
path("i18n/", include("django.conf.urls.i18n")),
diff --git a/shared/templates/base.html b/shared/templates/base.html
index ed33571..1a6e446 100644
--- a/shared/templates/base.html
+++ b/shared/templates/base.html
@@ -112,16 +112,22 @@