From dc60d36212b910f1aaca5e9440969c8073deb586 Mon Sep 17 00:00:00 2001 From: rgermain Date: Tue, 11 Aug 2026 17:04:11 +0200 Subject: [PATCH 1/3] feat: upgrade models --- .../migrations/0005_templatetab.py | 50 ++++++++++++ .../0006_templatetab_content_item_and_more.py | 23 ++++++ ...7_templatetab_content_templatetab_title.py | 23 ++++++ apps/organizations/models.py | 29 ++++++- apps/organizations/serializers.py | 76 ++++++++++++++++++- .../migrations/0005_projecttab_uuid.py | 21 +++++ apps/projects/models.py | 4 +- 7 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 apps/organizations/migrations/0005_templatetab.py create mode 100644 apps/organizations/migrations/0006_templatetab_content_item_and_more.py create mode 100644 apps/organizations/migrations/0007_templatetab_content_templatetab_title.py create mode 100644 apps/projects/migrations/0005_projecttab_uuid.py diff --git a/apps/organizations/migrations/0005_templatetab.py b/apps/organizations/migrations/0005_templatetab.py new file mode 100644 index 00000000..83c7c0e0 --- /dev/null +++ b/apps/organizations/migrations/0005_templatetab.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.5 on 2026-08-11 13:49 + +import apps.commons.mixins +import django.db.models.deletion +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)), + ( + "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, null=True)), + ( + "template", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="tabs", + to="organizations.template", + ), + ), + ], + bases=(apps.commons.mixins.OrganizationRelated, models.Model), + ), + ] diff --git a/apps/organizations/migrations/0006_templatetab_content_item_and_more.py b/apps/organizations/migrations/0006_templatetab_content_item_and_more.py new file mode 100644 index 00000000..bf354843 --- /dev/null +++ b/apps/organizations/migrations/0006_templatetab_content_item_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.5 on 2026-08-11 14:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0005_templatetab"), + ] + + operations = [ + migrations.AddField( + model_name="templatetab", + name="content_item", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="templatetab", + name="title_item", + field=models.TextField(blank=True, default="", max_length=255, null=True), + ), + ] diff --git a/apps/organizations/migrations/0007_templatetab_content_templatetab_title.py b/apps/organizations/migrations/0007_templatetab_content_templatetab_title.py new file mode 100644 index 00000000..6aab7164 --- /dev/null +++ b/apps/organizations/migrations/0007_templatetab_content_templatetab_title.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.5 on 2026-08-11 14:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0006_templatetab_content_item_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="templatetab", + name="content", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="templatetab", + name="title", + field=models.TextField(blank=True, default="", max_length=255, null=True), + ), + ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index bf284b30..d3f718c6 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -5,6 +5,7 @@ from django.contrib.postgres.fields import ArrayField from django.db import models from django.db.models import Q, QuerySet, UniqueConstraint +from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords from apps.commons.enums import Language @@ -19,7 +20,7 @@ get_permissions_from_subscopes, get_write_permissions_from_subscopes, ) -from services.translator.mixins import HasAutoTranslatedFields +from apps.projects.models import ProjectTab, ProjectTabItem if TYPE_CHECKING: from apps.accounts.models import ProjectUser @@ -480,6 +481,32 @@ def get_related_organizations(self) -> Organization: return [self.organization] +class TemplateTab(OrganizationRelated, models.Model): + uuid = models.UUIDField(auto_created=True) + + # content + title = models.TextField(max_length=255, default="", blank=True, null=True) + content = 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) + + 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..6e9450ac 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,24 @@ 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", + ] + + class Meta: + model = TemplateTab + fields = "__all__" + + @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 +488,7 @@ class Meta: "review_title", "review_description", "comment_content", + "tabs", ] fields = read_only_fields @@ -513,6 +528,7 @@ class TemplateSerializer( queryset=ProjectCategory.objects.all(), source="categories", ) + tabs = TemplateTabSerializer(many=True) class Meta: model = Template @@ -533,6 +549,7 @@ class Meta: "review_description", "comment_content", "categories_ids", + "tabs", ] def get_related_organizations(self) -> list[Organization]: @@ -548,6 +565,61 @@ 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() + + to_create = [] + + for tab_data in tabs_data: + tab_id = tab_data.pop("id", None) + + if tab_id: + # UPDATE + tab = existing_tabs.get(tab_id) + + if not tab: + raise serializers.ValidationError("Tabs id not exists") + + for field, value in tab_data.items(): + setattr(tab, field, value) + + tab.save() + received_ids.add(tab_id) + + else: + # CREATE + to_create.append(TemplateTab(**{**tab_data, "template": instance})) + + 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() + TemplateTab.objects.bulk_create(to_create) + + 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: + TemplateTab.objects.create( + template=template, + **tab_data, + ) + + return template + @auto_translated class ProjectCategoryHierarchySerializer( diff --git a/apps/projects/migrations/0005_projecttab_uuid.py b/apps/projects/migrations/0005_projecttab_uuid.py new file mode 100644 index 00000000..635d81c6 --- /dev/null +++ b/apps/projects/migrations/0005_projecttab_uuid.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.5 on 2026-08-11 13:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "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), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 8a19a1d1..b2980b58 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -14,6 +14,7 @@ from django.db.models import QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords, HistoricForeignKey from apps.analytics.models import Stat @@ -31,7 +32,6 @@ from apps.commons.models import GroupData from apps.commons.queryset import MultipleIdsQuerySet from apps.commons.utils import get_write_permissions_from_subscopes -from services.translator.mixins import HasAutoTranslatedFields from .exceptions import WrongProjectOrganizationError @@ -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.""" From 67fa4476b66d62a11eced57a1cb22344464193b1 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 12 Aug 2026 12:25:16 +0200 Subject: [PATCH 2/3] fix: models/isgnal --- ..._rename_content_templatetab_description.py | 18 +++++++++ apps/organizations/models.py | 6 +-- apps/organizations/serializers.py | 4 +- apps/organizations/signals.py | 11 ++++- apps/projects/models.py | 2 +- apps/projects/serializers.py | 1 + apps/projects/signals.py | 18 ++++++++- apps/projects/utils.py | 40 +++++++++++++++++-- 8 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 apps/organizations/migrations/0008_rename_content_templatetab_description.py diff --git a/apps/organizations/migrations/0008_rename_content_templatetab_description.py b/apps/organizations/migrations/0008_rename_content_templatetab_description.py new file mode 100644 index 00000000..c469cab2 --- /dev/null +++ b/apps/organizations/migrations/0008_rename_content_templatetab_description.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.5 on 2026-08-12 10:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0007_templatetab_content_templatetab_title"), + ] + + operations = [ + migrations.RenameField( + model_name="templatetab", + old_name="content", + new_name="description", + ), + ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index d3f718c6..b3790012 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -5,7 +5,6 @@ from django.contrib.postgres.fields import ArrayField from django.db import models from django.db.models import Q, QuerySet, UniqueConstraint -from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords from apps.commons.enums import Language @@ -21,6 +20,7 @@ get_write_permissions_from_subscopes, ) from apps.projects.models import ProjectTab, ProjectTabItem +from services.translator.mixins import HasAutoTranslatedFields if TYPE_CHECKING: from apps.accounts.models import ProjectUser @@ -484,9 +484,9 @@ def get_related_organizations(self) -> Organization: class TemplateTab(OrganizationRelated, models.Model): uuid = models.UUIDField(auto_created=True) - # content + # tabs title = models.TextField(max_length=255, default="", blank=True, null=True) - content = models.TextField(blank=True, null=True) + description = models.TextField(blank=True, null=True) template = models.ForeignKey( Template, on_delete=models.CASCADE, related_name="tabs" ) diff --git a/apps/organizations/serializers.py b/apps/organizations/serializers.py index 6e9450ac..9386bc03 100644 --- a/apps/organizations/serializers.py +++ b/apps/organizations/serializers.py @@ -9,8 +9,6 @@ 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 ( @@ -30,6 +28,8 @@ TagClassificationMultipleIdRelatedField, TagRelatedField, ) +from services.keycloak.serializers import IdentityProviderSerializer +from services.translator.serializers import auto_translated from .exceptions import ( CategoryHierarchyLoopError, 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/models.py b/apps/projects/models.py index b2980b58..904f2d86 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -14,7 +14,6 @@ from django.db.models import QuerySet from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from services.translator.mixins import HasAutoTranslatedFields from simple_history.models import HistoricalRecords, HistoricForeignKey from apps.analytics.models import Stat @@ -32,6 +31,7 @@ from apps.commons.models import GroupData from apps.commons.queryset import MultipleIdsQuerySet from apps.commons.utils import get_write_permissions_from_subscopes +from services.translator.mixins import HasAutoTranslatedFields from .exceptions import WrongProjectOrganizationError 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..f057f0ab 100644 --- a/apps/projects/utils.py +++ b/apps/projects/utils.py @@ -1,14 +1,15 @@ +from collections.abc import Iterable from typing import Any, TypeVar from django.db.models import CharField, QuerySet, Value from django.db.models.functions import Cast from rest_framework import serializers from rest_framework.utils import model_meta - -from apps.organizations.models import Organization from services.translator.serializers import prefix_fields_langs -from .models import Project +from apps.organizations.models import Organization, Template, TemplateTab + +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() + ) From 072d58d131ab047b296ef20eeb77d6e6d09c70a6 Mon Sep 17 00:00:00 2001 From: rgermain Date: Wed, 12 Aug 2026 14:48:12 +0200 Subject: [PATCH 3/3] fix: uuid tabs --- .../0009_templatetab_unique_template_tab.py | 19 +++++++ .../migrations/0010_alter_templatetab_uuid.py | 19 +++++++ apps/organizations/models.py | 12 ++++- apps/organizations/serializers.py | 50 ++++++++++--------- .../0006_projecttab_unique_project_tab.py | 22 ++++++++ apps/projects/models.py | 14 ++++++ apps/projects/utils.py | 2 +- 7 files changed, 112 insertions(+), 26 deletions(-) create mode 100644 apps/organizations/migrations/0009_templatetab_unique_template_tab.py create mode 100644 apps/organizations/migrations/0010_alter_templatetab_uuid.py create mode 100644 apps/projects/migrations/0006_projecttab_unique_project_tab.py diff --git a/apps/organizations/migrations/0009_templatetab_unique_template_tab.py b/apps/organizations/migrations/0009_templatetab_unique_template_tab.py new file mode 100644 index 00000000..def9fd02 --- /dev/null +++ b/apps/organizations/migrations/0009_templatetab_unique_template_tab.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.5 on 2026-08-12 12:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0008_rename_content_templatetab_description"), + ] + + operations = [ + migrations.AddConstraint( + model_name="templatetab", + constraint=models.UniqueConstraint( + fields=("uuid",), name="unique_template_tab" + ), + ), + ] diff --git a/apps/organizations/migrations/0010_alter_templatetab_uuid.py b/apps/organizations/migrations/0010_alter_templatetab_uuid.py new file mode 100644 index 00000000..425fb0e5 --- /dev/null +++ b/apps/organizations/migrations/0010_alter_templatetab_uuid.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.5 on 2026-08-12 12:20 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organizations", "0009_templatetab_unique_template_tab"), + ] + + operations = [ + migrations.AlterField( + model_name="templatetab", + name="uuid", + field=models.UUIDField(auto_created=True, default=uuid.uuid4), + ), + ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index b3790012..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 @@ -482,7 +483,7 @@ def get_related_organizations(self) -> Organization: class TemplateTab(OrganizationRelated, models.Model): - uuid = models.UUIDField(auto_created=True) + uuid = models.UUIDField(auto_created=True, default=uuid.uuid4) # tabs title = models.TextField(max_length=255, default="", blank=True, null=True) @@ -502,6 +503,15 @@ class TemplateTab(OrganizationRelated, models.Model): 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() diff --git a/apps/organizations/serializers.py b/apps/organizations/serializers.py index 9386bc03..b6235ae8 100644 --- a/apps/organizations/serializers.py +++ b/apps/organizations/serializers.py @@ -9,6 +9,8 @@ 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 ( @@ -28,8 +30,6 @@ TagClassificationMultipleIdRelatedField, TagRelatedField, ) -from services.keycloak.serializers import IdentityProviderSerializer -from services.translator.serializers import auto_translated from .exceptions import ( CategoryHierarchyLoopError, @@ -457,9 +457,23 @@ class TemplateTabSerializer(StringsImagesSerializer, serializers.ModelSerializer "description", ] + id = serializers.IntegerField(required=False) + uuid = serializers.UUIDField(read_only=True) + class Meta: model = TemplateTab - fields = "__all__" + fields = ( + "id", + "uuid", + "title", + "description", + "template", + "type", + "icon", + "show_preview", + "title_item", + "content_item", + ) @auto_translated @@ -578,31 +592,20 @@ def update(self, instance, validated_data): existing_tabs = {tab.id: tab for tab in instance.tabs.all()} received_ids = set() - to_create = [] - for tab_data in tabs_data: tab_id = tab_data.pop("id", None) + tab_data["template"] = instance - if tab_id: - # UPDATE - tab = existing_tabs.get(tab_id) + tab = existing_tabs.get(tab_id, TemplateTab(**tab_data)) - if not tab: - raise serializers.ValidationError("Tabs id not exists") + for field, value in tab_data.items(): + setattr(tab, field, value) - for field, value in tab_data.items(): - setattr(tab, field, value) - - tab.save() - received_ids.add(tab_id) - - else: - # CREATE - to_create.append(TemplateTab(**{**tab_data, "template": instance})) + 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() - TemplateTab.objects.bulk_create(to_create) return instance @@ -613,10 +616,9 @@ def create(self, validated_data): template = super().create(validated_data) for tab_data in tabs_data: - TemplateTab.objects.create( - template=template, - **tab_data, - ) + tab_data["template"] = template + template = TemplateTab(**tab_data) + template.save() return template diff --git a/apps/projects/migrations/0006_projecttab_unique_project_tab.py b/apps/projects/migrations/0006_projecttab_unique_project_tab.py new file mode 100644 index 00000000..0590dbfc --- /dev/null +++ b/apps/projects/migrations/0006_projecttab_unique_project_tab.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.5 on 2026-08-12 12:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("files", "0005_alter_image_options_peoplegroupimage"), + ("projects", "0005_projecttab_uuid"), + ] + + operations = [ + 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 904f2d86..ae041207 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -1046,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/utils.py b/apps/projects/utils.py index f057f0ab..d9c91f9b 100644 --- a/apps/projects/utils.py +++ b/apps/projects/utils.py @@ -5,9 +5,9 @@ from django.db.models.functions import Cast from rest_framework import serializers from rest_framework.utils import model_meta -from services.translator.serializers import prefix_fields_langs from apps.organizations.models import Organization, Template, TemplateTab +from services.translator.serializers import prefix_fields_langs from .models import Project, ProjectTab