diff --git a/config/settings/base.py b/config/settings/base.py
index adba814a7..bb076e9d3 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -81,6 +81,7 @@
"lacommunaute.forum_file",
"lacommunaute.search",
"lacommunaute.surveys",
+ "lacommunaute.partner",
]
INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS + THIRD_PARTIES_APPS
diff --git a/config/urls.py b/config/urls.py
index f31c0056d..f91533973 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -14,6 +14,7 @@
from lacommunaute.forum_upvote import urls as forum_upvote_urls
from lacommunaute.inclusion_connect import urls as inclusion_connect_urls
from lacommunaute.pages import urls as pages_urls
+from lacommunaute.partner import urls as partner_urls
from lacommunaute.search import urls as search_urls
from lacommunaute.stats import urls as stats_urls
from lacommunaute.surveys import urls as surveys_urls
@@ -38,6 +39,7 @@
path("", include(forum_moderation_urls)),
path("calendar/", include(event_urls)),
path("surveys/", include(surveys_urls)),
+ path("partners/", include(partner_urls)),
path("statistiques/", include(stats_urls)),
# machina legacy
path("", include(conversation_urlpatterns_factory.urlpatterns)),
diff --git a/lacommunaute/forum/management/commands/populate.py b/lacommunaute/forum/management/commands/populate.py
index c245abc76..d1a06d515 100644
--- a/lacommunaute/forum/management/commands/populate.py
+++ b/lacommunaute/forum/management/commands/populate.py
@@ -6,6 +6,7 @@
from lacommunaute.event.factories import EventFactory
from lacommunaute.forum.factories import CategoryForumFactory, ForumFactory
from lacommunaute.forum_conversation.factories import AnonymousTopicFactory, PostFactory, TopicFactory
+from lacommunaute.partner.factories import PartnerFactory
from lacommunaute.users.factories import UserFactory
@@ -37,6 +38,9 @@ def handle(self, *args, **options):
EventFactory.create_batch(5)
sys.stdout.write("events created\n")
+ PartnerFactory.create_batch(5, with_logo=True)
+ sys.stdout.write("partners created\n")
+
# refresh materialized view
with connection.cursor() as cursor:
cursor.execute("REFRESH MATERIALIZED VIEW search_commonindex")
diff --git a/lacommunaute/pages/tests/__snapshots__/test_homepage.ambr b/lacommunaute/pages/tests/__snapshots__/test_homepage.ambr
index 0d9861aa0..067ff3779 100644
--- a/lacommunaute/pages/tests/__snapshots__/test_homepage.ambr
+++ b/lacommunaute/pages/tests/__snapshots__/test_homepage.ambr
@@ -23,6 +23,9 @@
+ -
+ Nos partenaires
+
-
Diagnostic Parcours IAE
diff --git a/lacommunaute/partner/__init__.py b/lacommunaute/partner/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/lacommunaute/partner/admin.py b/lacommunaute/partner/admin.py
new file mode 100644
index 000000000..8840fc618
--- /dev/null
+++ b/lacommunaute/partner/admin.py
@@ -0,0 +1,10 @@
+from django.contrib import admin
+
+from lacommunaute.partner.models import Partner
+
+
+@admin.register(Partner)
+class PartnerAdmin(admin.ModelAdmin):
+ list_display = ("name", "url", "created")
+ search_fields = ("name",)
+ list_filter = ("created",)
diff --git a/lacommunaute/partner/apps.py b/lacommunaute/partner/apps.py
new file mode 100644
index 000000000..2a3d4c026
--- /dev/null
+++ b/lacommunaute/partner/apps.py
@@ -0,0 +1,8 @@
+from django.apps import AppConfig
+
+
+class PartenerAppConfig(AppConfig):
+ label = "partner"
+ name = "lacommunaute.partner"
+ verbose_name = "Partner"
+ verbose_name_plural = "Partners"
diff --git a/lacommunaute/partner/factories.py b/lacommunaute/partner/factories.py
new file mode 100644
index 000000000..c6780fcf1
--- /dev/null
+++ b/lacommunaute/partner/factories.py
@@ -0,0 +1,28 @@
+import factory
+from faker import Faker
+
+from lacommunaute.partner.models import Partner
+
+
+faker = Faker()
+
+
+class PartnerFactory(factory.django.DjangoModelFactory):
+ class Meta:
+ model = Partner
+
+ name = factory.LazyAttribute(lambda x: faker.company())
+ short_description = factory.LazyAttribute(lambda x: faker.text(max_nb_chars=200))
+ description = factory.LazyAttribute(
+ lambda x: "\n".join([f"### part {i} \n{faker.paragraph(nb_sentences=20)}" for i in range(2)])
+ )
+ url = factory.LazyAttribute(lambda x: faker.url())
+
+ class Params:
+ with_logo = factory.Trait(logo=factory.django.ImageField(filename="test.jpg"))
+ for_snapshot = factory.Trait(
+ name="Best Partner Ever",
+ description="### h3 long MD description \n lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ short_description="short description for SEO",
+ url="https://www.best-partner-ever.com",
+ )
diff --git a/lacommunaute/partner/migrations/0001_initial.py b/lacommunaute/partner/migrations/0001_initial.py
new file mode 100644
index 000000000..b3d76e2df
--- /dev/null
+++ b/lacommunaute/partner/migrations/0001_initial.py
@@ -0,0 +1,52 @@
+# Generated by Django 5.0.8 on 2024-09-03 11:35
+
+import machina.models.fields
+import storages.backends.s3
+from django.db import migrations, models
+
+import lacommunaute.utils.validators
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = []
+
+ operations = [
+ migrations.CreateModel(
+ name="Partner",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("created", models.DateTimeField(auto_now_add=True, verbose_name="Creation date")),
+ ("updated", models.DateTimeField(auto_now=True, verbose_name="Update date")),
+ ("name", models.CharField(max_length=100, unique=True, verbose_name="Nom")),
+ ("slug", models.SlugField(max_length=255, verbose_name="Slug")),
+ (
+ "short_description",
+ models.CharField(blank=True, max_length=400, null=True, verbose_name="Description courte (SEO)"),
+ ),
+ (
+ "description",
+ machina.models.fields.MarkupTextField(
+ blank=True, no_rendered_field=True, null=True, verbose_name="Description"
+ ),
+ ),
+ (
+ "logo",
+ models.ImageField(
+ help_text="1200x600 recommandé",
+ storage=storages.backends.s3.S3Storage(bucket_name="private-bucket", file_overwrite=False),
+ upload_to="logos/",
+ validators=[lacommunaute.utils.validators.validate_image_size],
+ ),
+ ),
+ ("url", models.URLField(blank=True, null=True, verbose_name="Lien vers le site du partenaire")),
+ ("_description_rendered", models.TextField(blank=True, editable=False, null=True)),
+ ],
+ options={
+ "verbose_name": "Partner",
+ "verbose_name_plural": "Partners",
+ "ordering": ["-created"],
+ },
+ ),
+ ]
diff --git a/lacommunaute/partner/migrations/__init__.py b/lacommunaute/partner/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/lacommunaute/partner/models.py b/lacommunaute/partner/models.py
new file mode 100644
index 000000000..32af30a9b
--- /dev/null
+++ b/lacommunaute/partner/models.py
@@ -0,0 +1,44 @@
+from django.conf import settings
+from django.db import models
+from django.urls import reverse
+from django.utils.encoding import force_str
+from django.utils.text import slugify
+from django.utils.translation import gettext_lazy as _
+from machina.models.abstract_models import DatedModel
+from machina.models.fields import MarkupTextField
+from storages.backends.s3boto3 import S3Boto3Storage
+
+from lacommunaute.utils.validators import validate_image_size
+
+
+class Partner(DatedModel):
+ name = models.CharField(max_length=100, verbose_name="Nom", unique=True)
+ slug = models.SlugField(max_length=255, verbose_name="Slug")
+ short_description = models.CharField(
+ max_length=400, blank=True, null=True, verbose_name="Description courte (SEO)"
+ )
+ description = MarkupTextField(verbose_name="Description", null=True, blank=True)
+ logo = models.ImageField(
+ storage=S3Boto3Storage(bucket_name=settings.AWS_STORAGE_BUCKET_NAME, file_overwrite=False),
+ validators=[validate_image_size],
+ upload_to="logos/",
+ help_text="1200x600 recommandé",
+ )
+ url = models.URLField(verbose_name="Lien vers le site du partenaire", null=True, blank=True)
+
+ objects = models.Manager()
+
+ class Meta:
+ verbose_name = _("Partner")
+ verbose_name_plural = _("Partners")
+ ordering = ["-created"]
+
+ def __str__(self):
+ return f"{self.name}"
+
+ def save(self, *args, **kwargs):
+ self.slug = slugify(force_str(self.name), allow_unicode=True)
+ super().save(*args, **kwargs)
+
+ def get_absolute_url(self):
+ return reverse("partner:detail", kwargs={"slug": self.slug, "pk": self.pk})
diff --git a/lacommunaute/partner/tests/__init__.py b/lacommunaute/partner/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/lacommunaute/partner/tests/__snapshots__/tests_partner_detailview.ambr b/lacommunaute/partner/tests/__snapshots__/tests_partner_detailview.ambr
new file mode 100644
index 000000000..dc69dc2dc
--- /dev/null
+++ b/lacommunaute/partner/tests/__snapshots__/tests_partner_detailview.ambr
@@ -0,0 +1,180 @@
+# serializer version: 1
+# name: test_partner_detailview[-partner_detailview as anonymous][partner_detailview as anonymous]
+ '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Best Partner Ever
+
+
+
short description for SEO
+
+
+
+
+
+
+
+
+
h3 long MD description
+
+ lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_partner_detailview[-partner_detailview as superuser][partner_detailview as superuser]
+ '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Best Partner Ever
+
+
+
Mettre à jour
+
+
short description for SEO
+
+
+
+
+
+
+
+
+
h3 long MD description
+
+ lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_partner_detailview[-partner_detailview][partner_detailview]
+ '''
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Best Partner Ever
+
+
+
short description for SEO
+
+
+
+
+
+
+
+
+
h3 long MD description
+
+ lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+
+
+
+
+
+
+
+
+ '''
+# ---
diff --git a/lacommunaute/partner/tests/__snapshots__/tests_partner_listview.ambr b/lacommunaute/partner/tests/__snapshots__/tests_partner_listview.ambr
new file mode 100644
index 000000000..410821c15
--- /dev/null
+++ b/lacommunaute/partner/tests/__snapshots__/tests_partner_listview.ambr
@@ -0,0 +1,61 @@
+# serializer version: 1
+# name: test_listview[partner_listview]
+ '''
+
+
+
+
+
+
+
+
+
+
+
+
+
Best Partner Ever
+
short description for SEO
+
+
+
+
+
+
+
+
+
+
+
+ '''
+# ---
+# name: test_pagination[partner_pagination]
+ '''
+
+ '''
+# ---
diff --git a/lacommunaute/partner/tests/tests_model.py b/lacommunaute/partner/tests/tests_model.py
new file mode 100644
index 000000000..af3e09d9c
--- /dev/null
+++ b/lacommunaute/partner/tests/tests_model.py
@@ -0,0 +1,30 @@
+import pytest # noqa
+from django.conf import settings
+from django.db import IntegrityError
+
+from lacommunaute.partner.factories import PartnerFactory
+from lacommunaute.partner.models import Partner
+
+
+def test_name_uniqueness(db):
+ partner = PartnerFactory()
+ with pytest.raises(IntegrityError):
+ PartnerFactory(name=partner.name)
+
+
+def test_slug_is_generated(db):
+ partner = Partner.objects.create(name="Test Partner")
+ assert partner.slug == "test-partner"
+
+
+def test_logo_url(db):
+ partner = PartnerFactory(with_logo=True)
+ assert (
+ partner.logo.url.split("?")[0] == f"{settings.MEDIA_URL}{settings.AWS_STORAGE_BUCKET_NAME}/{partner.logo.name}"
+ )
+ assert "AWSAccessKeyId=" in partner.logo.url
+
+
+def test_get_absolute_url(db):
+ partner = PartnerFactory()
+ assert partner.get_absolute_url() == f"/partners/{partner.slug}-{partner.pk}/"
diff --git a/lacommunaute/partner/tests/tests_partner_detailview.py b/lacommunaute/partner/tests/tests_partner_detailview.py
new file mode 100644
index 000000000..b00a572b6
--- /dev/null
+++ b/lacommunaute/partner/tests/tests_partner_detailview.py
@@ -0,0 +1,25 @@
+import pytest # noqa
+
+from lacommunaute.partner.factories import PartnerFactory
+from lacommunaute.users.factories import UserFactory
+from lacommunaute.utils.testing import parse_response_to_soup
+
+
+@pytest.mark.parametrize(
+ "user,snapshot_name",
+ [
+ (lambda: None, "partner_detailview as anonymous"),
+ (lambda: UserFactory(), "partner_detailview"),
+ (lambda: UserFactory(is_superuser=True), "partner_detailview as superuser"),
+ ],
+)
+def test_partner_detailview(client, db, snapshot, user, snapshot_name):
+ partner = PartnerFactory(for_snapshot=True, with_logo=True)
+ user = user()
+ if user:
+ client.force_login(user)
+ response = client.get(partner.get_absolute_url())
+ assert response.status_code == 200
+ assert str(
+ parse_response_to_soup(response, selector="main", replace_img_src=True, replace_in_href=[partner])
+ ) == snapshot(name=snapshot_name)
diff --git a/lacommunaute/partner/tests/tests_partner_listview.py b/lacommunaute/partner/tests/tests_partner_listview.py
new file mode 100644
index 000000000..9b7983703
--- /dev/null
+++ b/lacommunaute/partner/tests/tests_partner_listview.py
@@ -0,0 +1,21 @@
+import pytest # noqa
+from django.urls import reverse
+
+from lacommunaute.partner.factories import PartnerFactory
+from lacommunaute.utils.testing import parse_response_to_soup
+
+
+def test_listview(client, db, snapshot):
+ partner = PartnerFactory(for_snapshot=True, with_logo=True)
+ response = client.get(reverse("partner:list"))
+ assert response.status_code == 200
+ assert str(
+ parse_response_to_soup(response, selector="#partner-list", replace_img_src=True, replace_in_href=[partner])
+ ) == snapshot(name="partner_listview")
+
+
+def test_pagination(client, db, snapshot):
+ PartnerFactory.create_batch(8 * 3 + 1)
+ response = client.get(reverse("partner:list"))
+ assert response.status_code == 200
+ assert str(parse_response_to_soup(response, selector="ul.pagination")) == snapshot(name="partner_pagination")
diff --git a/lacommunaute/partner/urls.py b/lacommunaute/partner/urls.py
new file mode 100644
index 000000000..420186b4d
--- /dev/null
+++ b/lacommunaute/partner/urls.py
@@ -0,0 +1,12 @@
+from django.urls import path
+
+from lacommunaute.partner.views import PartnerDetailView, PartnerListView
+
+
+app_name = "partner"
+
+
+urlpatterns = [
+ path("", PartnerListView.as_view(), name="list"),
+ path("-/", PartnerDetailView.as_view(), name="detail"),
+]
diff --git a/lacommunaute/partner/views.py b/lacommunaute/partner/views.py
new file mode 100644
index 000000000..0f45646a6
--- /dev/null
+++ b/lacommunaute/partner/views.py
@@ -0,0 +1,16 @@
+from django.views.generic import DetailView, ListView
+
+from lacommunaute.partner.models import Partner
+
+
+class PartnerListView(ListView):
+ model = Partner
+ template_name = "partner/list.html"
+ context_object_name = "partners"
+ paginate_by = 8 * 3
+
+
+class PartnerDetailView(DetailView):
+ model = Partner
+ template_name = "partner/detail.html"
+ context_object_name = "partner"
diff --git a/lacommunaute/templates/partials/breadcrumb_partner.html b/lacommunaute/templates/partials/breadcrumb_partner.html
new file mode 100644
index 000000000..5eb07eebf
--- /dev/null
+++ b/lacommunaute/templates/partials/breadcrumb_partner.html
@@ -0,0 +1,11 @@
+{% load i18n %}
+
diff --git a/lacommunaute/templates/partials/footer.html b/lacommunaute/templates/partials/footer.html
index 2bc95acea..6881cd412 100644
--- a/lacommunaute/templates/partials/footer.html
+++ b/lacommunaute/templates/partials/footer.html
@@ -24,6 +24,9 @@
+ -
+ {% trans "Partners" %}
+
-
Diagnostic Parcours IAE
diff --git a/lacommunaute/templates/partner/detail.html b/lacommunaute/templates/partner/detail.html
new file mode 100644
index 000000000..7ab79f320
--- /dev/null
+++ b/lacommunaute/templates/partner/detail.html
@@ -0,0 +1,43 @@
+{% extends "layouts/base.html" %}
+{% load str_filters %}
+{% block title %}{{ partner.name }} {{ block.super }}{% endblock %}
+{% block meta_description %}
+ {{ partner.short_description }}
+{% endblock meta_description %}
+{% block breadcrumb %}
+ {% include "partials/breadcrumb_partner.html" %}
+{% endblock %}
+{% block content %}
+
+
+
+
+
+ {% if partner.logo %}
+
+ {% endif %}
+
{{ partner.name }}
+
+ {% if user.is_superuser %}
+
Mettre à jour
+ {% endif %}
+
{{ partner.short_description }}
+
+
+
+
+
+
+
+
+
{{ partner.description.rendered|urlizetrunc_target_blank:30|img_fluid }}
+ {% if partner.url %}
+
+ {% endif %}
+
+
+
+
+{% endblock content %}
diff --git a/lacommunaute/templates/partner/list.html b/lacommunaute/templates/partner/list.html
new file mode 100644
index 000000000..878aab030
--- /dev/null
+++ b/lacommunaute/templates/partner/list.html
@@ -0,0 +1,57 @@
+{% extends "layouts/base.html" %}
+{% load i18n %}
+{% block title %}
+ {% trans "Partners" %} {{ block.super }}
+{% endblock title %}
+{% block content %}
+
+
+
+
+
{% trans "Partners" %}
+
+
+
+
+
+
+
+
+
+ {% for partner in partners %}
+
+
+ {% if partner.logo %}
+
+ {% endif %}
+
+
{{ partner.name }}
+
{{ partner.short_description }}
+
+
+
+
+ {% empty %}
+
+ {% endfor %}
+
+ {% if page_obj.has_previous or page_obj.has_next %}
+ {% with pagination_size="pagination-sm justify-content-center mt-5" %}
+ {% include "partials/pagination.html" %}
+ {% endwith %}
+ {% endif %}
+
+
+
+
+{% endblock content %}
diff --git a/locale/fr/LC_MESSAGES/django.mo b/locale/fr/LC_MESSAGES/django.mo
index c6d3c6715..5fcd6eb83 100644
Binary files a/locale/fr/LC_MESSAGES/django.mo and b/locale/fr/LC_MESSAGES/django.mo differ
diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po
index e54c001c0..dd487641c 100644
--- a/locale/fr/LC_MESSAGES/django.po
+++ b/locale/fr/LC_MESSAGES/django.po
@@ -1032,3 +1032,9 @@ msgstr "Dès que possible"
msgid "The following day"
msgstr "Le jour suivant"
+
+msgid "Partner"
+msgstr "Partenaire"
+
+msgid "Partners"
+msgstr "Nos partenaires"