diff --git a/apps/organizations/migrations/0005_templatetab.py b/apps/organizations/migrations/0005_templatetab.py new file mode 100644 index 00000000..8380a56c --- /dev/null +++ b/apps/organizations/migrations/0005_templatetab.py @@ -0,0 +1,67 @@ +# Generated by Django 6.0.5 on 2026-08-13 13:22 + +import apps.commons.mixins +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0004_categoryfollow_categoryfollow_unique_category_follow"), + ] + + operations = [ + migrations.CreateModel( + name="TemplateTab", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("uuid", models.UUIDField(auto_created=True, default=uuid.uuid4)), + ( + "title", + models.TextField(blank=True, default="", max_length=255, null=True), + ), + ("description", models.TextField(blank=True, null=True)), + ( + "type", + models.CharField( + choices=[("text", "Text"), ("blog", "Blog")], + default="text", + max_length=32, + ), + ), + ("icon", models.CharField(blank=True, max_length=255, null=True)), + ("show_preview", models.BooleanField(default=True)), + ( + "title_item", + models.TextField(blank=True, default="", max_length=255, null=True), + ), + ("content_item", models.TextField(blank=True, null=True)), + ( + "template", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tabs", + to="organizations.template", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("uuid",), name="unique_template_tab" + ) + ], + }, + bases=(apps.commons.mixins.OrganizationRelated, models.Model), + ), + ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index bf284b30..b0a1929c 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -1,3 +1,4 @@ +import uuid from typing import TYPE_CHECKING, Any, Optional from django.contrib.auth.models import Group, Permission @@ -19,6 +20,7 @@ get_permissions_from_subscopes, get_write_permissions_from_subscopes, ) +from apps.projects.models import ProjectTab, ProjectTabItem from services.translator.mixins import HasAutoTranslatedFields if TYPE_CHECKING: @@ -480,6 +482,41 @@ def get_related_organizations(self) -> Organization: return [self.organization] +class TemplateTab(OrganizationRelated, models.Model): + uuid = models.UUIDField(auto_created=True, default=uuid.uuid4) + + # tabs + title = models.TextField(max_length=255, default="", blank=True, null=True) + description = models.TextField(blank=True, null=True) + template = models.ForeignKey( + Template, on_delete=models.CASCADE, related_name="tabs" + ) + type = models.CharField( + max_length=32, + choices=ProjectTab.TabType.choices, + default=ProjectTab.TabType.TEXT, + ) + icon = models.CharField(max_length=255, blank=True, null=True) + show_preview = models.BooleanField(default=True) + + # content + title_item = models.TextField(max_length=255, default="", blank=True, null=True) + content_item = models.TextField(blank=True, null=True) + + class Meta: + # tab need to be different uuid template (to have "unique tab") + constraints = [ + models.UniqueConstraint( + fields=["uuid"], + name="unique_template_tab", + ), + ] + + def get_related_organizations(self) -> Organization: + """Return the organizations related to this model.""" + return self.template.get_related_organizations() + + class ProjectCategory( HasAutoTranslatedFields, HasMultipleIDs, OrganizationRelated, models.Model ): diff --git a/apps/organizations/serializers.py b/apps/organizations/serializers.py index b8368b99..b6235ae8 100644 --- a/apps/organizations/serializers.py +++ b/apps/organizations/serializers.py @@ -4,10 +4,13 @@ from typing import Any from django.conf import settings +from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404 from rest_framework import serializers from rest_framework.relations import SlugRelatedField +from services.keycloak.serializers import IdentityProviderSerializer +from services.translator.serializers import auto_translated from apps.accounts.models import ProjectUser from apps.commons.fields import ( @@ -27,8 +30,6 @@ TagClassificationMultipleIdRelatedField, TagRelatedField, ) -from services.keycloak.serializers import IdentityProviderSerializer -from services.translator.serializers import auto_translated from .exceptions import ( CategoryHierarchyLoopError, @@ -44,6 +45,7 @@ Organization, ProjectCategory, Template, + TemplateTab, TermsAndConditions, ) @@ -449,12 +451,38 @@ def get_related_organizations(self) -> list[Organization]: return [ProjectCategory.objects.get(id=self.validated_data["id"]).organization] +class TemplateTabSerializer(StringsImagesSerializer, serializers.ModelSerializer): + string_images_fields: list[str] = [ + "content", + "description", + ] + + id = serializers.IntegerField(required=False) + uuid = serializers.UUIDField(read_only=True) + + class Meta: + model = TemplateTab + fields = ( + "id", + "uuid", + "title", + "description", + "template", + "type", + "icon", + "show_preview", + "title_item", + "content_item", + ) + + @auto_translated class ProjectTemplateSerializer( OrganizationRelatedSerializer, serializers.ModelSerializer, ): project_tags = TagRelatedField(many=True, read_only=True) + tabs = TemplateTabSerializer(many=True) class Meta: model = Template @@ -474,6 +502,7 @@ class Meta: "review_title", "review_description", "comment_content", + "tabs", ] fields = read_only_fields @@ -513,6 +542,7 @@ class TemplateSerializer( queryset=ProjectCategory.objects.all(), source="categories", ) + tabs = TemplateTabSerializer(many=True) class Meta: model = Template @@ -533,6 +563,7 @@ class Meta: "review_description", "comment_content", "categories_ids", + "tabs", ] def get_related_organizations(self) -> list[Organization]: @@ -548,6 +579,49 @@ def get_string_images_kwargs( "template_id": instance.id, } + @transaction.atomic + def update(self, instance, validated_data): + tabs_data = validated_data.pop("tabs", None) + + # Update du Template + instance = super().update(instance, validated_data) + + if tabs_data is None: + return instance + + existing_tabs = {tab.id: tab for tab in instance.tabs.all()} + received_ids = set() + + for tab_data in tabs_data: + tab_id = tab_data.pop("id", None) + tab_data["template"] = instance + + tab = existing_tabs.get(tab_id, TemplateTab(**tab_data)) + + for field, value in tab_data.items(): + setattr(tab, field, value) + + tab.save() + received_ids.add(tab.id) + + to_delete = [tab_id for tab_id in existing_tabs if tab_id not in received_ids] + TemplateTab.objects.filter(id__in=to_delete).delete() + + return instance + + @transaction.atomic + def create(self, validated_data): + tabs_data = validated_data.pop("tabs", []) + + template = super().create(validated_data) + + for tab_data in tabs_data: + tab_data["template"] = template + template = TemplateTab(**tab_data) + template.save() + + return template + @auto_translated class ProjectCategoryHierarchySerializer( diff --git a/apps/organizations/signals.py b/apps/organizations/signals.py index 7945ba6a..e52d8a21 100644 --- a/apps/organizations/signals.py +++ b/apps/organizations/signals.py @@ -1,7 +1,10 @@ from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver -from .models import TermsAndConditions +from apps.projects.models import Project +from apps.projects.utils import sync_project_tabs + +from .models import TemplateTab, TermsAndConditions @receiver(pre_delete, sender="organizations.Organization") @@ -15,3 +18,9 @@ def create_terms_and_conditions(sender, instance, created, **kwargs): """Create the associated terms and conditions at user's creation.""" if created: TermsAndConditions.objects.get_or_create(organization=instance) + + +@receiver(post_save, sender="organizations.TemplateTab") +def update_project_tab(sender, instance: TemplateTab, created=False, **kwargs): + template = instance.template + sync_project_tabs(Project.objects.filter(template=template), [instance]) diff --git a/apps/projects/migrations/0005_projecttab_uuid_projecttab_unique_project_tab.py b/apps/projects/migrations/0005_projecttab_uuid_projecttab_unique_project_tab.py new file mode 100644 index 00000000..d5819400 --- /dev/null +++ b/apps/projects/migrations/0005_projecttab_uuid_projecttab_unique_project_tab.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.5 on 2026-08-13 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0005_alter_image_options_peoplegroupimage"), + ( + "projects", + "0004_projecttab_show_preview_alter_projecttab_description_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="projecttab", + name="uuid", + field=models.UUIDField(default=None, null=True), + ), + migrations.AddConstraint( + model_name="projecttab", + constraint=models.UniqueConstraint( + condition=models.Q(("uuid__isnull", False)), + fields=("uuid", "project"), + name="unique_project_tab", + ), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 8a19a1d1..ae041207 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -1023,6 +1023,8 @@ class ProjectTab( """ auto_translated_fields: list[str] = ["title", "html:description"] + # uuid linked to template model + uuid = models.UUIDField(null=True, default=None) class TabType(models.TextChoices): """Type of a tab.""" @@ -1044,6 +1046,20 @@ class TabType(models.TextChoices): images = models.ManyToManyField("files.Image", related_name="project_tabs") show_preview = models.BooleanField(default=True) + class Meta: + # tab need to be different uuid by project (to have "unique tab") + constraints = [ + models.UniqueConstraint( + fields=["uuid", "project"], + name="unique_project_tab", + # ignore uuid if tab is create by user (not template) + condition=models.Q(uuid__isnull=False), + ), + ] + + def __repr__(self): + return f"" + def get_related_project(self) -> Project: """Return the projects related to this model.""" return self.project diff --git a/apps/projects/serializers.py b/apps/projects/serializers.py index a86bc64a..cd82caff 100644 --- a/apps/projects/serializers.py +++ b/apps/projects/serializers.py @@ -923,6 +923,7 @@ class Meta: "icon", "images", "show_preview", + "uuid", ] def validate_type(self, value: str): diff --git a/apps/projects/signals.py b/apps/projects/signals.py index a7f26fba..7e787ddd 100644 --- a/apps/projects/signals.py +++ b/apps/projects/signals.py @@ -4,7 +4,14 @@ from apps.announcements.models import Announcement from apps.feedbacks.models import Comment from apps.files.models import AttachmentFile, AttachmentLink -from apps.projects.models import BlogEntry, Goal, LinkedProject, Location +from apps.projects.models import ( + BlogEntry, + Goal, + LinkedProject, + Location, + Project, +) +from apps.projects.utils import sync_project_tabs @receiver(post_save, sender="projects.BlogEntry") @@ -149,3 +156,12 @@ def on_announcement_delete( project = instance.project project._change_reason = "Removed announcement" project.save() + + +@receiver(post_save, sender="projects.Project") +def on_edit_project(sender, instance: Project, created, **kw): + + # if project have a template, update projects-tabs + template = instance.template + if template: + sync_project_tabs([instance], template.tabs.all()) diff --git a/apps/projects/utils.py b/apps/projects/utils.py index af64556b..d9c91f9b 100644 --- a/apps/projects/utils.py +++ b/apps/projects/utils.py @@ -1,3 +1,4 @@ +from collections.abc import Iterable from typing import Any, TypeVar from django.db.models import CharField, QuerySet, Value @@ -5,10 +6,10 @@ from rest_framework import serializers from rest_framework.utils import model_meta -from apps.organizations.models import Organization +from apps.organizations.models import Organization, Template, TemplateTab from services.translator.serializers import prefix_fields_langs -from .models import Project +from .models import Project, ProjectTab T = TypeVar("T") @@ -95,3 +96,36 @@ def annotate_queryset_location(*querysets: QuerySet) -> QuerySet: all_qs = qs if all_qs is None else all_qs.union(qs) return all_qs + + +def sync_project_tabs(projects: Iterable[Project], tabs: Iterable[TemplateTab]): + """Update only projects/tabs (used in signal)""" + new_tabs = [ + ProjectTab( + uuid=tab.uuid, + project=project, + title=tab.title, + description=tab.description, + type=tab.type, + icon=tab.icon, + show_preview=tab.show_preview, + ) + for tab in tabs + for project in projects + ] + + ProjectTab.objects.bulk_create( + new_tabs, + update_conflicts=False, + ignore_conflicts=True, + unique_fields=["uuid", "project"], + ) + + +def sync_project_template(): + """Sync all projects tabs templates""" + + for template in Template.objects.prefetch_related("tabs").all(): + sync_project_tabs( + Project.objects.filter(template=template), template.tabs.all() + )