diff --git a/Makefile b/Makefile
index 0569c7de2..aeced0c0c 100644
--- a/Makefile
+++ b/Makefile
@@ -26,3 +26,13 @@ index:
INSTANCE_NUMBER=0 \
POSTGRESQL_ADDON_URI=$(POSTGRESQL_ADDON_URI) \
clevercloud/rebuild_index.sh
+
+# DB
+
+.PHONY: resetdb
+resetdb:
+ dropdb --if-exists $(POSTGRESQL_ADDON_DB)
+ createdb $(POSTGRESQL_ADDON_DB)
+ python manage.py migrate
+ python manage.py configure_bucket
+ python manage.py populate
diff --git a/clevercloud/collect_weekly_matomo_forum_stats.sh b/clevercloud/collect_weekly_matomo_forum_stats.sh
index 494ddf3e4..1a5ff24d0 100755
--- a/clevercloud/collect_weekly_matomo_forum_stats.sh
+++ b/clevercloud/collect_weekly_matomo_forum_stats.sh
@@ -15,4 +15,6 @@ fi
# $APP_HOME is set by default by clever cloud.
cd $APP_HOME
-python manage.py collect_matomo_forum_stats
+# reactivate after documentation migration
+# python manage.py collect_matomo_forum_stats
+echo "Collecting weekly matomo stats for the forum is disabled for now."
diff --git a/config/urls.py b/config/urls.py
index 4dddbf67d..fe094e811 100644
--- a/config/urls.py
+++ b/config/urls.py
@@ -5,6 +5,7 @@
from django.urls import include, path, re_path
from machina.core.loading import get_class
+from lacommunaute.documentation import urls as documentation_urls
from lacommunaute.event import urls as event_urls
from lacommunaute.forum import urls as forum_extension_urls
from lacommunaute.forum_conversation import urls as forum_conversation_extension_urls
@@ -30,6 +31,7 @@
path("inclusion_connect/", include(inclusion_connect_urls)),
# www.
path("", include(pages_urls)),
+ path("documentation/", include(documentation_urls)),
path("members/", include(forum_member_urls)),
path("", include(forum_conversation_extension_urls)),
path("", include(forum_extension_urls)),
diff --git a/lacommunaute/documentation/abstract_models.py b/lacommunaute/documentation/abstract_models.py
new file mode 100644
index 000000000..fb173f5ab
--- /dev/null
+++ b/lacommunaute/documentation/abstract_models.py
@@ -0,0 +1,38 @@
+from django.conf import settings
+from django.db import models
+from django.utils.encoding import force_str
+from django.utils.text import slugify
+from django.utils.translation import gettext_lazy as _
+from machina.models.fields import MarkupTextField
+from storages.backends.s3boto3 import S3Boto3Storage
+
+from lacommunaute.utils.validators import validate_image_size
+
+
+class AbstractDatedModel(models.Model):
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ abstract = True
+
+
+class AbstractPublication(AbstractDatedModel):
+ name = models.CharField(max_length=100, verbose_name=_("Name"))
+ 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)"
+ )
+ image = models.ImageField(
+ storage=S3Boto3Storage(bucket_name=settings.AWS_STORAGE_BUCKET_NAME, file_overwrite=False),
+ validators=[validate_image_size],
+ )
+
+ class Meta:
+ abstract = True
+
+ def save(self, *args, **kwargs):
+ self.slug = slugify(force_str(self.name), allow_unicode=True)
+ super().save(*args, **kwargs)
diff --git a/lacommunaute/documentation/admin.py b/lacommunaute/documentation/admin.py
new file mode 100644
index 000000000..72b67f7ae
--- /dev/null
+++ b/lacommunaute/documentation/admin.py
@@ -0,0 +1,40 @@
+from django.contrib import admin
+
+from lacommunaute.documentation.models import Category, Document, DocumentRating
+
+
+class DocumentInlines(admin.TabularInline):
+ model = Document
+ extra = 0
+ fields = ("name", "short_description")
+ readonly_fields = ("name", "short_description")
+
+ def has_delete_permission(self, request, obj=None):
+ return False
+
+ def has_add_permission(self, request, obj=None):
+ return False
+
+
+@admin.register(Category)
+class CategoryAdmin(admin.ModelAdmin):
+ list_display = ("name",)
+ search_fields = ("name",)
+ fields = ("name", "short_description", "description", "image")
+ inlines = [DocumentInlines]
+
+
+@admin.register(Document)
+class DocumentAdmin(admin.ModelAdmin):
+ list_display = ("name", "category")
+ list_filter = ("category",)
+ search_fields = ("name",)
+ fields = ("name", "short_description", "description", "image")
+
+
+@admin.register(DocumentRating)
+class DocumentRatingAdmin(admin.ModelAdmin):
+ list_display = ("document", "rating", "created_at")
+ list_filter = ("document",)
+ list_display_links = ("rating",)
+ raw_id_fields = ("document", "user")
diff --git a/lacommunaute/documentation/factories.py b/lacommunaute/documentation/factories.py
new file mode 100644
index 000000000..ee6e27890
--- /dev/null
+++ b/lacommunaute/documentation/factories.py
@@ -0,0 +1,22 @@
+import factory
+from faker import Faker
+
+from lacommunaute.documentation.models import Category
+
+
+faker = Faker()
+
+
+class CategoryFactory(factory.django.DjangoModelFactory):
+ name = factory.Faker("name")
+ description = factory.Faker("sentence", nb_words=100)
+ short_description = factory.Faker("sentence", nb_words=10)
+ image = factory.django.ImageField(filename="banner.jpg")
+
+ class Meta:
+ model = Category
+
+ class Params:
+ for_snapshot = factory.Trait(
+ name="Test Category", description="Test description", short_description="Test description"
+ )
diff --git a/lacommunaute/documentation/forms.py b/lacommunaute/documentation/forms.py
new file mode 100644
index 000000000..a1efeb709
--- /dev/null
+++ b/lacommunaute/documentation/forms.py
@@ -0,0 +1,96 @@
+import re
+
+from django import forms
+from django.conf import settings
+from django.forms import CharField, CheckboxSelectMultiple, ModelMultipleChoiceField
+from taggit.models import Tag
+
+from lacommunaute.documentation.models import Category, Document
+from lacommunaute.partner.models import Partner
+
+
+def wrap_iframe_in_div_tag(text):
+ # iframe tags must be wrapped in a div tag to be displayed correctly
+ # add div tag if not present
+
+ iframe_regex = r"((
)?(
)?)"
+
+ for match, starts_with, ends_with in re.findall(iframe_regex, text, re.DOTALL):
+ if not starts_with and not ends_with:
+ text = text.replace(match, f"{match}
")
+
+ return text
+
+
+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=False, label="Contenu (markdown autorisé)"
+ )
+ image = forms.ImageField(
+ required=False,
+ 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 = ModelMultipleChoiceField(
+ label="Sélectionner un ou plusieurs tags",
+ queryset=Tag.objects.all(),
+ widget=CheckboxSelectMultiple,
+ required=False,
+ )
+ new_tags = 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/management/commands/migrate_documentation.py b/lacommunaute/documentation/management/commands/migrate_documentation.py
new file mode 100644
index 000000000..82f40ab40
--- /dev/null
+++ b/lacommunaute/documentation/management/commands/migrate_documentation.py
@@ -0,0 +1,163 @@
+import sys
+
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.redirects.models import Redirect
+from django.core.management.base import BaseCommand
+from taggit.models import TaggedItem
+
+from lacommunaute.documentation.models import Category, Document, DocumentRating
+from lacommunaute.forum.models import Forum, ForumRating
+from lacommunaute.forum_conversation.models import Topic
+from lacommunaute.forum_upvote.models import UpVote
+from lacommunaute.stats.models import DocumentationStat, ForumStat
+
+
+def create_categories_from_catforums():
+ transpo_dict = {}
+ redirections = []
+
+ for forum in Forum.objects.filter(type=1, level=0):
+ category = Category.objects.create(
+ name=forum.name,
+ short_description=forum.short_description,
+ description=forum.description,
+ image=forum.image,
+ )
+ redirections.append(Redirect(site_id=1, old_path=forum.get_absolute_url(), new_path=category.get_absolute_url()))
+ print(f"{category} created")
+ transpo_dict[forum] = category
+
+ Redirect.objects.bulk_create(redirections)
+
+ return transpo_dict
+
+
+def create_document_from_forums(category_transpo_dict):
+ forum_content_type = ContentType.objects.get_for_model(Forum)
+ document_content_type = ContentType.objects.get_for_model(Document)
+ transpo_dict = {}
+ redirections = []
+
+ for forum in Forum.objects.filter(parent__type=1):
+ document = Document.objects.create(
+ name=forum.name,
+ short_description=forum.short_description,
+ description=forum.description,
+ image=forum.image,
+ category=category_transpo_dict[forum.parent],
+ partner=forum.partner,
+ certified=forum.certified,
+ )
+ UpVote.objects.filter(content_type=forum_content_type, object_id=forum.id).update(
+ content_type=document_content_type, object_id=document.id
+ )
+ TaggedItem.objects.filter(content_type=forum_content_type, object_id=forum.id).update(
+ content_type=document_content_type, object_id=document.id
+ )
+ redirections.append(
+ Redirect(site_id=1, old_path=forum.get_absolute_url(), new_path=document.get_absolute_url())
+ )
+ transpo_dict[forum] = document
+
+ Redirect.objects.bulk_create(redirections)
+
+ return transpo_dict
+
+
+def migrate_ratings(document_transpo_dict):
+ document_ratings = [
+ DocumentRating(
+ document=document_transpo_dict[rating.forum],
+ session_id=rating.session_id,
+ rating=rating.rating,
+ user=rating.user,
+ created_at=rating.created,
+ updated_at=rating.updated,
+ )
+ for rating in ForumRating.objects.all()
+ ]
+ DocumentRating.objects.bulk_create(document_ratings)
+ ForumRating.objects.all().delete()
+
+
+def migrate_topics(document_transpo_dict):
+ main_forum = Forum.objects.get_main_forum()
+
+ for forum, document in document_transpo_dict.items():
+ topics = Topic.objects.filter(forum=forum)
+ sys.stdout.write(f"*** {len(topics)} topics to migrate from {forum} ({forum.id}) to {main_forum}\n")
+
+ for topic in topics:
+ topic.document = document
+ topic.forum = main_forum
+ topic.save()
+ forum.save()
+
+
+def migrate_stats(category_transpo_dict,document_transpo_dict):
+ category_content_type = ContentType.objects.get_for_model(Category)
+ document_content_type = ContentType.objects.get_for_model(Document)
+ documentation_stats = []
+
+ for forum, category in category_transpo_dict.items():
+ forum_stats = ForumStat.objects.filter(forum=forum)
+ documentation_stats += [
+ DocumentationStat(
+ content_type=category_content_type,
+ object_id=category.id,
+ date=stat.date,
+ period=stat.period,
+ visits=stat.visits,
+ entry_visits=stat.entry_visits,
+ time_spent=stat.time_spent,
+ )
+ for stat in forum_stats
+ ]
+
+ for forum, document in document_transpo_dict.items():
+ forum_stats = ForumStat.objects.filter(forum=forum)
+ documentation_stats += [
+ DocumentationStat(
+ content_type=document_content_type,
+ object_id=document.id,
+ date=stat.date,
+ period=stat.period,
+ visits=stat.visits,
+ entry_visits=stat.entry_visits,
+ time_spent=stat.time_spent,
+ )
+ for stat in forum_stats
+ ]
+
+ DocumentationStat.objects.bulk_create(documentation_stats)
+
+def del_forums(category_transpo_dict,document_transpo_dict):
+ forums_to_delete = list(category_transpo_dict.keys()) + list(document_transpo_dict.keys())
+ return Forum.objects.filter(pk__in=[forum.pk for forum in forums_to_delete]).delete()
+
+class Command(BaseCommand):
+ help = "migration des forums de fiches pratiques vers la documentation"
+
+ def handle(self, *args, **options):
+ sys.stdout.write("let's go!\n")
+
+ category_transpo_dict = create_categories_from_catforums()
+ sys.stdout.write("Categories created\n")
+
+ document_transpo_dict = create_document_from_forums(category_transpo_dict)
+ sys.stdout.write("Documents created\n")
+
+ migrate_ratings(document_transpo_dict)
+ sys.stdout.write("Ratings migrated\n")
+
+ migrate_topics(document_transpo_dict)
+ sys.stdout.write("Topics migrated\n")
+
+ migrate_stats(category_transpo_dict,document_transpo_dict)
+ sys.stdout.write("Stats migrated\n")
+
+ deleted_forums = del_forums(category_transpo_dict,document_transpo_dict)
+ sys.stdout.write(f"{deleted_forums} forums deleted\n")
+
+ sys.stdout.write("that's all folks!")
+ sys.stdout.flush()
diff --git a/lacommunaute/documentation/migrations/0001_initial.py b/lacommunaute/documentation/migrations/0001_initial.py
new file mode 100644
index 000000000..60e4987ea
--- /dev/null
+++ b/lacommunaute/documentation/migrations/0001_initial.py
@@ -0,0 +1,48 @@
+# Generated by Django 5.0.9 on 2024-09-10 10:16
+
+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="Category",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("name", models.CharField(max_length=100, verbose_name="Name")),
+ ("slug", models.SlugField(max_length=255, unique=True, verbose_name="Slug")),
+ (
+ "description",
+ machina.models.fields.MarkupTextField(
+ blank=True, no_rendered_field=True, null=True, verbose_name="Description"
+ ),
+ ),
+ (
+ "short_description",
+ models.CharField(blank=True, max_length=400, null=True, verbose_name="Description courte (SEO)"),
+ ),
+ (
+ "image",
+ models.ImageField(
+ storage=storages.backends.s3.S3Storage(bucket_name="private-bucket", file_overwrite=False),
+ upload_to="",
+ validators=[lacommunaute.utils.validators.validate_image_size],
+ ),
+ ),
+ ("_description_rendered", models.TextField(blank=True, editable=False, null=True)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/lacommunaute/documentation/migrations/0002_alter_category_options.py b/lacommunaute/documentation/migrations/0002_alter_category_options.py
new file mode 100644
index 000000000..5e9201073
--- /dev/null
+++ b/lacommunaute/documentation/migrations/0002_alter_category_options.py
@@ -0,0 +1,16 @@
+# Generated by Django 5.0.9 on 2024-09-10 13:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documentation", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="category",
+ options={"ordering": ["created_at"], "verbose_name": "Catégorie", "verbose_name_plural": "Catégories"},
+ ),
+ ]
diff --git a/lacommunaute/documentation/migrations/0003_document.py b/lacommunaute/documentation/migrations/0003_document.py
new file mode 100644
index 000000000..fef88e06f
--- /dev/null
+++ b/lacommunaute/documentation/migrations/0003_document.py
@@ -0,0 +1,81 @@
+# Generated by Django 5.0.9 on 2024-09-11 13:48
+
+import django.db.models.deletion
+import machina.models.fields
+import storages.backends.s3
+import taggit.managers
+from django.db import migrations, models
+
+import lacommunaute.utils.validators
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documentation", "0002_alter_category_options"),
+ ("partner", "0002_alter_partner_options"),
+ ("taggit", "0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Document",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("name", models.CharField(max_length=100, verbose_name="Name")),
+ ("slug", models.SlugField(max_length=255, unique=True, verbose_name="Slug")),
+ (
+ "description",
+ machina.models.fields.MarkupTextField(
+ blank=True, no_rendered_field=True, null=True, verbose_name="Description"
+ ),
+ ),
+ (
+ "short_description",
+ models.CharField(blank=True, max_length=400, null=True, verbose_name="Description courte (SEO)"),
+ ),
+ (
+ "image",
+ models.ImageField(
+ storage=storages.backends.s3.S3Storage(bucket_name="private-bucket", file_overwrite=False),
+ upload_to="",
+ validators=[lacommunaute.utils.validators.validate_image_size],
+ ),
+ ),
+ (
+ "certified",
+ models.BooleanField(default=False, verbose_name="Certifié par la communauté de l'inclusion"),
+ ),
+ ("_description_rendered", models.TextField(blank=True, editable=False, null=True)),
+ (
+ "category",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="documents",
+ to="documentation.category",
+ ),
+ ),
+ (
+ "partner",
+ models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="partner.partner"
+ ),
+ ),
+ (
+ "tags",
+ taggit.managers.TaggableManager(
+ help_text="A comma-separated list of tags.",
+ through="taggit.TaggedItem",
+ to="taggit.Tag",
+ verbose_name="Tags",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Document",
+ "verbose_name_plural": "Documents",
+ "ordering": ["-created_at"],
+ },
+ ),
+ ]
diff --git a/lacommunaute/documentation/migrations/0004_documentrating.py b/lacommunaute/documentation/migrations/0004_documentrating.py
new file mode 100644
index 000000000..b77418193
--- /dev/null
+++ b/lacommunaute/documentation/migrations/0004_documentrating.py
@@ -0,0 +1,40 @@
+# Generated by Django 5.0.9 on 2024-09-12 14:14
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documentation", "0003_document"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="DocumentRating",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("session_id", models.CharField(max_length=40)),
+ ("rating", models.PositiveSmallIntegerField()),
+ (
+ "document",
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="documentation.document"),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Notation d'un document",
+ "verbose_name_plural": "Notations des documents",
+ "ordering": ("-created_at",),
+ },
+ ),
+ ]
diff --git a/lacommunaute/documentation/migrations/0005_alter_documentrating_created_at_and_more.py b/lacommunaute/documentation/migrations/0005_alter_documentrating_created_at_and_more.py
new file mode 100644
index 000000000..6e078fa43
--- /dev/null
+++ b/lacommunaute/documentation/migrations/0005_alter_documentrating_created_at_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 5.0.9 on 2024-09-12 14:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documentation", "0004_documentrating"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="documentrating",
+ name="created_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name="documentrating",
+ name="updated_at",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/lacommunaute/documentation/models.py b/lacommunaute/documentation/models.py
new file mode 100644
index 000000000..e2a7a9a02
--- /dev/null
+++ b/lacommunaute/documentation/models.py
@@ -0,0 +1,69 @@
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db import models
+from django.urls import reverse
+from taggit.managers import TaggableManager
+
+from lacommunaute.documentation.abstract_models import AbstractPublication
+from lacommunaute.forum_upvote.models import UpVote
+from lacommunaute.partner.models import Partner
+
+
+class Category(AbstractPublication):
+ class Meta:
+ verbose_name = "Catégorie"
+ verbose_name_plural = "Catégories"
+ ordering = ["created_at"]
+
+ def __str__(self):
+ return f"{self.name}"
+
+ def get_absolute_url(self, with_fqdn=False):
+ absolute_url = reverse("documentation:category_detail", kwargs={"slug": self.slug, "pk": self.pk})
+ if with_fqdn:
+ return f"{settings.COMMU_PROTOCOL}://{settings.COMMU_FQDN}{absolute_url}"
+ return absolute_url
+
+
+class Document(AbstractPublication):
+ category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="documents")
+ partner = models.ForeignKey(Partner, on_delete=models.CASCADE, null=True, blank=True)
+ upvotes = GenericRelation(UpVote, related_query_name="document")
+ certified = models.BooleanField(default=False, verbose_name="Certifié par la communauté de l'inclusion")
+ tags = TaggableManager()
+
+ class Meta:
+ verbose_name = "Document"
+ verbose_name_plural = "Documents"
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return f"{self.name}"
+
+ def get_absolute_url(self, with_fqdn=False):
+ absolute_url = reverse(
+ "documentation:document_detail",
+ kwargs={
+ "category_pk": self.category.pk,
+ "slug": self.slug,
+ "pk": self.pk,
+ },
+ )
+ if with_fqdn:
+ return f"{settings.COMMU_PROTOCOL}://{settings.COMMU_FQDN}{absolute_url}"
+ return absolute_url
+
+
+# use AbstractDatedModel after ForumRanting migration
+class DocumentRating(models.Model):
+ created_at = models.DateTimeField(null=True, blank=True)
+ updated_at = models.DateTimeField(null=True, blank=True)
+ session_id = models.CharField(max_length=40)
+ document = models.ForeignKey(Document, on_delete=models.CASCADE)
+ rating = models.PositiveSmallIntegerField()
+ user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True)
+
+ class Meta:
+ verbose_name = "Notation d'un document"
+ verbose_name_plural = "Notations des documents"
+ ordering = ("-created_at",)
diff --git a/lacommunaute/documentation/tests/tests_models.py b/lacommunaute/documentation/tests/tests_models.py
new file mode 100644
index 000000000..544aa0108
--- /dev/null
+++ b/lacommunaute/documentation/tests/tests_models.py
@@ -0,0 +1,14 @@
+import pytest # noqa
+from django.db.utils import IntegrityError
+from lacommunaute.documentation.factories import CategoryFactory
+
+
+class TestCategory:
+ def test_slug(self, db):
+ category = CategoryFactory(for_snapshot=True)
+ assert category.slug == "test-category"
+
+ def test_slug_is_unique(self, db):
+ CategoryFactory(for_snapshot=True)
+ with pytest.raises(IntegrityError):
+ CategoryFactory(for_snapshot=True)
diff --git a/lacommunaute/documentation/urls.py b/lacommunaute/documentation/urls.py
new file mode 100644
index 000000000..f00233ad1
--- /dev/null
+++ b/lacommunaute/documentation/urls.py
@@ -0,0 +1,17 @@
+from django.urls import path
+
+from lacommunaute.documentation.views import CategoryDetailView, CategoryListView, DocumentDetailView
+
+
+app_name = "documentation"
+
+
+urlpatterns = [
+ path("", CategoryListView.as_view(), name="category_list"),
+ path("-/", CategoryDetailView.as_view(), name="category_detail"),
+ path(
+ "/-/",
+ DocumentDetailView.as_view(),
+ name="document_detail",
+ ),
+]
diff --git a/lacommunaute/documentation/views.py b/lacommunaute/documentation/views.py
new file mode 100644
index 000000000..1af4db72c
--- /dev/null
+++ b/lacommunaute/documentation/views.py
@@ -0,0 +1,60 @@
+
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.contrib.contenttypes.models import ContentType
+from django.views.generic import DetailView, ListView, 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"
+ context_object_name = "categories"
+ paginate_by = 20 * 3
+
+
+class CategoryDetailView(DetailView):
+ model = Category
+ template_name = "documentation/category_detail.html"
+ context_object_name = "category"
+
+ def get_tags_of_documents(self):
+ return Tag.objects.filter(
+ taggit_taggeditem_items__content_type=ContentType.objects.get_for_model(Document),
+ taggit_taggeditem_items__object_id__in=self.object.documents.all().values_list("id", flat=True),
+ ).distinct()
+
+ def get_queryset(self):
+ return super().get_queryset().prefetch_related("documents__tags")
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["tags"] = self.get_tags_of_documents()
+ context["active_tag_slug"] = self.request.GET.get("tag") or None
+ return context
+
+
+class CategoryUpdateView(UserPassesTestMixin,UpdateView):
+ model = Category
+ template_name = "documentation/category_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"] = reverse("forum_extension:forum", kwargs={"pk": self.object.pk, "slug": self.object.slug})
+ return context
+
+
+class DocumentDetailView(DetailView):
+ model = Document
+ template_name = "documentation/document_detail.html"
+ context_object_name = "document"
+
+
+# DocumentCreateUpdateView
diff --git a/lacommunaute/forum/admin.py b/lacommunaute/forum/admin.py
index 21b624c63..69a018271 100644
--- a/lacommunaute/forum/admin.py
+++ b/lacommunaute/forum/admin.py
@@ -6,7 +6,12 @@
class ForumAdmin(BaseForumAdmin):
fieldsets = BaseForumAdmin.fieldsets
- fieldsets[0][1]["fields"] += ("short_description", "certified", "tags", "partner")
+ fieldsets[0][1]["fields"] += (
+ "short_description",
+ "certified",
+ "tags",
+ "partner",
+ )
@admin.register(ForumRating)
diff --git a/lacommunaute/forum/forms.py b/lacommunaute/forum/forms.py
index c86796c65..9890af4e8 100644
--- a/lacommunaute/forum/forms.py
+++ b/lacommunaute/forum/forms.py
@@ -5,6 +5,7 @@
from django.forms import CharField, CheckboxSelectMultiple, ModelMultipleChoiceField
from taggit.models import Tag
+from lacommunaute.documentation.models import Category
from lacommunaute.forum.models import Forum
from lacommunaute.partner.models import Partner
@@ -38,39 +39,12 @@ class ForumForm(forms.ModelForm):
label="Banniere de couverture, format 1200 x 630 pixels recommandé",
widget=forms.FileInput(attrs={"accept": settings.SUPPORTED_IMAGE_FILE_TYPES.keys()}),
)
- 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,
- )
- tags = ModelMultipleChoiceField(
- label="Sélectionner un ou plusieurs tags",
- queryset=Tag.objects.all(),
- widget=CheckboxSelectMultiple,
- required=False,
- )
- new_tags = 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):
forum = super().save(commit=False)
forum.description = wrap_iframe_in_div_tag(self.cleaned_data.get("description"))
- if commit:
- forum.save()
- forum.tags.set(self.cleaned_data["tags"])
- (
- forum.tags.add(*[tag.strip() for tag in self.cleaned_data["new_tags"].split(",")])
- if self.cleaned_data.get("new_tags")
- else None
- )
- return forum
-
class Meta:
model = Forum
- fields = ["name", "short_description", "description", "image", "certified", "partner"]
+ fields = ["name", "short_description", "description", "image",]
diff --git a/lacommunaute/forum/models.py b/lacommunaute/forum/models.py
index 0f73b9baa..b8d74718c 100644
--- a/lacommunaute/forum/models.py
+++ b/lacommunaute/forum/models.py
@@ -20,18 +20,22 @@ def get_main_forum(self):
class Forum(AbstractForum):
+ # to be removed after documentation refactor
short_description = models.CharField(
max_length=400, blank=True, null=True, verbose_name="Description courte (SEO)"
)
+ # to be removed after documentation refactor
image = models.ImageField(
storage=S3Boto3Storage(bucket_name=settings.AWS_STORAGE_BUCKET_NAME, file_overwrite=False),
validators=[validate_image_size],
)
+ # to be removed after documentation refactor
certified = models.BooleanField(default=False, verbose_name="Certifié par la communauté de l'inclusion")
-
+ # to be removed after documentation refactor
upvotes = GenericRelation(UpVote, related_query_name="forum")
-
+ # to be removed after documentation refactor
tags = TaggableManager()
+ # to be removed after documentation refactor
partner = models.ForeignKey(Partner, on_delete=models.CASCADE, null=True, blank=True)
objects = ForumQuerySet().as_manager()
@@ -55,11 +59,7 @@ def count_unanswered_topics(self):
def upvotes_count(self):
return self.upvotes.count()
- @cached_property
- def is_in_documentation_area(self):
- return (self.type == Forum.FORUM_CAT and self.get_level() == 0) or (
- self.get_level() > 0 and self.get_ancestors().first().type == Forum.FORUM_CAT
- )
+
@cached_property
def is_toplevel_discussion_area(self):
@@ -72,6 +72,7 @@ def get_average_rating(self):
return ForumRating.objects.filter(forum=self).aggregate(models.Avg("rating"))["rating__avg"]
+# to be removed after documentation refactor
class ForumRating(DatedModel):
session_id = models.CharField(max_length=40)
forum = models.ForeignKey(Forum, on_delete=models.CASCADE)
diff --git a/lacommunaute/forum/urls.py b/lacommunaute/forum/urls.py
index 77cd7c35c..4b29df8f2 100644
--- a/lacommunaute/forum/urls.py
+++ b/lacommunaute/forum/urls.py
@@ -2,13 +2,9 @@
from machina.apps.forum.views import IndexView
from lacommunaute.forum.views import (
- CategoryForumCreateView,
- CategoryForumListView,
ForumRatingView,
ForumUpdateView,
ForumView,
- SubCategoryForumCreateView,
- SubCategoryForumListView,
)
@@ -19,9 +15,5 @@
path("forum/-/", ForumView.as_view(), name="forum"),
path("forum/-/update/", ForumUpdateView.as_view(), name="edit_forum"),
path("forum/-/rate/", ForumRatingView.as_view(), name="rate"),
- path("forum/-/subs/", SubCategoryForumListView.as_view(), name="subcategory_forums"),
path("forums/", IndexView.as_view(), name="index"),
- path("documentation/", CategoryForumListView.as_view(), name="documentation"),
- path("documentation/category/create/", CategoryForumCreateView.as_view(), name="create_category"),
- path("documentation/category//create/", SubCategoryForumCreateView.as_view(), name="create_subcategory"),
]
diff --git a/lacommunaute/forum/views.py b/lacommunaute/forum/views.py
index f16088f96..1f326961a 100644
--- a/lacommunaute/forum/views.py
+++ b/lacommunaute/forum/views.py
@@ -3,21 +3,18 @@
from django.conf import settings
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.contenttypes.models import ContentType
-from django.db.models.query import QuerySet
from django.shortcuts import get_object_or_404, render
-from django.urls import reverse, reverse_lazy
+from django.urls import reverse
from django.views import View
-from django.views.generic import CreateView, ListView, UpdateView
+from django.views.generic import UpdateView
from machina.apps.forum.views import ForumView as BaseForumView
from machina.core.loading import get_class
-from taggit.models import Tag
from lacommunaute.forum.forms import ForumForm
from lacommunaute.forum.models import Forum, ForumRating
from lacommunaute.forum_conversation.forms import PostForm
from lacommunaute.forum_conversation.view_mixins import FilteredTopicsListViewMixin
from lacommunaute.forum_upvote.models import UpVote
-from lacommunaute.utils.perms import add_public_perms_on_forum, forum_visibility_content_tree_from_forums
logger = logging.getLogger(__name__)
@@ -25,48 +22,15 @@
PermissionRequiredMixin = get_class("forum_permission.viewmixins", "PermissionRequiredMixin")
-class SubCategoryForumListMixin:
- def get_descendants(self):
- qs = self.get_forum().get_descendants()
-
- forum_tag = self.request.GET.get("forum_tag") or None
- if forum_tag:
- qs = qs.filter(tags__slug=forum_tag)
-
- return qs.prefetch_related("tags")
-
- def get_tags_of_descendants(self):
- return Tag.objects.filter(
- taggit_taggeditem_items__content_type=ContentType.objects.get_for_model(Forum),
- taggit_taggeditem_items__object_id__in=self.get_forum().get_descendants().values_list("id", flat=True),
- ).distinct()
-
- def forum_tag_context(self):
- return {
- # TODO : remove permission management, though all forums are public in our case
- "sub_forums": forum_visibility_content_tree_from_forums(self.request, self.get_descendants()),
- "tags_of_descendants": self.get_tags_of_descendants(),
- "active_forum_tag_slug": self.request.GET.get("forum_tag") or None,
- }
-
-
-class ForumView(BaseForumView, FilteredTopicsListViewMixin, SubCategoryForumListMixin):
+class ForumView(BaseForumView, FilteredTopicsListViewMixin):
paginate_by = settings.FORUM_TOPICS_NUMBER_PER_PAGE
def get_template_names(self):
if self.request.META.get("HTTP_HX_REQUEST"):
return ["forum_conversation/topic_list.html"]
- if self.will_render_documentation_variant():
- return ["forum/forum_documentation.html"]
- if self.will_render_documentation_category_variant():
- return ["forum/forum_documentation_category.html"]
return ["forum/forum_detail.html"]
- def will_render_documentation_variant(self):
- return self.get_forum().parent and self.forum.is_in_documentation_area
- def will_render_documentation_category_variant(self):
- return self.get_forum().is_in_documentation_area and self.forum.level == 0
def get_queryset(self):
return self.filter_queryset(self.get_forum().topics.optimized_for_topics_list(self.request.user.id))
@@ -100,25 +64,13 @@ def get_context_data(self, **kwargs):
)
context = context | self.get_topic_filter_context()
- if self.will_render_documentation_category_variant():
- context = context | self.forum_tag_context()
- if self.will_render_documentation_variant():
- context["sibling_forums"] = forum.get_siblings(include_self=True)
if forum.image:
context["og_image"] = forum.image
return context
-class SubCategoryForumListView(BaseForumView, SubCategoryForumListMixin):
- template_name = "forum/partials/subcategory_forum_list.html"
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs) | self.forum_tag_context()
- return context
-
-
class ForumUpdateView(UserPassesTestMixin, UpdateView):
template_name = "forum/forum_create_or_update.html"
form_class = ForumForm
@@ -134,63 +86,6 @@ def get_context_data(self, **kwargs):
return context
-class CategoryForumListView(ListView):
- template_name = "forum/category_forum_list.html"
- context_object_name = "forums"
-
- def get_queryset(self) -> QuerySet[Forum]:
- return Forum.objects.filter(type=Forum.FORUM_CAT, level=0)
-
-
-class BaseCategoryForumCreateView(UserPassesTestMixin, CreateView):
- template_name = "forum/forum_create_or_update.html"
- form_class = ForumForm
-
- def test_func(self):
- return self.request.user.is_superuser
-
- def form_valid(self, form):
- response = super().form_valid(form)
- add_public_perms_on_forum(form.instance)
- return response
-
-
-class CategoryForumCreateView(BaseCategoryForumCreateView):
- success_url = reverse_lazy("forum_extension:documentation")
-
- def form_valid(self, form):
- form.instance.parent = None
- form.instance.type = Forum.FORUM_CAT
- return super().form_valid(form)
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["title"] = "Créer une nouvelle catégorie documentaire"
- context["back_url"] = reverse("forum_extension:documentation")
- return context
-
-
-class SubCategoryForumCreateView(BaseCategoryForumCreateView):
- def get_success_url(self):
- return reverse("forum_extension:forum", kwargs={"pk": self.object.pk, "slug": self.object.slug})
-
- def get_parent_forum(self):
- return Forum.objects.get(pk=self.kwargs["pk"])
-
- def form_valid(self, form):
- form.instance.type = Forum.FORUM_POST
- form.instance.parent = self.get_parent_forum()
- return super().form_valid(form)
-
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- context["title"] = f"Créer une fiche pratique dans la catégorie {self.get_parent_forum().name}"
- context["back_url"] = reverse(
- "forum_extension:forum", kwargs={"pk": self.get_parent_forum().pk, "slug": self.get_parent_forum().slug}
- )
- return context
-
-
class ForumRatingView(View):
def post(self, request, *args, **kwargs):
forum_rating = ForumRating.objects.create(
diff --git a/lacommunaute/forum_conversation/migrations/0009_topic_document.py b/lacommunaute/forum_conversation/migrations/0009_topic_document.py
new file mode 100644
index 000000000..e7d52d8e6
--- /dev/null
+++ b/lacommunaute/forum_conversation/migrations/0009_topic_document.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.0.9 on 2024-09-11 13:48
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documentation", "0003_document"),
+ ("forum_conversation", "0008_remove_topic_likers"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="topic",
+ name="document",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="topics",
+ to="documentation.document",
+ ),
+ ),
+ ]
diff --git a/lacommunaute/forum_conversation/models.py b/lacommunaute/forum_conversation/models.py
index c0325e804..0d92706dc 100644
--- a/lacommunaute/forum_conversation/models.py
+++ b/lacommunaute/forum_conversation/models.py
@@ -9,6 +9,7 @@
from machina.models.abstract_models import DatedModel
from taggit.managers import TaggableManager
+from lacommunaute.documentation.models import Document
from lacommunaute.forum_conversation.signals import post_create
from lacommunaute.forum_member.shortcuts import get_forum_member_display_name
from lacommunaute.forum_upvote.models import UpVote
@@ -52,6 +53,7 @@ def optimized_for_topics_list(self, user_id):
class Topic(AbstractTopic):
tags = TaggableManager()
+ document = models.ForeignKey(Document, on_delete=models.SET_NULL, null=True, blank=True, related_name="topics")
def get_absolute_url(self, with_fqdn=False):
absolute_url = reverse(
diff --git a/lacommunaute/search/models.py b/lacommunaute/search/models.py
index a4c4840e4..3f0795d42 100644
--- a/lacommunaute/search/models.py
+++ b/lacommunaute/search/models.py
@@ -41,3 +41,7 @@ class CommonIndex(models.Model):
class Meta:
managed = False
+
+
+# ajouter Document dans la materilized view
+# ajouter Categorie dans la materilized view
diff --git a/lacommunaute/static/stylesheets/itou_communaute.scss b/lacommunaute/static/stylesheets/itou_communaute.scss
index 63dc19715..914e3dfbf 100644
--- a/lacommunaute/static/stylesheets/itou_communaute.scss
+++ b/lacommunaute/static/stylesheets/itou_communaute.scss
@@ -220,3 +220,9 @@ span.highlighted {
.s-home-title-01::after {
left: 52%;
}
+
+.vertical-line {
+ border-left: 1px solid #a1a1a1;
+ padding-left: 10px;
+ height: 100%;
+}
diff --git a/lacommunaute/stats/admin.py b/lacommunaute/stats/admin.py
index c802d823a..4db9ac6c4 100644
--- a/lacommunaute/stats/admin.py
+++ b/lacommunaute/stats/admin.py
@@ -1,8 +1,9 @@
from dateutil.relativedelta import relativedelta
from django.contrib import admin
+from django.contrib.contenttypes.models import ContentType
from lacommunaute.forum.models import Forum
-from lacommunaute.stats.models import ForumStat, Stat
+from lacommunaute.stats.models import DocumentationStat, ForumStat, Stat
class ForumWithStatsFilter(admin.SimpleListFilter):
@@ -19,6 +20,23 @@ def queryset(self, request, queryset):
return queryset
+class DocumentationContentTypeFilter(admin.SimpleListFilter):
+ title = "Documentation type"
+ parameter_name = "content_type"
+
+ def lookups(self, request, model_admin):
+ content_types = ContentType.objects.filter(
+ model__in=['category', 'document'],
+ app_label='documentation'
+ )
+
+ return [(ct.id, ct.name) for ct in content_types]
+
+ def queryset(self, request, queryset):
+ if self.value():
+ return queryset.filter(content_type_id=self.value())
+ return queryset
+
class BaseStatAdmin(admin.ModelAdmin):
list_display = ("explicit_period",)
list_filter = ("date", "period")
@@ -43,3 +61,10 @@ class ForumStatAdmin(BaseStatAdmin):
list_display = BaseStatAdmin.list_display + ("forum", "visits", "entry_visits", "time_spent")
list_filter = BaseStatAdmin.list_filter + (ForumWithStatsFilter,)
raw_id_fields = ("forum",)
+
+
+@admin.register(DocumentationStat)
+class DocumentionStatAdmin(BaseStatAdmin):
+ list_display = BaseStatAdmin.list_display + ("content_type","object_id", "visits", "entry_visits", "time_spent")
+ list_filter = BaseStatAdmin.list_filter + (DocumentationContentTypeFilter,)
+
diff --git a/lacommunaute/stats/migrations/0003_documentstat.py b/lacommunaute/stats/migrations/0003_documentstat.py
new file mode 100644
index 000000000..46a756362
--- /dev/null
+++ b/lacommunaute/stats/migrations/0003_documentstat.py
@@ -0,0 +1,47 @@
+# Generated by Django 5.0.9 on 2024-09-12 14:45
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documentation", "0005_alter_documentrating_created_at_and_more"),
+ ("stats", "0002_forumstat"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="DocumentStat",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("date", models.DateField(verbose_name="Date")),
+ (
+ "period",
+ models.CharField(
+ choices=[("month", "Month"), ("week", "Week"), ("day", "Day")],
+ max_length=10,
+ verbose_name="Période",
+ ),
+ ),
+ ("visits", models.IntegerField(default=0, verbose_name="Visites")),
+ ("entry_visits", models.IntegerField(default=0, verbose_name="Visites entrantes")),
+ ("time_spent", models.IntegerField(default=0, verbose_name="Temps passé")),
+ (
+ "document",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="documentation.document",
+ verbose_name="Document",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Stat d'un document",
+ "verbose_name_plural": "Stats des documents",
+ "ordering": ["date", "period", "document"],
+ "unique_together": {("date", "period", "document")},
+ },
+ ),
+ ]
diff --git a/lacommunaute/stats/migrations/0004_documentationstat_delete_documentstat.py b/lacommunaute/stats/migrations/0004_documentationstat_delete_documentstat.py
new file mode 100644
index 000000000..cb7cbf412
--- /dev/null
+++ b/lacommunaute/stats/migrations/0004_documentationstat_delete_documentstat.py
@@ -0,0 +1,47 @@
+# Generated by Django 5.0.9 on 2024-09-18 12:14
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("stats", "0003_documentstat"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="DocumentationStat",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("object_id", models.PositiveBigIntegerField()),
+ ("date", models.DateField(verbose_name="Date")),
+ (
+ "period",
+ models.CharField(
+ choices=[("month", "Month"), ("week", "Week"), ("day", "Day")],
+ max_length=10,
+ verbose_name="Période",
+ ),
+ ),
+ ("visits", models.IntegerField(default=0, verbose_name="Visites")),
+ ("entry_visits", models.IntegerField(default=0, verbose_name="Visites entrantes")),
+ ("time_spent", models.IntegerField(default=0, verbose_name="Temps passé")),
+ (
+ "content_type",
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"),
+ ),
+ ],
+ options={
+ "verbose_name": "Stat de la documentation",
+ "verbose_name_plural": "Stats de la documentation",
+ "ordering": ["date", "period", "content_type", "object_id"],
+ "unique_together": {("date", "period", "content_type", "object_id")},
+ },
+ ),
+ migrations.DeleteModel(
+ name="DocumentStat",
+ ),
+ ]
diff --git a/lacommunaute/stats/models.py b/lacommunaute/stats/models.py
index 6d34d474c..c03724e78 100644
--- a/lacommunaute/stats/models.py
+++ b/lacommunaute/stats/models.py
@@ -1,3 +1,5 @@
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
from django.db import models
from lacommunaute.forum.models import Forum
@@ -37,7 +39,7 @@ def __str__(self):
objects = StatQuerySet().as_manager()
-
+# to be removed after documentation refactor
class ForumStat(models.Model):
"""
Represents a statistical data point, relative to a forum, for a given date and period.
@@ -60,3 +62,25 @@ class Meta:
def __str__(self):
return f"{self.date} - {self.period} - {self.forum}"
+
+
+class DocumentationStat(models.Model):
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+ object_id = models.PositiveBigIntegerField()
+ content_object = GenericForeignKey("content_type", "object_id")
+ date = models.DateField(verbose_name="Date")
+ period = models.CharField(max_length=10, verbose_name="Période", choices=Period.choices)
+ visits = models.IntegerField(verbose_name="Visites", default=0)
+ entry_visits = models.IntegerField(verbose_name="Visites entrantes", default=0)
+ time_spent = models.IntegerField(verbose_name="Temps passé", default=0)
+
+ objects = models.Manager()
+
+ class Meta:
+ verbose_name = "Stat de la documentation"
+ verbose_name_plural = "Stats de la documentation"
+ ordering = ["date", "period", "content_type", "object_id"]
+ unique_together = ("date", "period", "content_type", "object_id")
+
+ def __str__(self):
+ return f"{self.date} - {self.period} "
diff --git a/lacommunaute/templates/404.html b/lacommunaute/templates/404.html
index b06262438..359bdb9dd 100644
--- a/lacommunaute/templates/404.html
+++ b/lacommunaute/templates/404.html
@@ -15,7 +15,7 @@ Bienvenue sur le site de la Communauté de l'Inclusion.
Espace d'échanges
- Documentation
+ Documentation
Recherche
diff --git a/lacommunaute/templates/documentation/category_detail.html b/lacommunaute/templates/documentation/category_detail.html
new file mode 100644
index 000000000..8a8164a7d
--- /dev/null
+++ b/lacommunaute/templates/documentation/category_detail.html
@@ -0,0 +1,86 @@
+{% extends "layouts/base.html" %}
+{% block title %}{{ category.name }}{{ block.super }}{% endblock %}
+{% block meta_description %}
+ {{ category.short_description }}
+{% endblock meta_description %}
+{% block breadcrumb %}
+ {% include "partials/breadcrumb.html" %}
+{% endblock %}
+{% block content %}
+ {% load i18n %}
+ {% include 'documentation/partials/title_and_shortdesc.html' with obj=category user=user only %}
+ {% if category.description %}
+
+
+
{% include 'documentation/partials/image_and_desc.html' with obj=category only %}
+
+
+ {% endif %}
+ {% if tags %}
+
+
+
+
+
+
Afficher les fiches contenant l'étiquette
+
+ {% for tag in tags %}
+ {% if tag.slug == active_tag_slug %}
+
+ {{ tag.name }}
+
+ {% else %}
+ {{ tag.name }}
+ {% endif %}
+ {% endfor %}
+
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+ {% for obj in category.documents.all %}
+ {% include 'documentation/partials/content_summary.html' with obj=obj kind='document' only %}
+ {% endfor %}
+
+
+
+
+
+ {% if user.is_superuser %}
+
+ {% endif %}
+{% endblock content %}
diff --git a/lacommunaute/templates/documentation/category_list.html b/lacommunaute/templates/documentation/category_list.html
new file mode 100644
index 000000000..b3039af64
--- /dev/null
+++ b/lacommunaute/templates/documentation/category_list.html
@@ -0,0 +1,43 @@
+{% extends "layouts/base.html" %}
+{% load i18n %}
+{% block title %}
+ {% trans "Documents" %}{{ block.super }}
+{% endblock %}
+{% block meta_description %}
+ Des ressources exclusives pour les professionnels de l'inclusion. Retrouver les ressources nécessaires pour améliorer ses diagnostics socio-professionnels et ses accompagnements des personnes éloignées de l’emploi.
+{% endblock meta_description %}
+"
+{% block content %}
+
+
+
+
+
{% trans "Documents" %}
+
+ Retrouver les ressources nécessaires pour améliorer ses diagnostics socio-professionnels et ses accompagnements des personnes éloignées de l’emploi
+
+
+
+
+
+
+
+
+ {% for category in categories %}
+ {% include 'documentation/partials/content_summary.html' with obj=category kind='category' only %}
+ {% endfor %}
+
+
+
+ {% if user.is_superuser %}
+
+ {% endif %}
+{% endblock content %}
diff --git a/lacommunaute/templates/documentation/document_detail.html b/lacommunaute/templates/documentation/document_detail.html
new file mode 100644
index 000000000..16ec10260
--- /dev/null
+++ b/lacommunaute/templates/documentation/document_detail.html
@@ -0,0 +1,48 @@
+{% extends "layouts/base.html" %}
+{% block title %}{{ document.name }}{{ block.super }}{% endblock %}
+{% block meta_description %}
+ {{ document.short_description }}
+{% endblock meta_description %}
+{% block breadcrumb %}
+ {% include "partials/breadcrumb.html" with document=document only %}
+{% endblock %}
+{% block content %}
+ {% load i18n %}
+ {% include 'documentation/partials/title_and_shortdesc.html' with obj=document user=user only %}
+ {% if document.description %}
+
+
+
+
+
+
{% include 'documentation/partials/certified.html' with obj=document only %}
+
{% include "partials/upvotes.html" with obj=document %}
+ {% include 'documentation/partials/image_and_desc.html' with obj=document only %}
+ {% if document.partner %}
+
+ {% include "documentation/partials/partner.html" with partner=document.partner only %}
+
+ {% endif %}
+ {% comment %}{% include "forum/partials/rating.html" with forum=obj rating_area_id="1" %}{% endcomment %}
+
+
+
+
+
+
+ {% endif %}
+{% endblock content %}
diff --git a/lacommunaute/templates/documentation/partials/certified.html b/lacommunaute/templates/documentation/partials/certified.html
new file mode 100644
index 000000000..87d52279d
--- /dev/null
+++ b/lacommunaute/templates/documentation/partials/certified.html
@@ -0,0 +1,7 @@
+{% if obj.certified %}
+
+
+ Certifiée par la communauté de l'inclusion
+
+{% endif %}
+Mis à jour le {{ obj.updated_at|date:"d/m/Y" }}
diff --git a/lacommunaute/templates/documentation/partials/content_summary.html b/lacommunaute/templates/documentation/partials/content_summary.html
new file mode 100644
index 000000000..2f8037db7
--- /dev/null
+++ b/lacommunaute/templates/documentation/partials/content_summary.html
@@ -0,0 +1,37 @@
+
+
+ {% if obj.image %}
+
+ {% endif %}
+
+
{{ obj.name }}
+
+ {% for tag in obj.tags.all %}{{ tag.name }} {% endfor %}
+
+ {% if obj.short_description %}
{{ obj.short_description }}
{% endif %}
+
+
+
+
diff --git a/lacommunaute/templates/documentation/partials/image_and_desc.html b/lacommunaute/templates/documentation/partials/image_and_desc.html
new file mode 100644
index 000000000..0ed1b4fad
--- /dev/null
+++ b/lacommunaute/templates/documentation/partials/image_and_desc.html
@@ -0,0 +1,11 @@
+{% load str_filters %}
+{% if obj.image %}
+
+
+
+
+
+{% endif %}
+
+
{{ obj.description.rendered|urlizetrunc_target_blank:30|img_fluid }}
+
diff --git a/lacommunaute/templates/documentation/partials/partner.html b/lacommunaute/templates/documentation/partials/partner.html
new file mode 100644
index 000000000..a40782717
--- /dev/null
+++ b/lacommunaute/templates/documentation/partials/partner.html
@@ -0,0 +1,8 @@
+
+
+ {% if partner.logo %}
+
+ {% endif %}
+
Fiche co-rédigée en partenariat avec {{ partner.name }}
+
+
diff --git a/lacommunaute/templates/documentation/partials/title_and_shortdesc.html b/lacommunaute/templates/documentation/partials/title_and_shortdesc.html
new file mode 100644
index 000000000..863f2ec38
--- /dev/null
+++ b/lacommunaute/templates/documentation/partials/title_and_shortdesc.html
@@ -0,0 +1,11 @@
+
+
+
+
+
{{ obj.name }}
+ {% if user.is_superuser %}
Mettre à jour {% endif %}
+ {% if obj.short_description %}
{{ obj.short_description }} {% endif %}
+
+
+
+
diff --git a/lacommunaute/templates/forum/category_forum_list.html b/lacommunaute/templates/forum/category_forum_list.html
deleted file mode 100644
index e1949d499..000000000
--- a/lacommunaute/templates/forum/category_forum_list.html
+++ /dev/null
@@ -1,67 +0,0 @@
-{% extends "layouts/base.html" %}
-{% load i18n %}
-{% block title %}
- {% trans "Documents" %}{{ block.super }}
-{% endblock %}
-{% block meta_description %}
- Des ressources exclusives pour les professionnels de l'inclusion. Retrouver les ressources nécessaires pour améliorer ses diagnostics socio-professionnels et ses accompagnements des personnes éloignées de l’emploi.
-{% endblock meta_description %}
-"
-{% block content %}
-
-
-
-
-
{% trans "Documents" %}
-
- Retrouver les ressources nécessaires pour améliorer ses diagnostics socio-professionnels et ses accompagnements des personnes éloignées de l’emploi
-
-
-
-
-
-
-
-
- {% for forum in forums %}
-
-
- {% if forum.image %}
-
- {% endif %}
-
-
- {{ forum.name }}
-
- {% if forum.short_description %}
{{ forum.short_description }}
{% endif %}
-
-
-
-
- {% endfor %}
-
-
-
- {% if user.is_superuser %}
-
- {% endif %}
-{% endblock content %}
diff --git a/lacommunaute/templates/forum/forum_documentation_category.html b/lacommunaute/templates/forum/forum_documentation_category.html
deleted file mode 100644
index e12bfabe5..000000000
--- a/lacommunaute/templates/forum/forum_documentation_category.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% extends "forum/forum_detail.html" %}
-{% block subforum_list %}
- {% include "forum/partials/subcategory_forum_list.html" with forum=forum sub_forums=sub_forums tags_of_descendants=tags_of_descendants active_forum_tag_slug=active_forum_tag_slug only %}
-{% endblock subforum_list %}
-{% block forum_foot_content %}
- {% if user.is_superuser %}
-
- {% endif %}
-{% endblock %}
diff --git a/lacommunaute/templates/pages/home.html b/lacommunaute/templates/pages/home.html
index d4ad09ad1..784ae8892 100644
--- a/lacommunaute/templates/pages/home.html
+++ b/lacommunaute/templates/pages/home.html
@@ -10,7 +10,7 @@
{% block body_class %}p-home{{ block.super }}{% endblock %}
{% block content %}
{% url 'forum_conversation_extension:topics' as publicforum_url %}
- {% url 'forum_extension:documentation' as documentation_url %}
+ {% url 'documentation:category_list' as documentation_url %}
{% url 'event:current' as event_url %}
{% url 'surveys:dsp_create' as dsp_url %}
diff --git a/lacommunaute/templates/partials/ask_a_question.html b/lacommunaute/templates/partials/ask_a_question.html
index 3516f50cc..9fd7a5e4c 100644
--- a/lacommunaute/templates/partials/ask_a_question.html
+++ b/lacommunaute/templates/partials/ask_a_question.html
@@ -21,7 +21,7 @@
ou