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