diff --git a/lacommunaute/documentation/abstract_models.py b/lacommunaute/documentation/abstract_models.py index fb173f5a..04a4d53e 100644 --- a/lacommunaute/documentation/abstract_models.py +++ b/lacommunaute/documentation/abstract_models.py @@ -22,10 +22,10 @@ class AbstractPublication(AbstractDatedModel): slug = models.SlugField(max_length=255, verbose_name=_("Slug"), unique=True) description = MarkupTextField(verbose_name=_("Description"), null=True, blank=True) - short_description = models.CharField( - max_length=400, blank=True, null=True, verbose_name="Description courte (SEO)" - ) + short_description = models.CharField(max_length=400, verbose_name="Description courte (SEO)") image = models.ImageField( + blank=True, + null=True, storage=S3Boto3Storage(bucket_name=settings.AWS_STORAGE_BUCKET_NAME, file_overwrite=False), validators=[validate_image_size], ) diff --git a/lacommunaute/documentation/forms.py b/lacommunaute/documentation/forms.py new file mode 100644 index 00000000..8d435744 --- /dev/null +++ b/lacommunaute/documentation/forms.py @@ -0,0 +1,81 @@ +from django import forms +from django.conf import settings +from taggit.models import Tag + +from lacommunaute.documentation.models import Category, Document +from lacommunaute.partner.models import Partner +from lacommunaute.utils.iframe import wrap_iframe_in_div_tag + + +class DocumentationFormMixin: + name = forms.CharField(required=True, label="Titre") + short_description = forms.CharField( + widget=forms.Textarea(attrs={"rows": 3}), + max_length=400, + required=True, + label="Sous-titre (400 caractères pour le SEO)", + ) + description = forms.CharField( + widget=forms.Textarea(attrs={"rows": 20}), required=True, label="Contenu (markdown autorisé)" + ) + image = forms.ImageField( + label="Banniere de couverture, format 1200 x 630 pixels recommandé", + widget=forms.FileInput(attrs={"accept": settings.SUPPORTED_IMAGE_FILE_TYPES.keys()}), + ) + + def save(self, commit=True): + instance = super().save(commit=False) + instance.description = wrap_iframe_in_div_tag(self.cleaned_data.get("description")) + + if commit: + instance.save() + return instance + + +class CategoryForm(forms.ModelForm, DocumentationFormMixin): + class Meta: + model = Category + fields = ["name", "short_description", "description", "image"] + + +class DocumentForm(forms.ModelForm, DocumentationFormMixin): + certified = forms.BooleanField(required=False, label="Certifiée par la communauté de l'inclusion") + partner = forms.ModelChoiceField( + label="Sélectionner un partenaire", + queryset=Partner.objects.all(), + required=False, + ) + category = forms.ModelChoiceField( + label="Sélectionner une catégorie documentaire", + queryset=Category.objects.all(), + required=False, + ) + tags = forms.ModelMultipleChoiceField( + label="Sélectionner un ou plusieurs tags", + queryset=Tag.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False, + ) + new_tags = forms.CharField(required=False, label="Ajouter un tag ou plusieurs tags (séparés par des virgules)") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields["tags"].initial = self.instance.tags.all() + + def save(self, commit=True): + instance = super().save(commit=False) + + if commit: + instance.save() + instance.tags.set(self.cleaned_data["tags"]) + ( + instance.tags.add(*[tag.strip() for tag in self.cleaned_data["new_tags"].split(",")]) + if self.cleaned_data.get("new_tags") + else None + ) + return instance + + class Meta: + model = Document + fields = ["name", "short_description", "description", "image", "certified", "partner", "category"] diff --git a/lacommunaute/documentation/migrations/0002_alter_category_image_and_more.py b/lacommunaute/documentation/migrations/0002_alter_category_image_and_more.py new file mode 100644 index 00000000..adbee8f9 --- /dev/null +++ b/lacommunaute/documentation/migrations/0002_alter_category_image_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 5.0.9 on 2024-09-19 14:09 + +import storages.backends.s3 +from django.db import migrations, models + +import lacommunaute.utils.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("documentation", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="category", + name="image", + field=models.ImageField( + blank=True, + null=True, + storage=storages.backends.s3.S3Storage(bucket_name="private-bucket", file_overwrite=False), + upload_to="", + validators=[lacommunaute.utils.validators.validate_image_size], + ), + ), + migrations.AlterField( + model_name="category", + name="short_description", + field=models.CharField(default="empty", max_length=400, verbose_name="Description courte (SEO)"), + preserve_default=False, + ), + migrations.AlterField( + model_name="document", + name="image", + field=models.ImageField( + blank=True, + null=True, + storage=storages.backends.s3.S3Storage(bucket_name="private-bucket", file_overwrite=False), + upload_to="", + validators=[lacommunaute.utils.validators.validate_image_size], + ), + ), + migrations.AlterField( + model_name="document", + name="short_description", + field=models.CharField(default="empty", max_length=400, verbose_name="Description courte (SEO)"), + preserve_default=False, + ), + ] diff --git a/lacommunaute/documentation/models.py b/lacommunaute/documentation/models.py index b8d2782d..d8c56981 100644 --- a/lacommunaute/documentation/models.py +++ b/lacommunaute/documentation/models.py @@ -24,6 +24,9 @@ def get_absolute_url(self, with_fqdn=False): return f"{settings.COMMU_PROTOCOL}://{settings.COMMU_FQDN}{absolute_url}" return absolute_url + def get_update_url(self): + return reverse("documentation:category_update", kwargs={"pk": self.pk, "slug": self.slug}) + class Document(AbstractPublication): category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="documents") diff --git a/lacommunaute/documentation/tests/tests_category_create_view.py b/lacommunaute/documentation/tests/tests_category_create_view.py new file mode 100644 index 00000000..21294364 --- /dev/null +++ b/lacommunaute/documentation/tests/tests_category_create_view.py @@ -0,0 +1,58 @@ +import pytest # noqa + +from django.urls import reverse + +from lacommunaute.documentation.models import Category +from lacommunaute.users.factories import UserFactory + + +@pytest.fixture(name="url") +def fixture_url(): + return reverse("documentation:category_create") + + +@pytest.fixture(name="superuser") +def fixture_superuser(db): + return UserFactory(is_superuser=True) + + +def test_user_pass_test_mixin(client, db, url, superuser): + response = client.get(url) + assert response.status_code == 302 + + client.force_login(superuser) + response = client.get(url) + assert response.status_code == 200 + + +def test_context(client, db, url, superuser): + client.force_login(superuser) + response = client.get(url) + assert response.status_code == 200 + assert response.context["title"] == "Ajouter une catégorie documentaire" + assert response.context["back_url"] == reverse("documentation:category_list") + assert response.context["form"].fields.keys() == {"name", "short_description", "description", "image"} + assert response.context["form"].fields["name"].required + assert response.context["form"].fields["short_description"].required + + +def test_create_category(client, db, url, superuser): + client.force_login(superuser) + response = client.post( + url, + data={ + "name": "Test Name", + "short_description": "Test Short Description", + "description": "Test Description", + }, + ) + assert response.status_code == 302 + + category = Category.objects.get() + assert category.name == "Test Name" + assert category.short_description == "Test Short Description" + assert category.description.raw == "Test Description" + assert category.slug == "test-name" + + +# TODO tester avec image diff --git a/lacommunaute/documentation/tests/tests_category_detail_view.py b/lacommunaute/documentation/tests/tests_category_detail_view.py index cfa2b0ce..936c0bca 100644 --- a/lacommunaute/documentation/tests/tests_category_detail_view.py +++ b/lacommunaute/documentation/tests/tests_category_detail_view.py @@ -4,6 +4,7 @@ from lacommunaute.documentation.factories import CategoryFactory, DocumentFactory from lacommunaute.utils.testing import parse_response_to_soup +from lacommunaute.users.factories import UserFactory @pytest.fixture(name="category") @@ -32,3 +33,26 @@ def test_category_detail_view_with_tagged_documents(client, db, url, category, a assert response.status_code == 200 content = parse_response_to_soup(response, selector="main", replace_img_src=True, replace_in_href=[category]) assert str(content) == snapshot(name=snapshot_name) + + +@pytest.mark.parametrize( + "user_factory,link_is_visible", + [ + (None, False), + (UserFactory, False), + (lambda: UserFactory(is_superuser=True), True), + ], +) +def test_update_link_is_visible_for_superuser_only(client, db, url, category, user_factory, link_is_visible): + user = user_factory() if user_factory else None + if user: + client.force_login(user) + + response = client.get(url) + assert response.status_code == 200 + + category_update_url = reverse("documentation:category_update", kwargs={"pk": category.pk, "slug": category.slug}) + if link_is_visible: + assert category_update_url in str(response.content) + else: + assert category_update_url not in str(response.content) diff --git a/lacommunaute/documentation/tests/tests_category_list_view.py b/lacommunaute/documentation/tests/tests_category_list_view.py index c5af06b1..2f61c6fb 100644 --- a/lacommunaute/documentation/tests/tests_category_list_view.py +++ b/lacommunaute/documentation/tests/tests_category_list_view.py @@ -3,6 +3,7 @@ from lacommunaute.documentation.factories import CategoryFactory from lacommunaute.utils.testing import parse_response_to_soup +from lacommunaute.users.factories import UserFactory @pytest.fixture(name="url") @@ -30,3 +31,27 @@ def test_category_list_view(client, db, url, objects, status_code, snapshot_name assert response.status_code == status_code content = parse_response_to_soup(response, selector="main", replace_img_src=True, replace_in_href=categories) assert str(content) == snapshot(name=snapshot_name) + + +@pytest.mark.parametrize( + "user_factory,link_is_visible", + [ + (None, False), + (UserFactory, False), + (lambda: UserFactory(is_superuser=True), True), + ], +) +def test_create_category_link_for_superuser_only(client, db, url, user_factory, link_is_visible): + user = user_factory() if user_factory else None + if user: + client.force_login(user) + + response = client.get(url) + assert response.status_code == 200 + + category_create_url = reverse("documentation:category_create") + + if link_is_visible: + assert category_create_url in str(response.content) + else: + assert category_create_url not in str(response.content) diff --git a/lacommunaute/documentation/tests/tests_category_update_view.py b/lacommunaute/documentation/tests/tests_category_update_view.py new file mode 100644 index 00000000..fc98a77e --- /dev/null +++ b/lacommunaute/documentation/tests/tests_category_update_view.py @@ -0,0 +1,64 @@ +import pytest # noqa + +from django.urls import reverse + +from lacommunaute.documentation.factories import CategoryFactory +from lacommunaute.documentation.models import Category +from lacommunaute.users.factories import UserFactory + + +@pytest.fixture(name="category") +def fixture_category(db): + return CategoryFactory(for_snapshot=True) + + +@pytest.fixture(name="url") +def fixture_url(category): + return reverse("documentation:category_update", kwargs={"pk": category.pk, "slug": category.slug}) + + +@pytest.fixture(name="superuser") +def fixture_superuser(db): + return UserFactory(is_superuser=True) + + +def test_user_pass_test_mixin(client, db, url, superuser): + response = client.get(url) + assert response.status_code == 302 + + client.force_login(superuser) + response = client.get(url) + assert response.status_code == 200 + + +def test_context(client, db, url, superuser, category): + client.force_login(superuser) + response = client.get(url) + assert response.status_code == 200 + assert response.context["title"] == "Mettre à jour la catégorie Test Category" + assert response.context["back_url"] == category.get_absolute_url() + assert response.context["form"].fields.keys() == {"name", "short_description", "description", "image"} + assert response.context["form"].fields["name"].required + assert response.context["form"].fields["short_description"].required + + +def test_update_category(client, db, url, superuser, category): + client.force_login(superuser) + response = client.post( + url, + data={ + "name": "Updated Name", + "short_description": "Updated Short Description", + "description": "Updated Description", + }, + ) + assert response.status_code == 302 + + category = Category.objects.get() + assert category.name == "Updated Name" + assert category.short_description == "Updated Short Description" + assert category.description.raw == "Updated Description" + assert category.slug == "updated-name" + + +# TODO tester la mise à jour de l'image diff --git a/lacommunaute/documentation/tests/tests_models.py b/lacommunaute/documentation/tests/tests_models.py index 7efd28f5..0d912435 100644 --- a/lacommunaute/documentation/tests/tests_models.py +++ b/lacommunaute/documentation/tests/tests_models.py @@ -17,6 +17,10 @@ def test_get_absolute_url(self, db): category = CategoryFactory() assert category.get_absolute_url() == f"/documentation/{category.slug}-{category.pk}/" + def test_get_update_url(self, db): + category = CategoryFactory() + assert category.get_update_url() == f"/documentation/{category.slug}-{category.pk}/update/" + class TestDocument: def test_slug(self, db): diff --git a/lacommunaute/documentation/urls.py b/lacommunaute/documentation/urls.py index 7fa5e000..cabe9629 100644 --- a/lacommunaute/documentation/urls.py +++ b/lacommunaute/documentation/urls.py @@ -1,6 +1,12 @@ from django.urls import path -from lacommunaute.documentation.views import CategoryDetailView, CategoryListView, DocumentDetailView +from lacommunaute.documentation.views import ( + CategoryCreateView, + CategoryDetailView, + CategoryListView, + CategoryUpdateView, + CategoryCreateView, +) app_name = "documentation" @@ -9,4 +15,6 @@ urlpatterns = [ path("", CategoryListView.as_view(), name="category_list"), path("-/", CategoryDetailView.as_view(), name="category_detail"), + path("create/", CategoryCreateView.as_view(), name="category_create"), + path("-/update/", CategoryUpdateView.as_view(), name="category_update"), ] diff --git a/lacommunaute/documentation/views.py b/lacommunaute/documentation/views.py index 2664bb5a..e30664cd 100644 --- a/lacommunaute/documentation/views.py +++ b/lacommunaute/documentation/views.py @@ -1,8 +1,14 @@ +from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.contenttypes.models import ContentType +from django.urls import reverse from django.views.generic import DetailView, ListView +from django.views.generic.edit import CreateView, UpdateView from taggit.models import Tag + +from lacommunaute.documentation.forms import CategoryForm from lacommunaute.documentation.models import Category, Document + class CategoryListView(ListView): model = Category template_name = "documentation/category_list.html" @@ -31,3 +37,31 @@ def get_context_data(self, **kwargs): return context +class CategoryCreateView(UserPassesTestMixin, CreateView): + model = Category + template_name = "documentation/category_create_update.html" + form_class = CategoryForm + + def test_func(self): + return self.request.user.is_superuser + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = "Ajouter une catégorie documentaire" + context["back_url"] = reverse("documentation:category_list") + return context + + +class CategoryUpdateView(UserPassesTestMixin, UpdateView): + model = Category + template_name = "documentation/category_create_update.html" + form_class = CategoryForm + + def test_func(self): + return self.request.user.is_superuser + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["title"] = f"Mettre à jour la catégorie {self.object.name}" + context["back_url"] = self.object.get_absolute_url() + return context diff --git a/lacommunaute/templates/documentation/category_create_update.html b/lacommunaute/templates/documentation/category_create_update.html new file mode 100644 index 00000000..48e51185 --- /dev/null +++ b/lacommunaute/templates/documentation/category_create_update.html @@ -0,0 +1,16 @@ +{% extends "board_base.html" %} +{% block sub_title %} + {{ title }} +{% endblock sub_title %} +{% block content %} +
+
+
+
+

{{ title }}

+
+
{% include "documentation/partials/form_category.html" %}
+
+
+
+{% endblock content %} diff --git a/lacommunaute/templates/documentation/category_list.html b/lacommunaute/templates/documentation/category_list.html index b3039af6..618049cd 100644 --- a/lacommunaute/templates/documentation/category_list.html +++ b/lacommunaute/templates/documentation/category_list.html @@ -34,7 +34,7 @@

diff --git a/lacommunaute/templates/documentation/partials/form_category.html b/lacommunaute/templates/documentation/partials/form_category.html new file mode 100644 index 00000000..6ed13ac7 --- /dev/null +++ b/lacommunaute/templates/documentation/partials/form_category.html @@ -0,0 +1,20 @@ +{% load i18n %} +
+ {% csrf_token %} + {% for error in post_form.non_field_errors %} +
+ {{ error }} +
+ {% endfor %} + {% include "partials/form_field.html" with field=form.name %} + {% include "partials/form_field.html" with field=form.short_description %} + {% include "partials/form_field.html" with field=form.description %} + {% include "partials/form_field.html" with field=form.image %} +
+ +
diff --git a/lacommunaute/templates/documentation/partials/title_and_shortdesc.html b/lacommunaute/templates/documentation/partials/title_and_shortdesc.html index 863f2ec3..3940c938 100644 --- a/lacommunaute/templates/documentation/partials/title_and_shortdesc.html +++ b/lacommunaute/templates/documentation/partials/title_and_shortdesc.html @@ -3,7 +3,7 @@

{{ obj.name }}

- {% if user.is_superuser %}Mettre à jour{% endif %} + {% if user.is_superuser %}Mettre à jour{% endif %} {% if obj.short_description %}

{{ obj.short_description }}

{% endif %}