From 315f2ca728968c56b66585363c6699c3717e82b1 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 28 Apr 2026 11:38:35 +0330 Subject: [PATCH] feat(workspaces): add thumbnail upload and lifecycle support --- apps/workspaces/api/serializers.py | 80 ++++++++++++++++--- apps/workspaces/api/views.py | 10 ++- .../migrations/0006_workspace_thumbnail.py | 17 ++++ apps/workspaces/models.py | 1 + 4 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 apps/workspaces/migrations/0006_workspace_thumbnail.py diff --git a/apps/workspaces/api/serializers.py b/apps/workspaces/api/serializers.py index 0cb4a37..dc8c470 100644 --- a/apps/workspaces/api/serializers.py +++ b/apps/workspaces/api/serializers.py @@ -1,4 +1,5 @@ from decimal import Decimal +import json from rest_framework import serializers @@ -10,37 +11,73 @@ from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, Wo from core.serializers.mini import UserMiniSerializer -class WorkspaceMemberInputSerializer(serializers.Serializer): +class WorkspaceMemberInputSerializer(serializers.Serializer): user_id = serializers.UUIDField() role = serializers.ChoiceField(choices=WorkspaceMembership.Role.choices, default=WorkspaceMembership.Role.MEMBER) -class WorkspaceSerializer(BaseModelSerializer): - members = WorkspaceMemberInputSerializer(many=True, write_only=True, required=False) - my_role = serializers.SerializerMethodField() +class WorkspaceSerializer(BaseModelSerializer): + members = serializers.JSONField(write_only=True, required=False) + clear_thumbnail = serializers.BooleanField(write_only=True, required=False, default=False) + my_role = serializers.SerializerMethodField() class Meta: model = Workspace fields = BaseModelSerializer.Meta.fields + ( - "name", - "description", - "owner", - "my_role", - "members", + "name", + "description", + "thumbnail", + "clear_thumbnail", + "owner", + "my_role", + "members", ) read_only_fields = BaseModelSerializer.Meta.fields + ( "owner", ) - def get_my_role(self, obj): + def get_my_role(self, obj): membership = WorkspaceMembership.objects.filter( workspace=obj, user=self.context["request"].user, - ).first() - return getattr(membership, "role", None) + ).first() + return getattr(membership, "role", None) + + def validate_thumbnail(self, value): + if value is None: + return value + max_bytes = 2 * 1024 * 1024 + if getattr(value, "size", 0) > max_bytes: + raise serializers.ValidationError("Image size must be 2MB or less.") + content_type = (getattr(value, "content_type", "") or "").lower() + allowed_types = {"image/jpeg", "image/png", "image/webp"} + if content_type and content_type not in allowed_types: + raise serializers.ValidationError("Unsupported image type. Use JPG, PNG, or WebP.") + return value + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get("request") + if instance.thumbnail: + thumbnail_url = instance.thumbnail.url + data["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url + else: + data["thumbnail"] = None + data.pop("clear_thumbnail", None) + return data def create(self, validated_data): - members_data = validated_data.pop('members', []) + members_data = validated_data.pop("members", []) + if isinstance(members_data, str): + try: + members_data = json.loads(members_data) + except json.JSONDecodeError as exc: + raise serializers.ValidationError({"members": "Invalid members format."}) from exc + if members_data: + members_serializer = WorkspaceMemberInputSerializer(data=members_data, many=True) + members_serializer.is_valid(raise_exception=True) + members_data = members_serializer.validated_data + validated_data.pop("clear_thumbnail", None) workspace = super().create(validated_data) @@ -74,6 +111,23 @@ class WorkspaceSerializer(BaseModelSerializer): ) return workspace + + def update(self, instance, validated_data): + clear_thumbnail = validated_data.pop("clear_thumbnail", False) + old_thumbnail_name = instance.thumbnail.name if instance.thumbnail else None + + if clear_thumbnail and instance.thumbnail: + instance.thumbnail.delete(save=False) + instance.thumbnail = None + + updated_workspace = super().update(instance, validated_data) + + if old_thumbnail_name and updated_workspace.thumbnail and updated_workspace.thumbnail.name != old_thumbnail_name: + storage = updated_workspace.thumbnail.storage + if storage.exists(old_thumbnail_name): + storage.delete(old_thumbnail_name) + + return updated_workspace class WorkspaceMembershipSerializer(BaseModelSerializer): diff --git a/apps/workspaces/api/views.py b/apps/workspaces/api/views.py index cb72d4e..fc9b79a 100644 --- a/apps/workspaces/api/views.py +++ b/apps/workspaces/api/views.py @@ -5,8 +5,9 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.filters import OrderingFilter, SearchFilter from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import FormParser, MultiPartParser, JSONParser from apps.notifications.services import ( notify_workspace_membership_added, @@ -43,9 +44,10 @@ from apps.workspaces.services import ( from core.paginations.limit_offset import CustomLimitOffsetPagination -class WorkspaceViewSet(ModelViewSet): - serializer_class = WorkspaceSerializer - pagination_class = CustomLimitOffsetPagination +class WorkspaceViewSet(ModelViewSet): + serializer_class = WorkspaceSerializer + parser_classes = [MultiPartParser, FormParser, JSONParser] + pagination_class = CustomLimitOffsetPagination filter_backends = (DjangoFilterBackend, OrderingFilter, SearchFilter) filterset_class = WorkspaceFilter search_fields = ("name", "description") diff --git a/apps/workspaces/migrations/0006_workspace_thumbnail.py b/apps/workspaces/migrations/0006_workspace_thumbnail.py new file mode 100644 index 0000000..579df23 --- /dev/null +++ b/apps/workspaces/migrations/0006_workspace_thumbnail.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("workspaces", "0005_remove_priceunit_priceunit_id_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="workspace", + name="thumbnail", + field=models.ImageField(blank=True, null=True, upload_to="profile/workspaces/"), + ), + ] + diff --git a/apps/workspaces/models.py b/apps/workspaces/models.py index 7015c5e..1c91442 100644 --- a/apps/workspaces/models.py +++ b/apps/workspaces/models.py @@ -9,6 +9,7 @@ User = get_user_model() class Workspace(BaseModel): name = models.CharField(max_length=255) description = models.TextField(blank=True) + thumbnail = models.ImageField(upload_to="profile/workspaces/", blank=True, null=True) owner = models.ForeignKey( User, on_delete=models.PROTECT,