From 38e6b76ea1bfcf80d0d64897ac812c857c5b0908 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 8 Jun 2026 17:02:00 +0700 Subject: [PATCH 01/10] fix: remove old image from s3 when updating or deleting image --- apps/organization/serializers.py | 27 +++++++++++++++++++++++++++ bootstrap_service/celery.py | 1 + 2 files changed, 28 insertions(+) diff --git a/apps/organization/serializers.py b/apps/organization/serializers.py index af235a1..3174db9 100644 --- a/apps/organization/serializers.py +++ b/apps/organization/serializers.py @@ -1,3 +1,7 @@ +from common.apps.upload_file.service import get_presigned_url +from common.celery import constants +from common.celery.task_senders import send_task +from django.conf import settings from rest_framework import serializers from apps.organization.models import Organization @@ -21,3 +25,26 @@ def validate_slug_name(self, data): if "_" in data: raise serializers.ValidationError(detail="slug name is invalid") return data + + def update(self, instance, validated_data): + old_logo = instance.logo + new_logo = validated_data.get("logo", old_logo) + instance = super().update(instance, validated_data) + if old_logo and old_logo != new_logo: + send_task( + name=constants.CONSOLE_SERVICE_DELETE_UPLOAD_FILE, + message={ + "bucket_name": settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), + "link_file": f"uploads/{old_logo}", + }, + ) + return instance + + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.logo and instance.logo not in ["", None]: + data["url_logo"] = get_presigned_url( + settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), + f"uploads/{instance.logo}", + ) + return data diff --git a/bootstrap_service/celery.py b/bootstrap_service/celery.py index 0d511a1..f6e66bb 100644 --- a/bootstrap_service/celery.py +++ b/bootstrap_service/celery.py @@ -19,6 +19,7 @@ TASKS_CONSOLE = [ constants.CONSOLE_SERVICE_ADD_OR_REMOVE_SPACE, + constants.CONSOLE_SERVICE_DELETE_UPLOAD_FILE, ] existing = {queue.name: queue for queue in (app.conf.task_queues or ())} From 4e78ca983992f8a20c3c032dfce999f36559eeab Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 2 Jul 2026 15:38:11 +0700 Subject: [PATCH 02/10] feat: implement api crud for auth pages and email --- apps/custom_page/__init__.py | 0 apps/custom_page/apps.py | 6 ++ apps/custom_page/constants.py | 8 +++ apps/custom_page/migrations/0001_initial.py | 66 +++++++++++++++++++ .../0002_backfill_default_custom_pages.py | 50 ++++++++++++++ apps/custom_page/migrations/__init__.py | 0 apps/custom_page/models.py | 24 +++++++ apps/custom_page/serializers.py | 40 +++++++++++ apps/custom_page/service.py | 65 ++++++++++++++++++ apps/custom_page/urls.py | 9 +++ apps/custom_page/views.py | 16 +++++ .../management/commands/init_organization.py | 2 + bootstrap_service/settings.py | 1 + bootstrap_service/urls.py | 1 + 14 files changed, 288 insertions(+) create mode 100644 apps/custom_page/__init__.py create mode 100644 apps/custom_page/apps.py create mode 100644 apps/custom_page/constants.py create mode 100644 apps/custom_page/migrations/0001_initial.py create mode 100644 apps/custom_page/migrations/0002_backfill_default_custom_pages.py create mode 100644 apps/custom_page/migrations/__init__.py create mode 100644 apps/custom_page/models.py create mode 100644 apps/custom_page/serializers.py create mode 100644 apps/custom_page/service.py create mode 100644 apps/custom_page/urls.py create mode 100644 apps/custom_page/views.py diff --git a/apps/custom_page/__init__.py b/apps/custom_page/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/custom_page/apps.py b/apps/custom_page/apps.py new file mode 100644 index 0000000..a5a381b --- /dev/null +++ b/apps/custom_page/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CustomPageConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.custom_page" diff --git a/apps/custom_page/constants.py b/apps/custom_page/constants.py new file mode 100644 index 0000000..91027fe --- /dev/null +++ b/apps/custom_page/constants.py @@ -0,0 +1,8 @@ +from django.db import models + + +class PageTypes(models.TextChoices): + SIGN_IN = "sign_in" + SIGN_UP = "sign_up" + FORGET_PASSWORD = "forget_password" # nosec B105 + CHANGE_PASSWORD = "change_password" # nosec B105 diff --git a/apps/custom_page/migrations/0001_initial.py b/apps/custom_page/migrations/0001_initial.py new file mode 100644 index 0000000..9a5840b --- /dev/null +++ b/apps/custom_page/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 5.0.6 on 2026-05-27 09:20 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("organization", "0004_alter_organization_template"), + ] + + operations = [ + migrations.CreateModel( + name="CustomPage", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "page_type", + models.CharField( + choices=[ + ("sign_in", "Sign In"), + ("sign_up", "Sign Up"), + ("forget_password", "Forget Password"), + ("change_password", "Change Password"), + ], + max_length=255, + ), + ), + ("title", models.CharField(max_length=255)), + ("subtitle", models.CharField(blank=True, max_length=255, null=True)), + ("metadata", models.JSONField(blank=True, default=dict)), + ("theme_colors", models.JSONField(blank=True, default=dict)), + ( + "background_image", + models.CharField(blank=True, default="", max_length=500), + ), + ("show_logo", models.BooleanField(default=True)), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_custom_page", + to="organization.organization", + ), + ), + ], + options={ + "db_table": "custom_pages", + "ordering": ["-created_at"], + }, + ), + ] diff --git a/apps/custom_page/migrations/0002_backfill_default_custom_pages.py b/apps/custom_page/migrations/0002_backfill_default_custom_pages.py new file mode 100644 index 0000000..75143c1 --- /dev/null +++ b/apps/custom_page/migrations/0002_backfill_default_custom_pages.py @@ -0,0 +1,50 @@ +from django.db import migrations + +from apps.custom_page.service import get_default_pages + + +def backfill_default_custom_pages(apps, schema_editor): + CustomPage = apps.get_model("custom_page", "CustomPage") + Organization = apps.get_model("organization", "Organization") + + pages_to_create = [] + existing_pages = {} + for org_id, page_type in CustomPage.objects.values_list( + "organization_id", "page_type" + ): + if org_id not in existing_pages: + existing_pages[org_id] = set() + existing_pages[org_id].add(page_type) + + for organization in Organization.objects.all().iterator(): + existing_page_types = existing_pages.get(organization.id, set()) + + for page in get_default_pages(): + if page["page_type"] in existing_page_types: + continue + + pages_to_create.append( + CustomPage( + organization_id=organization.id, + page_type=page["page_type"], + title=page["title"], + subtitle=page["subtitle"], + metadata=page["metadata"], + theme_colors=page["theme_colors"], + background_image=page["background_image"], + show_logo=page["show_logo"], + ) + ) + + if pages_to_create: + CustomPage.objects.bulk_create(pages_to_create, batch_size=50) + + +class Migration(migrations.Migration): + dependencies = [ + ("custom_page", "0001_initial"), + ] + + operations = [ + migrations.RunPython(backfill_default_custom_pages, migrations.RunPython.noop), + ] diff --git a/apps/custom_page/migrations/__init__.py b/apps/custom_page/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/custom_page/models.py b/apps/custom_page/models.py new file mode 100644 index 0000000..b5717f2 --- /dev/null +++ b/apps/custom_page/models.py @@ -0,0 +1,24 @@ +from common.models.base_model import BaseModel +from django.db import models + +from apps.custom_page.constants import PageTypes +from apps.organization.models import Organization + + +class CustomPage(BaseModel): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="organization_custom_page", + ) + page_type = models.CharField(max_length=255, choices=PageTypes.choices) + title = models.CharField(max_length=255) + subtitle = models.CharField(max_length=255, null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + theme_colors = models.JSONField(default=dict, blank=True) + background_image = models.CharField(max_length=500, blank=True, default="") + show_logo = models.BooleanField(default=True) + + class Meta: + db_table = "custom_pages" + ordering = ["-created_at"] diff --git a/apps/custom_page/serializers.py b/apps/custom_page/serializers.py new file mode 100644 index 0000000..cfe181d --- /dev/null +++ b/apps/custom_page/serializers.py @@ -0,0 +1,40 @@ +from common.apps.upload_file.service import get_presigned_url +from django.conf import settings +from rest_framework import serializers + +from apps.custom_page.models import CustomPage + + +class CustomPageSerializer(serializers.ModelSerializer): + class Meta: + model = CustomPage + fields = [ + "id", + "page_type", + "title", + "subtitle", + "metadata", + "theme_colors", + "background_image", + "show_logo", + "created_at", + "updated_at", + ] + extra_kwargs = { + "id": {"read_only": True}, + "background_image": {"write_only": True}, + "created_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } + + def validate_background_image(self, value): + return value or "" + + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.background_image: + data["url_background_image"] = get_presigned_url( + settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), + f"uploads/{instance.background_image}", + ) + return data diff --git a/apps/custom_page/service.py b/apps/custom_page/service.py new file mode 100644 index 0000000..74cacca --- /dev/null +++ b/apps/custom_page/service.py @@ -0,0 +1,65 @@ +from apps.custom_page.constants import PageTypes +from apps.custom_page.models import CustomPage + + +def get_default_pages(): + return [ + { + "page_type": PageTypes.SIGN_IN, + "title": "Sign in to SpaceDF", + "subtitle": "Sign in to manage your IoT devices", + "metadata": {}, + "theme_colors": {}, + "background_image": "", + "show_logo": True, + }, + { + "page_type": PageTypes.SIGN_UP, + "title": "Sign up to SpaceDF", + "subtitle": "Sign up to manage your IoT devices", + "metadata": {}, + "theme_colors": {}, + "background_image": "", + "show_logo": True, + }, + { + "page_type": PageTypes.FORGET_PASSWORD, + "title": "Forgot Your Password?", + "subtitle": "Enter your email address and we will send you instructions to reset your password.", + "metadata": {}, + "theme_colors": { + "background_color": "#FFFFFF", + }, + "background_image": "", + "show_logo": True, + }, + { + "page_type": PageTypes.CHANGE_PASSWORD, + "title": "Create New Password", + "subtitle": "Create your new password. If you forget it, then you have to do forget password", + "metadata": {}, + "theme_colors": { + "background_color": "#FFFFFF", + }, + "background_image": "", + "show_logo": True, + }, + ] + + +def create_default_pages(organization): + default_pages = get_default_pages() + list_data = [ + CustomPage( + organization=organization, + page_type=page.get("page_type"), + title=page.get("title"), + subtitle=page.get("subtitle"), + metadata=page.get("metadata"), + theme_colors=page.get("theme_colors"), + background_image=page.get("background_image"), + show_logo=page.get("show_logo"), + ) + for page in default_pages + ] + CustomPage.objects.bulk_create(list_data) diff --git a/apps/custom_page/urls.py b/apps/custom_page/urls.py new file mode 100644 index 0000000..966b7cb --- /dev/null +++ b/apps/custom_page/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from apps.custom_page.views import ListCustomPageView + +app_name = "custom_page" + +urlpatterns = [ + path("custom-pages", ListCustomPageView.as_view(), name="custom-pages-list"), +] diff --git a/apps/custom_page/views.py b/apps/custom_page/views.py new file mode 100644 index 0000000..faea208 --- /dev/null +++ b/apps/custom_page/views.py @@ -0,0 +1,16 @@ +from common.pagination.base_pagination import BasePagination +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import OrderingFilter + +from apps.custom_page.models import CustomPage +from apps.custom_page.serializers import CustomPageSerializer +from utils.views import OrganizationListAPIView + + +class ListCustomPageView(OrganizationListAPIView): + serializer_class = CustomPageSerializer + queryset = CustomPage.objects.select_related("organization").all() + organization_field = "organization" + pagination_class = BasePagination + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering = ["-created_at"] diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index da8eff0..c3f2b9c 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -24,6 +24,7 @@ from django.utils import timezone from apps.authentication.models import RootUser +from apps.custom_page.service import create_default_pages from apps.organization.models import Organization from apps.organization_roles.constants import OrganizationRoleType from apps.organization_roles.models import OrganizationRoleUser @@ -117,6 +118,7 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): self.stdout.write(self.style.SUCCESS(f"Created organization: {org_name}")) create_default_policies(organization) + create_default_pages(organization) self.stdout.write(self.style.SUCCESS("Created default policies")) role_mappings = [ diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index 0770998..738897c 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -60,6 +60,7 @@ "common.apps.refresh_tokens", "bootstrap_service", "apps.organization", + "apps.custom_page", "apps.organization_roles", "apps.authentication", ] diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index 1eca210..b09fad2 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -54,5 +54,6 @@ def health_check(_): path("bootstrap/admin/", admin.site.urls), # apis path("api/bootstrap/", include("apps.authentication.urls")), + path("api/", include("apps.custom_page.urls")), path("api/", include("apps.organization.urls")), ] From 8c8ac0e1c4b282dfecd9d34522d943d5acbfedfb Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 2 Jul 2026 16:55:36 +0700 Subject: [PATCH 03/10] feat: implement api crud for brand and theme color --- apps/organization/views.py | 16 +- apps/organization_setting/apps.py | 6 + apps/organization_setting/constants.py | 6 + .../migrations/0001_initial.py | 87 +++++++++++ .../0002_backfill_organization_settings.py | 71 +++++++++ .../migrations/__init__.py | 0 apps/organization_setting/models.py | 40 +++++ apps/organization_setting/serializers.py | 137 ++++++++++++++++++ apps/organization_setting/services.py | 71 +++++++++ apps/organization_setting/urls.py | 13 ++ apps/organization_setting/views.py | 21 +++ .../management/commands/init_organization.py | 2 + bootstrap_service/settings.py | 1 + bootstrap_service/urls.py | 1 + 14 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 apps/organization_setting/apps.py create mode 100644 apps/organization_setting/constants.py create mode 100644 apps/organization_setting/migrations/0001_initial.py create mode 100644 apps/organization_setting/migrations/0002_backfill_organization_settings.py create mode 100644 apps/organization_setting/migrations/__init__.py create mode 100644 apps/organization_setting/models.py create mode 100644 apps/organization_setting/serializers.py create mode 100644 apps/organization_setting/services.py create mode 100644 apps/organization_setting/urls.py create mode 100644 apps/organization_setting/views.py diff --git a/apps/organization/views.py b/apps/organization/views.py index 4185d0f..51c8605 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -8,6 +8,8 @@ from apps.organization.models import Organization 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 OrganizationSettingSerializer from utils.views import OrganizationRetrieveAPIView @@ -63,7 +65,19 @@ def get(self, request, slug_name): else: result = "The organization is valid." + setting = ( + OrganizationSetting.objects.filter(organization=organization) + .prefetch_related("themes") + .first() + ) + return Response( - {"result": result, "template": organization.template}, + { + "result": result, + "template": organization.template, + "setting": OrganizationSettingSerializer(setting).data + if setting + else None, + }, status=status.HTTP_200_OK, ) diff --git a/apps/organization_setting/apps.py b/apps/organization_setting/apps.py new file mode 100644 index 0000000..07340e4 --- /dev/null +++ b/apps/organization_setting/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SettingsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.organization_setting" diff --git a/apps/organization_setting/constants.py b/apps/organization_setting/constants.py new file mode 100644 index 0000000..445042f --- /dev/null +++ b/apps/organization_setting/constants.py @@ -0,0 +1,6 @@ +from django.db import models + + +class ThemeType(models.TextChoices): + LIGHT = "light" + DARK = "dark" diff --git a/apps/organization_setting/migrations/0001_initial.py b/apps/organization_setting/migrations/0001_initial.py new file mode 100644 index 0000000..e835dc2 --- /dev/null +++ b/apps/organization_setting/migrations/0001_initial.py @@ -0,0 +1,87 @@ +# Generated by Django 5.0.6 on 2026-06-01 02:26 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("organization", "0004_alter_organization_template"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationSetting", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("brand_name", models.CharField(blank=True, max_length=255)), + ("site_title", models.CharField(blank=True, max_length=255)), + ("site_description", models.TextField(blank=True)), + ("border_radius", models.JSONField(blank=True, default=dict)), + ( + "organization", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_settings", + to="organization.organization", + ), + ), + ], + options={ + "db_table": "organization_settings", + }, + ), + migrations.CreateModel( + name="OrganizationTheme", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("theme_key", models.CharField(max_length=50)), + ("favicon", models.CharField(blank=True, max_length=500)), + ("logo", models.CharField(blank=True, max_length=500)), + ("theme_colors", models.JSONField(blank=True, default=dict)), + ( + "setting", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="themes", + to="organization_setting.organizationsetting", + ), + ), + ], + options={ + "db_table": "organization_themes", + }, + ), + migrations.AddConstraint( + model_name="organizationtheme", + constraint=models.UniqueConstraint( + fields=("setting", "theme_key"), + name="unique_organization_theme_key_per_setting", + ), + ), + ] diff --git a/apps/organization_setting/migrations/0002_backfill_organization_settings.py b/apps/organization_setting/migrations/0002_backfill_organization_settings.py new file mode 100644 index 0000000..717f2a6 --- /dev/null +++ b/apps/organization_setting/migrations/0002_backfill_organization_settings.py @@ -0,0 +1,71 @@ +import uuid +from django.db import migrations + + +def backfill_organization_settings(apps, schema_editor): + from apps.organization_setting.services import ( + DEFAULT_DARK_THEME_VALUES, + DEFAULT_LIGHT_THEME_VALUES, + DEFAULT_ORGANIZATION_SETTING_VALUES, + ) + + Organization = apps.get_model("organization", "Organization") + OrganizationSetting = apps.get_model("organization_setting", "OrganizationSetting") + OrganizationTheme = apps.get_model("organization_setting", "OrganizationTheme") + + organizations = list(Organization.objects.values_list("id", flat=True)) + existing_setting_org_ids = set( + OrganizationSetting.objects.values_list("organization_id", flat=True) + ) + + missing_settings = [ + OrganizationSetting( + id=uuid.uuid4(), + organization_id=organization_id, + **DEFAULT_ORGANIZATION_SETTING_VALUES, + ) + for organization_id in organizations + if organization_id not in existing_setting_org_ids + ] + if missing_settings: + OrganizationSetting.objects.bulk_create(missing_settings) + + settings = list(OrganizationSetting.objects.values("id", "organization_id")) + existing_theme_keys = set( + OrganizationTheme.objects.values_list("setting_id", "theme_key") + ) + + themes_to_create = [] + for setting in settings: + for theme_key, theme_defaults in [ + ("light", DEFAULT_LIGHT_THEME_VALUES), + ("dark", DEFAULT_DARK_THEME_VALUES), + ]: + theme_identity = (setting["id"], theme_key) + if theme_identity in existing_theme_keys: + continue + + themes_to_create.append( + OrganizationTheme( + id=uuid.uuid4(), + setting_id=setting["id"], + theme_key=theme_key, + **theme_defaults, + ) + ) + + if themes_to_create: + OrganizationTheme.objects.bulk_create(themes_to_create) + + +class Migration(migrations.Migration): + dependencies = [ + ("organization_setting", "0001_initial"), + ] + + operations = [ + migrations.RunPython( + backfill_organization_settings, + migrations.RunPython.noop, + ), + ] diff --git a/apps/organization_setting/migrations/__init__.py b/apps/organization_setting/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization_setting/models.py b/apps/organization_setting/models.py new file mode 100644 index 0000000..04af6d6 --- /dev/null +++ b/apps/organization_setting/models.py @@ -0,0 +1,40 @@ +from common.models.base_model import BaseModel +from django.db import models + +from apps.organization.models import Organization + + +class OrganizationSetting(BaseModel): + organization = models.OneToOneField( + Organization, + on_delete=models.CASCADE, + related_name="organization_settings", + ) + brand_name = models.CharField(max_length=255, blank=True) + site_title = models.CharField(max_length=255, blank=True) + site_description = models.TextField(blank=True) + border_radius = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "organization_settings" + + +class OrganizationTheme(BaseModel): + setting = models.ForeignKey( + OrganizationSetting, + on_delete=models.CASCADE, + related_name="themes", + ) + theme_key = models.CharField(max_length=50) + favicon = models.CharField(max_length=500, blank=True) + logo = models.CharField(max_length=500, blank=True) + theme_colors = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "organization_themes" + constraints = [ + models.UniqueConstraint( + fields=["setting", "theme_key"], + name="unique_organization_theme_key_per_setting", + ) + ] diff --git a/apps/organization_setting/serializers.py b/apps/organization_setting/serializers.py new file mode 100644 index 0000000..3aec34f --- /dev/null +++ b/apps/organization_setting/serializers.py @@ -0,0 +1,137 @@ +from common.apps.upload_file.service import get_presigned_url +from django.conf import settings +from django.db import transaction +from rest_framework import serializers + +from apps.custom_page.serializers import CustomPageSerializer +from apps.organization_setting.models import OrganizationSetting, OrganizationTheme + + +class OrganizationThemeSerializer(serializers.ModelSerializer): + url_logo = serializers.SerializerMethodField() + url_favicon = serializers.SerializerMethodField() + + class Meta: + model = OrganizationTheme + fields = [ + "id", + "theme_key", + "favicon", + "logo", + "theme_colors", + "url_logo", + "url_favicon", + ] + extra_kwargs = { + "id": {"read_only": True}, + "favicon": {"write_only": True}, + "logo": {"write_only": True}, + "url_logo": {"read_only": True}, + "url_favicon": {"read_only": True}, + } + + def get_url_logo(self, instance): + host = settings.HOST.rstrip("/") + if not instance.logo: + default_logo = ( + "logo_white.png" if instance.theme_key == "dark" else "logo_black.png" + ) + return f"{host}/static/images/branding/{default_logo}" + return get_presigned_url( + settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), + f"uploads/{instance.logo}", + ) + + def get_url_favicon(self, instance): + host = settings.HOST.rstrip("/") + if not instance.favicon: + default_favicon = ( + "favicon_white.png" + if instance.theme_key == "dark" + else "favicon_black.png" + ) + return f"{host}/static/images/branding/{default_favicon}" + return get_presigned_url( + settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), + f"uploads/{instance.favicon}", + ) + + +class OrganizationSettingSerializer(serializers.ModelSerializer): + themes = OrganizationThemeSerializer(many=True, required=False) + + class Meta: + model = OrganizationSetting + fields = [ + "id", + "site_title", + "site_description", + "border_radius", + "themes", + "brand_name", + ] + read_only_fields = ["id"] + + +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) + 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) + 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 to_representation(self, instance): + data = OrganizationSettingSerializer(instance, context=self.context).data + data["custom_pages"] = CustomPageSerializer( + instance.organization.organization_custom_page.all(), + many=True, + context=self.context, + ).data + return data + + def update(self, instance, validated_data): + with transaction.atomic(): + custom_pages_data = validated_data.pop("custom_pages", []) + themes_data = validated_data.pop("themes", []) + + self._save_instance(instance, validated_data) + self._update_themes(instance, themes_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: + continue + self._save_instance(page, page_data) + + return instance diff --git a/apps/organization_setting/services.py b/apps/organization_setting/services.py new file mode 100644 index 0000000..5062613 --- /dev/null +++ b/apps/organization_setting/services.py @@ -0,0 +1,71 @@ +from apps.organization.models import Organization +from apps.organization_setting.constants import ThemeType +from apps.organization_setting.models import OrganizationSetting, OrganizationTheme + +DEFAULT_ORGANIZATION_SETTING_VALUES = { + "brand_name": "SpaceDF", + "site_title": "SpaceDF - No-Code IoT Management Platform", + "site_description": ( + "SpaceDF is a ready-to-use IoT platform that lets you connect, " + "manage, and control all your devices from a single dashboard " + "with no-code and minimal setup." + ), + "border_radius": { + "button": 8, + "input": 8, + "card": 6, + }, +} + +DEFAULT_DARK_THEME_VALUES = { + "favicon": "", + "logo": "", + "theme_colors": { + "primary": "#4006AA", + "outline": "#171A28", + "background": "#171A28", + "text": "#FFFFFF", + "input": "#090C18", + "support_text": "#6A749C", + "device_card": "#202431", + "widget_card": "#202431", + "widget_border": "#242A46", + }, +} + +DEFAULT_LIGHT_THEME_VALUES = { + "favicon": "", + "logo": "", + "theme_colors": { + "primary": "#171A28", + "outline": "#FFFFFF", + "background": "#FFFFFF", + "text": "#1F2937", + "input": "#F0F1F3", + "support_text": "#667085", + "device_card": "#F0F1F3", + "widget_card": "#FFFFFF", + "widget_border": "#F0F1F3", + }, +} + + +def create_default_organization_setting(organization: Organization): + setting = OrganizationSetting.objects.create( + organization=organization, + **DEFAULT_ORGANIZATION_SETTING_VALUES, + ) + OrganizationTheme.objects.bulk_create( + [ + OrganizationTheme( + setting=setting, + theme_key=ThemeType.LIGHT, + **DEFAULT_LIGHT_THEME_VALUES, + ), + OrganizationTheme( + setting=setting, + theme_key=ThemeType.DARK, + **DEFAULT_DARK_THEME_VALUES, + ), + ] + ) diff --git a/apps/organization_setting/urls.py b/apps/organization_setting/urls.py new file mode 100644 index 0000000..9bea969 --- /dev/null +++ b/apps/organization_setting/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from apps.organization_setting.views import UpdateOrganizationSettingView + +app_name = "organization_setting" + +urlpatterns = [ + path( + "organizations/settings", + UpdateOrganizationSettingView.as_view(), + name="organization-settings-update", + ), +] diff --git a/apps/organization_setting/views.py b/apps/organization_setting/views.py new file mode 100644 index 0000000..6b381ce --- /dev/null +++ b/apps/organization_setting/views.py @@ -0,0 +1,21 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import generics + +from apps.organization.models import Organization +from apps.organization_setting.models import OrganizationSetting +from apps.organization_setting.serializers import UpdateOrganizationSettingSerializer + + +class UpdateOrganizationSettingView(generics.UpdateAPIView): + serializer_class = UpdateOrganizationSettingSerializer + queryset = OrganizationSetting.objects.select_related( + "organization" + ).prefetch_related("themes") + + def get_object(self): + organization = get_object_or_404( + Organization, + slug_name=self.request.headers.get("X-Organization"), + is_active=True, + ) + return get_object_or_404(self.get_queryset(), organization=organization) diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index c3f2b9c..fd7c3c4 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -32,6 +32,7 @@ create_default_organization_role_by_policy_tag, create_default_policies, ) +from apps.organization_setting.services import create_default_organization_setting from utils.check_tenant_exists import check_tenant_exists from utils.event_publisher import publish_org_event @@ -119,6 +120,7 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): create_default_policies(organization) create_default_pages(organization) + create_default_organization_setting(organization) self.stdout.write(self.style.SUCCESS("Created default policies")) role_mappings = [ diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index 738897c..bea089b 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -61,6 +61,7 @@ "bootstrap_service", "apps.organization", "apps.custom_page", + "apps.organization_setting", "apps.organization_roles", "apps.authentication", ] diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index b09fad2..db1a981 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -55,5 +55,6 @@ def health_check(_): # apis path("api/bootstrap/", include("apps.authentication.urls")), path("api/", include("apps.custom_page.urls")), + path("api/", include("apps.organization_setting.urls")), path("api/", include("apps.organization.urls")), ] From ce3f6b70d5b4cb16696924941743cff27e37879b Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 2 Jul 2026 17:20:57 +0700 Subject: [PATCH 04/10] feat: implement custom emails models --- apps/custom_email/__init__.py | 0 apps/custom_email/apps.py | 6 ++ apps/custom_email/constants.py | 7 ++ apps/custom_email/migrations/0001_initial.py | 72 +++++++++++++++++++ .../0002_backfill_organization_emails.py | 43 +++++++++++ apps/custom_email/migrations/__init__.py | 1 + apps/custom_email/models.py | 31 ++++++++ apps/custom_email/serializers.py | 49 +++++++++++++ apps/custom_email/service.py | 59 +++++++++++++++ apps/custom_email/urls.py | 9 +++ apps/custom_email/views.py | 16 +++++ apps/organization/views.py | 7 +- apps/organization_setting/serializers.py | 51 ++++++++++--- apps/organization_setting/views.py | 6 +- .../management/commands/init_organization.py | 2 + bootstrap_service/settings.py | 1 + bootstrap_service/urls.py | 1 + 17 files changed, 351 insertions(+), 10 deletions(-) create mode 100644 apps/custom_email/__init__.py create mode 100644 apps/custom_email/apps.py create mode 100644 apps/custom_email/constants.py create mode 100644 apps/custom_email/migrations/0001_initial.py create mode 100644 apps/custom_email/migrations/0002_backfill_organization_emails.py create mode 100644 apps/custom_email/migrations/__init__.py create mode 100644 apps/custom_email/models.py create mode 100644 apps/custom_email/serializers.py create mode 100644 apps/custom_email/service.py create mode 100644 apps/custom_email/urls.py create mode 100644 apps/custom_email/views.py diff --git a/apps/custom_email/__init__.py b/apps/custom_email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/custom_email/apps.py b/apps/custom_email/apps.py new file mode 100644 index 0000000..3df3ee8 --- /dev/null +++ b/apps/custom_email/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CustomEmailConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.custom_email" diff --git a/apps/custom_email/constants.py b/apps/custom_email/constants.py new file mode 100644 index 0000000..535d811 --- /dev/null +++ b/apps/custom_email/constants.py @@ -0,0 +1,7 @@ +from django.db import models + + +class EmailTypes(models.TextChoices): + INVITATION_TO_SPACE = "invitation_to_space" + VERIFICATION_CODE = "verification_code" + RESET_PASSWORD = "reset_password" # nosec B105 diff --git a/apps/custom_email/migrations/0001_initial.py b/apps/custom_email/migrations/0001_initial.py new file mode 100644 index 0000000..6cf36d9 --- /dev/null +++ b/apps/custom_email/migrations/0001_initial.py @@ -0,0 +1,72 @@ +# Generated by Django 5.0.6 on 2026-06-01 00:00 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("organization", "0004_alter_organization_template"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationEmail", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "email_type", + models.CharField( + choices=[ + ("invitation_to_space", "Invitation To Space"), + ("verification_code", "Verification Code"), + ("reset_password", "Reset Password"), + ], + max_length=255, + ), + ), + ("sender_name", models.CharField(blank=True, max_length=255)), + ("sender_email", models.CharField(blank=True, max_length=255)), + ("theme_colors", models.JSONField(blank=True, default=dict)), + ("footer_text", models.TextField(blank=True)), + ( + "header_image", + models.CharField(blank=True, default="", max_length=500), + ), + ("show_logo", models.BooleanField(default=True)), + ("social_links", models.JSONField(blank=True, default=dict)), + ("metadata", models.JSONField(blank=True, default=dict)), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_custom_emails", + to="organization.organization", + ), + ), + ], + options={ + "db_table": "custom_emails", + "constraints": [ + models.UniqueConstraint( + fields=("organization", "email_type"), + name="unique_organization_email_type", + ) + ], + }, + ), + ] diff --git a/apps/custom_email/migrations/0002_backfill_organization_emails.py b/apps/custom_email/migrations/0002_backfill_organization_emails.py new file mode 100644 index 0000000..380a432 --- /dev/null +++ b/apps/custom_email/migrations/0002_backfill_organization_emails.py @@ -0,0 +1,43 @@ +from django.db import migrations + +from apps.custom_email.service import get_default_organization_emails + + +def backfill_organization_emails(apps, schema_editor): + Organization = apps.get_model("organization", "Organization") + OrganizationEmail = apps.get_model("custom_email", "OrganizationEmail") + + existing_pairs = set( + OrganizationEmail.objects.values_list("organization_id", "email_type") + ) + emails_to_create = [] + default_emails = get_default_organization_emails() + + for organization in Organization.objects.all().iterator(): + for email_data in default_emails: + email_type = email_data["email_type"] + if (organization.id, email_type) in existing_pairs: + continue + + emails_to_create.append( + OrganizationEmail( + organization_id=organization.id, + **email_data, + ) + ) + + if emails_to_create: + OrganizationEmail.objects.bulk_create(emails_to_create, batch_size=100) + + +class Migration(migrations.Migration): + dependencies = [ + ("custom_email", "0001_initial"), + ] + + operations = [ + migrations.RunPython( + backfill_organization_emails, + migrations.RunPython.noop, + ), + ] diff --git a/apps/custom_email/migrations/__init__.py b/apps/custom_email/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/custom_email/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/custom_email/models.py b/apps/custom_email/models.py new file mode 100644 index 0000000..3b4c293 --- /dev/null +++ b/apps/custom_email/models.py @@ -0,0 +1,31 @@ +from common.models.base_model import BaseModel +from django.db import models + +from apps.custom_email.constants import EmailTypes +from apps.organization.models import Organization + + +class OrganizationEmail(BaseModel): + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="organization_custom_emails", + ) + email_type = models.CharField(max_length=255, choices=EmailTypes.choices) + sender_name = models.CharField(max_length=255, blank=True) + sender_email = models.CharField(max_length=255, blank=True) + theme_colors = models.JSONField(default=dict, blank=True) + footer_text = models.TextField(blank=True) + header_image = models.CharField(max_length=500, blank=True, default="") + show_logo = models.BooleanField(default=True) + social_links = models.JSONField(default=dict, blank=True) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + db_table = "custom_emails" + constraints = [ + models.UniqueConstraint( + fields=["organization", "email_type"], + name="unique_organization_email_type", + ) + ] diff --git a/apps/custom_email/serializers.py b/apps/custom_email/serializers.py new file mode 100644 index 0000000..f7fdad8 --- /dev/null +++ b/apps/custom_email/serializers.py @@ -0,0 +1,49 @@ +from common.apps.upload_file.service import get_presigned_url +from django.conf import settings +from rest_framework import serializers + +from apps.custom_email.constants import EmailTypes +from apps.custom_email.models import OrganizationEmail + + +class OrganizationEmailSerializer(serializers.ModelSerializer): + class Meta: + model = OrganizationEmail + fields = [ + "id", + "email_type", + "sender_name", + "sender_email", + "theme_colors", + "footer_text", + "header_image", + "show_logo", + "social_links", + "metadata", + ] + extra_kwargs = { + "id": {"read_only": True}, + "email_type": {"required": True}, + "header_image": {"write_only": True}, + } + + def to_representation(self, instance): + data = super().to_representation(instance) + if instance.header_image: + data["url_header_image"] = get_presigned_url( + settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), + f"uploads/{instance.header_image}", + ) + return data + + image_mapping = { + EmailTypes.INVITATION_TO_SPACE: "auth/invitation.png", + EmailTypes.VERIFICATION_CODE: "auth/verification_code.png", + EmailTypes.RESET_PASSWORD: "auth/forget_password.png", + } + image_path = image_mapping.get(instance.email_type, "") + host = settings.HOST.rstrip("/") + data["url_header_image"] = ( + f"{host}/static/images/{image_path}" if image_path else "" + ) + return data diff --git a/apps/custom_email/service.py b/apps/custom_email/service.py new file mode 100644 index 0000000..cf303db --- /dev/null +++ b/apps/custom_email/service.py @@ -0,0 +1,59 @@ +from apps.custom_email.constants import EmailTypes +from apps.custom_email.models import OrganizationEmail + + +def get_default_organization_emails(): + common_data = { + "sender_name": "The @SpaceDF team", + "sender_email": "support@spacedf.com", + "footer_text": "©2025 Digital Fortress. All rights reserved.", + "header_image": "", + "show_logo": True, + "social_links": { + "linkedin_url": "https://vn.linkedin.com/company/digital-fortress-vn", + "facebook_url": "https://www.facebook.com/digitalfortress.dev/", + "tiktok_url": "", + "instagram_url": "https://www.instagram.com/df.iot/", + }, + "metadata": { + "show_facebook": True, + "show_instagram": True, + "show_linkedin": True, + "show_tiktok": False, + }, + } + return [ + { + **common_data, + "email_type": EmailTypes.INVITATION_TO_SPACE, + "theme_colors": { + "background_color": "#FFFFFF", + "primary_color": "#171A28", + }, + }, + { + **common_data, + "email_type": EmailTypes.VERIFICATION_CODE, + "theme_colors": { + "background_color": "#FFFFFF", + "primary_color": "#171A28", + }, + }, + { + **common_data, + "email_type": EmailTypes.RESET_PASSWORD, + "theme_colors": { + "background_color": "#FFFFFF", + "primary_color": "#171A28", + }, + }, + ] + + +def create_default_organization_email(organization): + return OrganizationEmail.objects.bulk_create( + [ + OrganizationEmail(organization=organization, **email_data) + for email_data in get_default_organization_emails() + ] + ) diff --git a/apps/custom_email/urls.py b/apps/custom_email/urls.py new file mode 100644 index 0000000..3a4b577 --- /dev/null +++ b/apps/custom_email/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from apps.custom_email.views import ListCustomEmailView + +app_name = "custom_email" + +urlpatterns = [ + path("custom-emails", ListCustomEmailView.as_view(), name="custom-emails-list"), +] diff --git a/apps/custom_email/views.py b/apps/custom_email/views.py new file mode 100644 index 0000000..ec0c5b1 --- /dev/null +++ b/apps/custom_email/views.py @@ -0,0 +1,16 @@ +from common.pagination.base_pagination import BasePagination +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import OrderingFilter + +from apps.custom_email.models import OrganizationEmail +from apps.custom_email.serializers import OrganizationEmailSerializer +from utils.views import OrganizationListAPIView + + +class ListCustomEmailView(OrganizationListAPIView): + serializer_class = OrganizationEmailSerializer + queryset = OrganizationEmail.objects.all() + organization_field = "organization" + pagination_class = BasePagination + filter_backends = [DjangoFilterBackend, OrderingFilter] + ordering = ["-created_at"] diff --git a/apps/organization/views.py b/apps/organization/views.py index 51c8605..a6b331d 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -67,7 +67,12 @@ def get(self, request, slug_name): setting = ( OrganizationSetting.objects.filter(organization=organization) - .prefetch_related("themes") + .select_related("organization") + .prefetch_related( + "themes", + "organization__organization_custom_emails", + "organization__organization_custom_page", + ) .first() ) diff --git a/apps/organization_setting/serializers.py b/apps/organization_setting/serializers.py index 3aec34f..c350e10 100644 --- a/apps/organization_setting/serializers.py +++ b/apps/organization_setting/serializers.py @@ -3,6 +3,7 @@ from django.db import transaction from rest_framework import serializers +from apps.custom_email.serializers import OrganizationEmailSerializer from apps.custom_page.serializers import CustomPageSerializer from apps.organization_setting.models import OrganizationSetting, OrganizationTheme @@ -70,7 +71,28 @@ class Meta: "themes", "brand_name", ] - read_only_fields = ["id"] + extra_kwargs = { + "id": {"read_only": True}, + } + + +class OrganizationConfigSerializer(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, + ) + + class Meta(OrganizationSettingSerializer.Meta): + fields = OrganizationSettingSerializer.Meta.fields + [ + "custom_pages", + "custom_emails", + ] class UpdateOrganizationSettingSerializer(serializers.Serializer): @@ -80,6 +102,7 @@ class UpdateOrganizationSettingSerializer(serializers.Serializer): 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) @@ -110,22 +133,34 @@ def _update_themes(self, instance, themes_data): ) self._save_instance(theme, theme_data) + def _update_custom_emails(self, instance, custom_emails_data): + if not custom_emails_data: + 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) + def to_representation(self, instance): - data = OrganizationSettingSerializer(instance, context=self.context).data - data["custom_pages"] = CustomPageSerializer( - instance.organization.organization_custom_page.all(), - many=True, - context=self.context, - ).data - return data + return OrganizationConfigSerializer(instance, context=self.context).data 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: diff --git a/apps/organization_setting/views.py b/apps/organization_setting/views.py index 6b381ce..8f12006 100644 --- a/apps/organization_setting/views.py +++ b/apps/organization_setting/views.py @@ -10,7 +10,11 @@ class UpdateOrganizationSettingView(generics.UpdateAPIView): serializer_class = UpdateOrganizationSettingSerializer queryset = OrganizationSetting.objects.select_related( "organization" - ).prefetch_related("themes") + ).prefetch_related( + "themes", + "organization__organization_custom_emails", + "organization__organization_custom_page", + ) def get_object(self): organization = get_object_or_404( diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index fd7c3c4..b28298c 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -24,6 +24,7 @@ from django.utils import timezone from apps.authentication.models import RootUser +from apps.custom_email.service import create_default_organization_email from apps.custom_page.service import create_default_pages from apps.organization.models import Organization from apps.organization_roles.constants import OrganizationRoleType @@ -120,6 +121,7 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): create_default_policies(organization) create_default_pages(organization) + create_default_organization_email(organization) create_default_organization_setting(organization) self.stdout.write(self.style.SUCCESS("Created default policies")) diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index bea089b..ab778bb 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -61,6 +61,7 @@ "bootstrap_service", "apps.organization", "apps.custom_page", + "apps.custom_email", "apps.organization_setting", "apps.organization_roles", "apps.authentication", diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index db1a981..c48efcf 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -55,6 +55,7 @@ def health_check(_): # apis path("api/bootstrap/", include("apps.authentication.urls")), path("api/", include("apps.custom_page.urls")), + path("api/", include("apps.custom_email.urls")), path("api/", include("apps.organization_setting.urls")), path("api/", include("apps.organization.urls")), ] From 5807e5b7dccf0fb100e634d32335c5c0c9d181c2 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 3 Jul 2026 09:44:28 +0700 Subject: [PATCH 05/10] feat: update the email ui in the template folder --- apps/authentication/services.py | 13 ------- apps/authentication/views.py | 22 +++++++----- apps/custom_email/serializers.py | 27 +++++++++++++++ apps/custom_email/service.py | 43 ++++++++++++++++++++++++ apps/custom_email/views.py | 1 + apps/organization_setting/serializers.py | 3 -- apps/organization_setting/services.py | 2 +- apps/organization_setting/views.py | 23 ++++++++++++- 8 files changed, 107 insertions(+), 27 deletions(-) diff --git a/apps/authentication/services.py b/apps/authentication/services.py index 7d405eb..31f29a5 100644 --- a/apps/authentication/services.py +++ b/apps/authentication/services.py @@ -4,9 +4,7 @@ from common.apps.refresh_tokens.services import create_jwt_tokens from django.conf import settings from django.core.cache import cache -from django.template.loader import render_to_string from rest_framework import status -from rest_framework.exceptions import ValidationError from rest_framework.response import Response from apps.authentication.models import RootUser @@ -92,14 +90,3 @@ def handle_access_token(access_token, provider: Literal["GOOGLE"]): "default_organization": default_organization_slug, }, ) - - -def render_email_format(template, data): - try: - html_message = render_to_string( - template, - data, - ) - return html_message - except Exception as e: - raise ValidationError({"error": f"Error: {e}"}) diff --git a/apps/authentication/views.py b/apps/authentication/views.py index c419c6c..e4dab92 100644 --- a/apps/authentication/views.py +++ b/apps/authentication/views.py @@ -1,6 +1,7 @@ from datetime import datetime, timezone from common.apps.refresh_tokens.serializers import TokenPairSerializer +from common.utils.email_context import get_email_context, render_email_format from common.utils.send_email import send_email from common.utils.token_jwt import generate_token from django.conf import settings @@ -22,10 +23,9 @@ SendEmailSerializer, UserSerializer, ) -from apps.authentication.services import ( - create_organization_access_token, - render_email_format, -) +from apps.authentication.services import create_organization_access_token +from apps.custom_email.constants import EmailTypes +from apps.custom_email.service import get_custom_email_item class LoginAPIView(TokenObtainPairView): @@ -69,11 +69,15 @@ def post(self, request): subject = "🔒 Forgot your password? Reset now" token = generate_token({"email": email}) - data = { - "redirect_url": f"{settings.HOST_FRONTEND_ADMIN}/auth/reset-password?token={token}", - "host": settings.HOST, - } - message = render_email_format("email_forget_password.html", data) + custom_email_item = get_custom_email_item(EmailTypes.RESET_PASSWORD) + email_context = get_email_context( + { + "host": settings.HOST, + "redirect_url": f"{settings.HOST_FRONTEND_ADMIN}/auth/reset-password?token={token}", + }, + custom_email=custom_email_item, + ) + message = render_email_format("email_forget_password.html", email_context) send_email(settings.DEFAULT_FROM_EMAIL, [email], subject, message) return Response( { diff --git a/apps/custom_email/serializers.py b/apps/custom_email/serializers.py index f7fdad8..c0f0e56 100644 --- a/apps/custom_email/serializers.py +++ b/apps/custom_email/serializers.py @@ -4,13 +4,22 @@ from apps.custom_email.constants import EmailTypes from apps.custom_email.models import OrganizationEmail +from apps.custom_email.service import get_theme_logo_url class OrganizationEmailSerializer(serializers.ModelSerializer): + brand_logo_dark = serializers.SerializerMethodField() + brand_logo_light = serializers.SerializerMethodField() + brand_name = serializers.SerializerMethodField() + class Meta: model = OrganizationEmail fields = [ "id", + "brand_logo_dark", + "brand_logo_light", + "brand_name", + "email_type", "email_type", "sender_name", "sender_email", @@ -25,6 +34,9 @@ class Meta: "id": {"read_only": True}, "email_type": {"required": True}, "header_image": {"write_only": True}, + "brand_logo_dark": {"read_only": True}, + "brand_logo_light": {"read_only": True}, + "brand_name": {"read_only": True}, } def to_representation(self, instance): @@ -47,3 +59,18 @@ def to_representation(self, instance): f"{host}/static/images/{image_path}" if image_path else "" ) return data + + def get_brand_logo_dark(self, instance): + return get_theme_logo_url(instance, "dark") + + def get_brand_logo_light(self, instance): + return get_theme_logo_url(instance, "light") + + def get_brand_name(self, instance): + organization = getattr(instance, "organization", None) + setting = ( + getattr(organization, "organization_settings", None) + if organization + else None + ) + return getattr(setting, "brand_name", "") or "" diff --git a/apps/custom_email/service.py b/apps/custom_email/service.py index cf303db..6409ee9 100644 --- a/apps/custom_email/service.py +++ b/apps/custom_email/service.py @@ -1,5 +1,31 @@ +from common.apps.upload_file.service import get_presigned_url +from django.conf import settings + from apps.custom_email.constants import EmailTypes from apps.custom_email.models import OrganizationEmail +from apps.organization_setting.constants import ThemeType + + +def get_theme_logo_url(instance, theme_key): + host = settings.HOST.rstrip("/") + default_logo = "logo_white.png" if theme_key == ThemeType.DARK else "logo_black.png" + organization = getattr(instance, "organization", None) + if not organization: + return f"{host}/static/images/branding/{default_logo}" + + setting = getattr(organization, "organization_settings", None) + if not setting: + return f"{host}/static/images/branding/{default_logo}" + + theme = setting.themes.filter(theme_key=theme_key).first() + + if not theme or not theme.logo: + return f"{host}/static/images/branding/{default_logo}" + + return get_presigned_url( + settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), + f"uploads/{theme.logo}", + ) def get_default_organization_emails(): @@ -57,3 +83,20 @@ def create_default_organization_email(organization): for email_data in get_default_organization_emails() ] ) + + +def get_custom_email_item(email_type): + from apps.custom_email.serializers import OrganizationEmailSerializer + + custom_email = next( + ( + item + for item in get_default_organization_emails() + if item.get("email_type") == email_type + ), + None, + ) + if not custom_email: + return {} + + return OrganizationEmailSerializer(OrganizationEmail(**custom_email)).data diff --git a/apps/custom_email/views.py b/apps/custom_email/views.py index ec0c5b1..1298b79 100644 --- a/apps/custom_email/views.py +++ b/apps/custom_email/views.py @@ -12,5 +12,6 @@ class ListCustomEmailView(OrganizationListAPIView): queryset = OrganizationEmail.objects.all() organization_field = "organization" pagination_class = BasePagination + filterset_fields = ["email_type"] filter_backends = [DjangoFilterBackend, OrderingFilter] ordering = ["-created_at"] diff --git a/apps/organization_setting/serializers.py b/apps/organization_setting/serializers.py index c350e10..797a777 100644 --- a/apps/organization_setting/serializers.py +++ b/apps/organization_setting/serializers.py @@ -149,9 +149,6 @@ def _update_custom_emails(self, instance, custom_emails_data): ) self._save_instance(custom_email, custom_email_data) - def to_representation(self, instance): - return OrganizationConfigSerializer(instance, context=self.context).data - def update(self, instance, validated_data): with transaction.atomic(): custom_pages_data = validated_data.pop("custom_pages", []) diff --git a/apps/organization_setting/services.py b/apps/organization_setting/services.py index 5062613..d016f4d 100644 --- a/apps/organization_setting/services.py +++ b/apps/organization_setting/services.py @@ -38,7 +38,7 @@ "logo": "", "theme_colors": { "primary": "#171A28", - "outline": "#FFFFFF", + "outline": "#171A28", "background": "#FFFFFF", "text": "#1F2937", "input": "#F0F1F3", diff --git a/apps/organization_setting/views.py b/apps/organization_setting/views.py index 8f12006..16c13c7 100644 --- a/apps/organization_setting/views.py +++ b/apps/organization_setting/views.py @@ -1,9 +1,13 @@ from django.shortcuts import get_object_or_404 from rest_framework import generics +from rest_framework.response import Response from apps.organization.models import Organization from apps.organization_setting.models import OrganizationSetting -from apps.organization_setting.serializers import UpdateOrganizationSettingSerializer +from apps.organization_setting.serializers import ( + OrganizationConfigSerializer, + UpdateOrganizationSettingSerializer, +) class UpdateOrganizationSettingView(generics.UpdateAPIView): @@ -23,3 +27,20 @@ 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 = OrganizationConfigSerializer( + fresh_instance, + context=self.get_serializer_context(), + ) + return Response(response_serializer.data) From 8438cf5d90a5cdab1da8e6efa5f92bc90dae05ed Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 3 Jul 2026 10:00:30 +0700 Subject: [PATCH 06/10] fix: remove logo and favicon default --- apps/organization_setting/serializers.py | 43 +++++++----------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/apps/organization_setting/serializers.py b/apps/organization_setting/serializers.py index 797a777..928a54f 100644 --- a/apps/organization_setting/serializers.py +++ b/apps/organization_setting/serializers.py @@ -9,9 +9,6 @@ class OrganizationThemeSerializer(serializers.ModelSerializer): - url_logo = serializers.SerializerMethodField() - url_favicon = serializers.SerializerMethodField() - class Meta: model = OrganizationTheme fields = [ @@ -20,42 +17,28 @@ class Meta: "favicon", "logo", "theme_colors", - "url_logo", - "url_favicon", ] extra_kwargs = { "id": {"read_only": True}, "favicon": {"write_only": True}, "logo": {"write_only": True}, - "url_logo": {"read_only": True}, - "url_favicon": {"read_only": True}, } - def get_url_logo(self, instance): - host = settings.HOST.rstrip("/") - if not instance.logo: - default_logo = ( - "logo_white.png" if instance.theme_key == "dark" else "logo_black.png" - ) - return f"{host}/static/images/branding/{default_logo}" - return get_presigned_url( - settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), - f"uploads/{instance.logo}", + def to_representation(self, instance): + data = super().to_representation(instance) + aws_s3 = getattr(settings, "AWS_S3", None) + bucket_name = aws_s3.get("AWS_STORAGE_BUCKET_NAME") if aws_s3 else None + data["url_logo"] = ( + get_presigned_url(bucket_name, f"uploads/{instance.logo}") + if instance.logo and bucket_name + else None ) - - def get_url_favicon(self, instance): - host = settings.HOST.rstrip("/") - if not instance.favicon: - default_favicon = ( - "favicon_white.png" - if instance.theme_key == "dark" - else "favicon_black.png" - ) - return f"{host}/static/images/branding/{default_favicon}" - return get_presigned_url( - settings.AWS_S3.get("AWS_STORAGE_BUCKET_NAME"), - f"uploads/{instance.favicon}", + data["url_favicon"] = ( + get_presigned_url(bucket_name, f"uploads/{instance.favicon}") + if instance.favicon and bucket_name + else None ) + return data class OrganizationSettingSerializer(serializers.ModelSerializer): From 94c394d152529760f2b2e039d0d14641c3d6cfb1 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 3 Jul 2026 10:31:57 +0700 Subject: [PATCH 07/10] feat: implement django silk for api performance monitoring and profiling --- bootstrap_service/settings.py | 21 +++++++++++++++++++++ bootstrap_service/urls.py | 6 ++++++ requirements.txt | 3 ++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index ab778bb..4212eea 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -36,6 +36,7 @@ DJANGO_SETTINGS_MODULE = "bootstrap_service.settings" ROOT_URLCONF = "bootstrap_service.urls" +SILK_ENABLED = os.getenv("ENV", "dev").lower() == "dev" DEBUG = True ALLOWED_HOSTS = ["*"] @@ -113,6 +114,9 @@ ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_AUTHENTICATION_METHOD = "email" +if SILK_ENABLED: + INSTALLED_APPS.append("silk") + # Middleware configuration (required for admin application) MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", @@ -124,6 +128,23 @@ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] +if SILK_ENABLED: + MIDDLEWARE.insert(2, "silk.middleware.SilkyMiddleware") + + def silky_intercept_func(request): + return request.path.startswith("/api/") + + SILKY_INTERCEPT_FUNC = silky_intercept_func + SILKY_AUTHENTICATION = False + SILKY_AUTHORISATION = False + SILKY_PYTHON_PROFILER = True + SILKY_PYTHON_PROFILER_BINARY = False + SILKY_MAX_REQUEST_BODY_SIZE = 1024 + SILKY_MAX_RESPONSE_BODY_SIZE = 0 + SILKY_META = True + SILKY_INTERCEPT_PERCENT = 10 + SILKY_MAX_RECORDED_REQUESTS_CHECK_PERCENT = 10 + # Internationalization # https://docs.djangoproject.com/en/3.2/topics/i18n/ LANGUAGE_CODE = "en-us" diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index c48efcf..47edc4a 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -14,6 +14,7 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings from django.contrib import admin from django.db import connection from django.http import HttpResponse @@ -48,6 +49,11 @@ def health_check(_): schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui", ), + *( + [path("silk/console/", include("silk.urls", namespace="silk"))] + if settings.SILK_ENABLED + else [] + ), # health path("bootstrap/api/health", health_check), # admin diff --git a/requirements.txt b/requirements.txt index f622631..ba1f2eb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ pika==1.3.2 django-redis==5.4.0 boto3==1.37.13 psycopg2-binary==2.9.9 -django_tenants==3.6.1 \ No newline at end of file +django_tenants==3.6.1 +django-silk==5.3.2 \ No newline at end of file From 5be3410e0f0d9eae487eb10efba954f1b599b19d Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 3 Jul 2026 10:36:06 +0700 Subject: [PATCH 08/10] fix: rename url for silk --- bootstrap_service/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py index 47edc4a..f764f69 100644 --- a/bootstrap_service/urls.py +++ b/bootstrap_service/urls.py @@ -50,7 +50,7 @@ def health_check(_): name="schema-swagger-ui", ), *( - [path("silk/console/", include("silk.urls", namespace="silk"))] + [path("silk/bootstrap/", include("silk.urls", namespace="silk"))] if settings.SILK_ENABLED else [] ), From 25b3f9803900bf726876240464dfec99e1e1773a Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 3 Jul 2026 11:04:45 +0700 Subject: [PATCH 09/10] chore: improve query performance --- apps/custom_email/service.py | 6 ++++- apps/custom_email/views.py | 12 +++++++++- apps/organization_roles/services.py | 24 +++++++++++-------- .../management/commands/init_organization.py | 5 ++-- bootstrap_service/settings.py | 1 + 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/custom_email/service.py b/apps/custom_email/service.py index 6409ee9..b2a8dac 100644 --- a/apps/custom_email/service.py +++ b/apps/custom_email/service.py @@ -17,7 +17,11 @@ def get_theme_logo_url(instance, theme_key): if not setting: return f"{host}/static/images/branding/{default_logo}" - theme = setting.themes.filter(theme_key=theme_key).first() + themes = getattr(setting, "_prefetched_objects_cache", {}).get("themes") + if themes is not None: + theme = next((item for item in themes if item.theme_key == theme_key), None) + else: + theme = setting.themes.filter(theme_key=theme_key).first() if not theme or not theme.logo: return f"{host}/static/images/branding/{default_logo}" diff --git a/apps/custom_email/views.py b/apps/custom_email/views.py index 1298b79..4541749 100644 --- a/apps/custom_email/views.py +++ b/apps/custom_email/views.py @@ -1,15 +1,25 @@ from common.pagination.base_pagination import BasePagination +from django.db.models import Prefetch from django_filters.rest_framework import DjangoFilterBackend from rest_framework.filters import OrderingFilter from apps.custom_email.models import OrganizationEmail from apps.custom_email.serializers import OrganizationEmailSerializer +from apps.organization_setting.models import OrganizationTheme from utils.views import OrganizationListAPIView class ListCustomEmailView(OrganizationListAPIView): serializer_class = OrganizationEmailSerializer - queryset = OrganizationEmail.objects.all() + queryset = OrganizationEmail.objects.select_related( + "organization", + "organization__organization_settings", + ).prefetch_related( + Prefetch( + "organization__organization_settings__themes", + queryset=OrganizationTheme.objects.all(), + ) + ) organization_field = "organization" pagination_class = BasePagination filterset_fields = ["email_type"] diff --git a/apps/organization_roles/services.py b/apps/organization_roles/services.py index de21d5e..7fa7679 100644 --- a/apps/organization_roles/services.py +++ b/apps/organization_roles/services.py @@ -108,22 +108,26 @@ def create_default_policies(organization): - organization_policies = [] - for policy in default_policies: - organization_policy = OrganizationPolicy(**policy, organization=organization) - organization_policy.save() - organization_policies.append(organization_policy.pk) + organization_policies = [ + OrganizationPolicy(**policy, organization=organization) + for policy in default_policies + ] + OrganizationPolicy.objects.bulk_create(organization_policies) return organization_policies -def create_default_organization_role_by_policy_tag(name, tag, organization): - policies = OrganizationPolicy.objects.filter( - tags__icontains=tag, organization=organization - ).all() +def create_default_organization_role_by_policy_tag( + name, tag, organization, policies=None +): + if policies is None: + policies = OrganizationPolicy.objects.filter( + tags__contains=[tag], organization=organization + ).all() + else: + policies = [policy for policy in policies if tag in policy.tags] organization_role = OrganizationRole(name=name, organization=organization) organization_role.save() organization_role.policies.set([policy.pk for policy in policies]) - organization_role.save() return organization_role diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index b28298c..244a646 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -119,7 +119,8 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): ) self.stdout.write(self.style.SUCCESS(f"Created organization: {org_name}")) - create_default_policies(organization) + organization_policies = create_default_policies(organization) + create_default_pages(organization) create_default_organization_email(organization) create_default_organization_setting(organization) @@ -135,7 +136,7 @@ def _create_organization_with_roles(self, org_id, org_name, org_slug, result): owner_role = None for role_type, policy_tag in role_mappings: role = create_default_organization_role_by_policy_tag( - role_type, policy_tag, organization + role_type, policy_tag, organization, organization_policies ) if role_type == OrganizationRoleType.OWNER_ROLE: owner_role = role diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index 4212eea..f74dfbd 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -143,6 +143,7 @@ def silky_intercept_func(request): SILKY_MAX_RESPONSE_BODY_SIZE = 0 SILKY_META = True SILKY_INTERCEPT_PERCENT = 10 + SILKY_MAX_RECORDED_REQUESTS = 500 SILKY_MAX_RECORDED_REQUESTS_CHECK_PERCENT = 10 # Internationalization From c0998f93e172300ad4d877f1a8d268922c643af8 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 3 Jul 2026 13:08:14 +0700 Subject: [PATCH 10/10] fix: update response for api check org --- apps/organization/views.py | 6 ++++-- apps/organization_setting/serializers.py | 15 ++++++++++++++- apps/organization_setting/views.py | 4 ++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/organization/views.py b/apps/organization/views.py index a6b331d..b2d0c29 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -9,7 +9,9 @@ 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 OrganizationSettingSerializer +from apps.organization_setting.serializers import ( + OrganizationSettingsWithCustomPagesSerializer, +) from utils.views import OrganizationRetrieveAPIView @@ -80,7 +82,7 @@ def get(self, request, slug_name): { "result": result, "template": organization.template, - "setting": OrganizationSettingSerializer(setting).data + "setting": OrganizationSettingsWithCustomPagesSerializer(setting).data if setting else None, }, diff --git a/apps/organization_setting/serializers.py b/apps/organization_setting/serializers.py index 928a54f..ab4b94a 100644 --- a/apps/organization_setting/serializers.py +++ b/apps/organization_setting/serializers.py @@ -59,7 +59,7 @@ class Meta: } -class OrganizationConfigSerializer(OrganizationSettingSerializer): +class OrganizationSettingsExpandedSerializer(OrganizationSettingSerializer): custom_pages = CustomPageSerializer( source="organization.organization_custom_page", many=True, @@ -78,6 +78,19 @@ class Meta(OrganizationSettingSerializer.Meta): ] +class OrganizationSettingsWithCustomPagesSerializer(OrganizationSettingSerializer): + custom_pages = CustomPageSerializer( + source="organization.organization_custom_page", + many=True, + read_only=True, + ) + + class Meta(OrganizationSettingSerializer.Meta): + fields = OrganizationSettingSerializer.Meta.fields + [ + "custom_pages", + ] + + class UpdateOrganizationSettingSerializer(serializers.Serializer): id = serializers.UUIDField(read_only=True) site_title = serializers.CharField(required=False, allow_blank=True) diff --git a/apps/organization_setting/views.py b/apps/organization_setting/views.py index 16c13c7..54fcf40 100644 --- a/apps/organization_setting/views.py +++ b/apps/organization_setting/views.py @@ -5,7 +5,7 @@ from apps.organization.models import Organization from apps.organization_setting.models import OrganizationSetting from apps.organization_setting.serializers import ( - OrganizationConfigSerializer, + OrganizationSettingsExpandedSerializer, UpdateOrganizationSettingSerializer, ) @@ -39,7 +39,7 @@ def update(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) updated_instance = serializer.save() fresh_instance = self.get_queryset().get(pk=updated_instance.pk) - response_serializer = OrganizationConfigSerializer( + response_serializer = OrganizationSettingsExpandedSerializer( fresh_instance, context=self.get_serializer_context(), )