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")), ]