From f84834a56eeb54fc81bf80a22581efdde32dcae8 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 18 Aug 2026 10:02:28 +0700 Subject: [PATCH] fix: use organization settings for custom pages and emails --- ...unique_organization_email_type_and_more.py | 68 ++++++++ apps/custom_email/models.py | 12 +- apps/custom_email/views.py | 7 +- ...page_organization_and_more.py\342\200\216" | 61 ++++++++ apps/custom_page/models.py | 10 +- apps/custom_page/views.py | 4 +- apps/organization/views.py | 10 +- apps/organization_setting/serializers.py | 148 +++++++++--------- apps/organization_setting/views.py | 34 +--- utils/views.py | 104 ++++++++++++ 10 files changed, 332 insertions(+), 126 deletions(-) create mode 100644 apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py create mode 100644 "apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" diff --git a/apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py b/apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py new file mode 100644 index 0000000..006b3f2 --- /dev/null +++ b/apps/custom_email/migrations/0003_remove_organizationemail_unique_organization_email_type_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 5.0.6 on 2026-08-06 02:35 + +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_organization_setting(apps, schema_editor): + OrganizationEmail = apps.get_model("custom_email", "OrganizationEmail") + OrganizationSetting = apps.get_model("organization_setting", "OrganizationSetting") + + organization_setting_map = { + s.organization_id: s.id + for s in OrganizationSetting.objects.only("id", "organization_id") + } + + updates = [] + + for email in OrganizationEmail.objects.exclude(organization_id=None).iterator(): + if setting_id := organization_setting_map.get(email.organization_id): + email.organization_setting_id = setting_id + updates.append(email) + + if updates: + OrganizationEmail.objects.bulk_update( + updates, + ["organization_setting"], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("custom_email", "0002_backfill_organization_emails"), + ("organization_setting", "0002_backfill_organization_settings"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="organizationemail", + name="unique_organization_email_type", + ), + migrations.AddField( + model_name="organizationemail", + name="organization_setting", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_setting_custom_emails", + to="organization_setting.organizationsetting", + ), + ), + migrations.RunPython( + backfill_organization_setting, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="organizationemail", + name="organization", + ), + migrations.AddConstraint( + model_name="organizationemail", + constraint=models.UniqueConstraint( + fields=("organization_setting", "email_type"), + name="unique_organization_email_type", + ), + ), + ] diff --git a/apps/custom_email/models.py b/apps/custom_email/models.py index 3b4c293..a6504d3 100644 --- a/apps/custom_email/models.py +++ b/apps/custom_email/models.py @@ -2,14 +2,16 @@ from django.db import models from apps.custom_email.constants import EmailTypes -from apps.organization.models import Organization +from apps.organization_setting.models import OrganizationSetting class OrganizationEmail(BaseModel): - organization = models.ForeignKey( - Organization, + organization_setting = models.ForeignKey( + OrganizationSetting, on_delete=models.CASCADE, - related_name="organization_custom_emails", + related_name="organization_setting_custom_emails", + null=True, + blank=True, ) email_type = models.CharField(max_length=255, choices=EmailTypes.choices) sender_name = models.CharField(max_length=255, blank=True) @@ -25,7 +27,7 @@ class Meta: db_table = "custom_emails" constraints = [ models.UniqueConstraint( - fields=["organization", "email_type"], + fields=["organization_setting", "email_type"], name="unique_organization_email_type", ) ] diff --git a/apps/custom_email/views.py b/apps/custom_email/views.py index e5d8cb2..057b5a8 100644 --- a/apps/custom_email/views.py +++ b/apps/custom_email/views.py @@ -14,15 +14,14 @@ class ListCustomEmailView(QuotaMixin, OrganizationListAPIView): serializer_class = OrganizationEmailSerializer queryset = OrganizationEmail.objects.select_related( - "organization", - "organization__organization_settings", + "organization_setting" ).prefetch_related( Prefetch( - "organization__organization_settings__themes", + "organization_setting__themes", queryset=OrganizationTheme.objects.all(), ) ) - organization_field = "organization" + organization_field = "organization_setting__organization" pagination_class = BasePagination filterset_fields = ["email_type"] filter_backends = [DjangoFilterBackend, OrderingFilter] diff --git "a/apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" "b/apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" new file mode 100644 index 0000000..ef66dfd --- /dev/null +++ "b/apps/custom_page/migrations/0003_remove_custompage_organization_and_more.py\342\200\216" @@ -0,0 +1,61 @@ +# Generated by Django 5.0.6 on 2026-08-06 02:35 + +import django.db.models.deletion +from django.db import migrations, models + + +def backfill_organization_setting(apps, schema_editor): + CustomPage = apps.get_model("custom_page", "CustomPage") + OrganizationSetting = apps.get_model( + "organization_setting", + "OrganizationSetting", + ) + + organization_setting_map = { + s.organization_id: s.id + for s in OrganizationSetting.objects.only("id", "organization_id") + } + + updates = [] + + for page in CustomPage.objects.exclude(organization_id=None).iterator(): + setting_id = organization_setting_map.get(page.organization_id) + if setting_id is not None: + page.organization_setting_id = setting_id + updates.append(page) + + if updates: + CustomPage.objects.bulk_update( + updates, + ["organization_setting"], + batch_size=1000, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("custom_page", "0002_backfill_default_custom_pages"), + ("organization_setting", "0002_backfill_organization_settings"), + ] + + operations = [ + migrations.AddField( + model_name="custompage", + name="organization_setting", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_setting_custom_page", + to="organization_setting.organizationsetting", + ), + ), + migrations.RunPython( + backfill_organization_setting, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="custompage", + name="organization", + ), + ] \ No newline at end of file diff --git a/apps/custom_page/models.py b/apps/custom_page/models.py index b5717f2..9615394 100644 --- a/apps/custom_page/models.py +++ b/apps/custom_page/models.py @@ -2,14 +2,16 @@ from django.db import models from apps.custom_page.constants import PageTypes -from apps.organization.models import Organization +from apps.organization_setting.models import OrganizationSetting class CustomPage(BaseModel): - organization = models.ForeignKey( - Organization, + organization_setting = models.ForeignKey( + OrganizationSetting, on_delete=models.CASCADE, - related_name="organization_custom_page", + related_name="organization_setting_custom_page", + null=True, + blank=True, ) page_type = models.CharField(max_length=255, choices=PageTypes.choices) title = models.CharField(max_length=255) diff --git a/apps/custom_page/views.py b/apps/custom_page/views.py index 58f99d8..f0b3101 100644 --- a/apps/custom_page/views.py +++ b/apps/custom_page/views.py @@ -11,8 +11,8 @@ class ListCustomPageView(QuotaMixin, OrganizationListAPIView): serializer_class = CustomPageSerializer - queryset = CustomPage.objects.select_related("organization").all() - organization_field = "organization" + queryset = CustomPage.objects.select_related("organization_setting").all() + organization_field = "organization_setting__organization" pagination_class = BasePagination filter_backends = [DjangoFilterBackend, OrderingFilter] ordering = ["-created_at"] diff --git a/apps/organization/views.py b/apps/organization/views.py index b1c895c..59a2d15 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -11,9 +11,7 @@ from apps.organization.serializers import OrganizationSerializer from apps.organization.services import get_owner_name_query_set from apps.organization_setting.models import OrganizationSetting -from apps.organization_setting.serializers import ( - OrganizationSettingsWithCustomPagesSerializer, -) +from apps.organization_setting.serializers import OrganizationSettingWithPagesSerializer from utils.views import OrganizationRetrieveAPIView @@ -74,8 +72,8 @@ def get(self, request, slug_name): .select_related("organization") .prefetch_related( "themes", - "organization__organization_custom_emails", - "organization__organization_custom_page", + "organization_setting_custom_emails", + "organization_setting_custom_page", ) .first() ) @@ -90,7 +88,7 @@ def get(self, request, slug_name): { "result": result, "template": organization.template, - "setting": OrganizationSettingsWithCustomPagesSerializer(setting).data + "setting": OrganizationSettingWithPagesSerializer(setting).data if setting else None, "plan": plan_code, diff --git a/apps/organization_setting/serializers.py b/apps/organization_setting/serializers.py index ab4b94a..77c1995 100644 --- a/apps/organization_setting/serializers.py +++ b/apps/organization_setting/serializers.py @@ -59,107 +59,103 @@ class Meta: } -class OrganizationSettingsExpandedSerializer(OrganizationSettingSerializer): +class OrganizationSettingWithPagesSerializer(OrganizationSettingSerializer): custom_pages = CustomPageSerializer( - source="organization.organization_custom_page", - many=True, - read_only=True, - ) - custom_emails = OrganizationEmailSerializer( - source="organization.organization_custom_emails", many=True, read_only=True, + source="organization_setting_custom_page", ) class Meta(OrganizationSettingSerializer.Meta): fields = OrganizationSettingSerializer.Meta.fields + [ "custom_pages", - "custom_emails", ] -class OrganizationSettingsWithCustomPagesSerializer(OrganizationSettingSerializer): +class UpdateOrganizationSettingSerializer(OrganizationSettingSerializer): custom_pages = CustomPageSerializer( - source="organization.organization_custom_page", many=True, - read_only=True, + required=False, + source="organization_setting_custom_page", + ) + custom_emails = OrganizationEmailSerializer( + many=True, + required=False, + source="organization_setting_custom_emails", ) class Meta(OrganizationSettingSerializer.Meta): fields = OrganizationSettingSerializer.Meta.fields + [ "custom_pages", + "custom_emails", ] - -class UpdateOrganizationSettingSerializer(serializers.Serializer): - id = serializers.UUIDField(read_only=True) - site_title = serializers.CharField(required=False, allow_blank=True) - site_description = serializers.CharField(required=False, allow_blank=True) - border_radius = serializers.JSONField(required=False) - themes = OrganizationThemeSerializer(many=True, required=False) - custom_pages = CustomPageSerializer(many=True, required=False) - custom_emails = OrganizationEmailSerializer(many=True, required=False) - brand_name = serializers.CharField(required=False, allow_blank=True) - created_at = serializers.DateTimeField(read_only=True) - updated_at = serializers.DateTimeField(read_only=True) - - def _save_instance(self, instance, data): - for attr, value in data.items(): - setattr(instance, attr, value) + def _update_instance(self, instance, data): + for field, value in data.items(): + setattr(instance, field, value) instance.save() - def _get_instance(self, queryset, data, fallback_field, default=None): - data = data.copy() - object_id = data.pop("id", None) - fallback_value = data.get(fallback_field) - instance = queryset.filter(id=object_id).first() if object_id else None - if instance is None and fallback_value: - instance = queryset.filter(**{fallback_field: fallback_value}).first() - return instance or default(data) - - def _update_themes(self, instance, themes_data): - for theme_data in themes_data or []: - theme = self._get_instance( - instance.themes, - theme_data, - "theme_key", - lambda data: instance.themes.create( - theme_key=data.get("theme_key") or "light" - ), - ) - self._save_instance(theme, theme_data) - - def _update_custom_emails(self, instance, custom_emails_data): - if not custom_emails_data: + def _upsert( + self, + manager, + items, + *, + lookup_field, + create_kwargs=None, + create_if_missing=True, + ): + if not items: return - emails = instance.organization.organization_custom_emails - for custom_email_data in custom_emails_data: - custom_email = self._get_instance( - emails, - custom_email_data, - "email_type", - lambda data: emails.create( - email_type=data.get("email_type"), - ), - ) - self._save_instance(custom_email, custom_email_data) + create_kwargs = create_kwargs or {} + for data in items: + data = data.copy() + object_id = data.pop("id", None) + lookup_value = data.get(lookup_field) + object = None - def update(self, instance, validated_data): - with transaction.atomic(): - custom_pages_data = validated_data.pop("custom_pages", []) - custom_emails_data = validated_data.pop("custom_emails", []) - themes_data = validated_data.pop("themes", []) - - self._save_instance(instance, validated_data) - self._update_themes(instance, themes_data) - self._update_custom_emails(instance, custom_emails_data) - - pages = instance.organization.organization_custom_page - for page_data in custom_pages_data: - page = self._get_instance(pages, page_data, "page_type") - if page is None: + if object_id: + object = manager.filter(id=object_id).first() + + if object is None and lookup_value is not None: + object = manager.filter(**{lookup_field: lookup_value}).first() + + if object is None: + if not create_if_missing: continue - self._save_instance(page, page_data) + + create_data = dict(create_kwargs) + if lookup_field is not None and lookup_value is not None: + create_data[lookup_field] = lookup_value + + object = manager.create(**create_data) + self._update_instance(object, data) + + @transaction.atomic + def update(self, instance, validated_data): + themes = validated_data.pop("themes", []) + custom_pages = validated_data.pop("organization_setting_custom_page", []) + custom_emails = validated_data.pop("organization_setting_custom_emails", []) + + self._update_instance(instance, validated_data) + + self._upsert( + manager=instance.themes, + items=themes, + lookup_field="theme_key", + create_kwargs={"theme_key": "light"}, + ) + + self._upsert( + manager=instance.organization_setting_custom_emails, + items=custom_emails, + lookup_field="email_type", + ) + + self._upsert( + manager=instance.organization_setting_custom_page, + items=custom_pages, + lookup_field="page_type", + ) return instance diff --git a/apps/organization_setting/views.py b/apps/organization_setting/views.py index e8be844..c87dd82 100644 --- a/apps/organization_setting/views.py +++ b/apps/organization_setting/views.py @@ -1,26 +1,19 @@ from common.apps.billing.mixins import QuotaMixin from django.shortcuts import get_object_or_404 -from rest_framework import generics -from rest_framework.response import Response from apps.billing.quotas import WhitelabelQuota from apps.organization.models import Organization from apps.organization_setting.models import OrganizationSetting -from apps.organization_setting.serializers import ( - OrganizationSettingsExpandedSerializer, - UpdateOrganizationSettingSerializer, -) +from apps.organization_setting.serializers import UpdateOrganizationSettingSerializer +from utils.views import OrganizationUpdateAPIView -class UpdateOrganizationSettingView(QuotaMixin, generics.UpdateAPIView): +class UpdateOrganizationSettingView(QuotaMixin, OrganizationUpdateAPIView): serializer_class = UpdateOrganizationSettingSerializer queryset = OrganizationSetting.objects.select_related( "organization" - ).prefetch_related( - "themes", - "organization__organization_custom_emails", - "organization__organization_custom_page", - ) + ).prefetch_related("themes") + organization_field = "organization" quota_classes = [WhitelabelQuota] def get_object(self): @@ -30,20 +23,3 @@ def get_object(self): is_active=True, ) return get_object_or_404(self.get_queryset(), organization=organization) - - def update(self, request, *args, **kwargs): - partial = kwargs.pop("partial", False) - instance = self.get_object() - serializer = self.get_serializer( - instance, - data=request.data, - partial=partial, - ) - serializer.is_valid(raise_exception=True) - updated_instance = serializer.save() - fresh_instance = self.get_queryset().get(pk=updated_instance.pk) - response_serializer = OrganizationSettingsExpandedSerializer( - fresh_instance, - context=self.get_serializer_context(), - ) - return Response(response_serializer.data) diff --git a/utils/views.py b/utils/views.py index ff89c9d..91a0178 100644 --- a/utils/views.py +++ b/utils/views.py @@ -84,6 +84,18 @@ def create_with_organization(self, serializer): return serializer.save() +class OrganizationCreateAPIView(mixins.CreateModelMixin, OrganizationAPIView): + """ + Concrete view for creating a model instance of organization. + """ + + def perform_create(self, serializer): + self.create_with_organization(serializer) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + class OrganizationListAPIView(mixins.ListModelMixin, OrganizationAPIView): """ Concrete view for listing a queryset of organization. @@ -100,3 +112,95 @@ class OrganizationRetrieveAPIView(mixins.RetrieveModelMixin, OrganizationAPIView def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) + + +class OrganizationDestroyAPIView(mixins.DestroyModelMixin, OrganizationAPIView): + """ + Concrete view for deleting a model instance of organization. + """ + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + +class OrganizationUpdateAPIView(mixins.UpdateModelMixin, OrganizationAPIView): + """ + Concrete view for updating a model instance of organization. + """ + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + +class OrganizationListCreateAPIView( + mixins.ListModelMixin, mixins.CreateModelMixin, OrganizationAPIView +): + """ + Concrete view for listing a queryset or creating a model instance of organization. + """ + + def perform_create(self, serializer): + self.create_with_organization(serializer) + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + return self.create(request, *args, **kwargs) + + +class OrganizationRetrieveUpdateAPIView( + mixins.RetrieveModelMixin, mixins.UpdateModelMixin, OrganizationAPIView +): + """ + Concrete view for retrieving, updating a model instance of organization. + """ + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + +class OrganizationRetrieveDestroyAPIView( + mixins.RetrieveModelMixin, mixins.DestroyModelMixin, OrganizationAPIView +): + """ + Concrete view for retrieving or deleting a model instance of organization. + """ + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + + +class OrganizationRetrieveUpdateDestroyAPIView( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + OrganizationAPIView, +): + """ + Concrete view for retrieving, updating or deleting a model instance of organization. + """ + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs)