diff --git a/apps/clients/api/serializers.py b/apps/clients/api/serializers.py index 95616f9..6b9cd9b 100644 --- a/apps/clients/api/serializers.py +++ b/apps/clients/api/serializers.py @@ -3,32 +3,65 @@ from apps.clients.models import Client from core.serializers.base import BaseModelSerializer -class ClientSerializer(BaseModelSerializer): +class ClientSerializer(BaseModelSerializer): """ Serializer for retrieving and representing client details. """ class Meta: model = Client - fields = BaseModelSerializer.Meta.fields + ( - "workspace", - "name", - "notes", - ) - read_only_fields = fields + fields = BaseModelSerializer.Meta.fields + ( + "workspace", + "name", + "notes", + "thumbnail", + ) + read_only_fields = fields + + 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 + return data + + +def validate_thumbnail(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 class ClientCreateSerializer(serializers.Serializer): """ Serializer for handling input data during client creation. """ - workspace_id = serializers.UUIDField() - name = serializers.CharField(max_length=255) - notes = serializers.CharField(allow_blank=True, required=False, default="") + workspace_id = serializers.UUIDField() + name = serializers.CharField(max_length=255) + notes = serializers.CharField(allow_blank=True, required=False, default="") + thumbnail = serializers.ImageField(required=False, allow_null=True) + + def validate_thumbnail(self, value): + return validate_thumbnail(value) class ClientUpdateSerializer(serializers.Serializer): """ Serializer for handling input data during client updates. """ - name = serializers.CharField(max_length=255, required=False) - notes = serializers.CharField(allow_blank=True, required=False) + name = serializers.CharField(max_length=255, required=False) + notes = serializers.CharField(allow_blank=True, required=False) + thumbnail = serializers.ImageField(required=False, allow_null=True) + clear_thumbnail = serializers.BooleanField(required=False, default=False) + + def validate_thumbnail(self, value): + return validate_thumbnail(value) diff --git a/apps/clients/api/views.py b/apps/clients/api/views.py index 7eb9a98..b2497b4 100644 --- a/apps/clients/api/views.py +++ b/apps/clients/api/views.py @@ -61,12 +61,13 @@ class ClientViewSet(ModelViewSet): client = create_client( user=request.user, - workspace_id=serializer.validated_data["workspace_id"], - name=serializer.validated_data["name"], - notes=serializer.validated_data.get("notes", "") - ) - - output_serializer = ClientSerializer(client) + workspace_id=serializer.validated_data["workspace_id"], + name=serializer.validated_data["name"], + notes=serializer.validated_data.get("notes", ""), + thumbnail=serializer.validated_data.get("thumbnail"), + ) + + output_serializer = ClientSerializer(client, context={"request": request}) return Response(output_serializer.data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): @@ -80,12 +81,14 @@ class ClientViewSet(ModelViewSet): serializer.is_valid(raise_exception=True) updated_client = update_client( - client=client, - name=serializer.validated_data.get("name"), - notes=serializer.validated_data.get("notes") - ) - - output_serializer = ClientSerializer(updated_client) + client=client, + name=serializer.validated_data.get("name"), + notes=serializer.validated_data.get("notes"), + thumbnail=serializer.validated_data.get("thumbnail"), + clear_thumbnail=serializer.validated_data.get("clear_thumbnail", False), + ) + + output_serializer = ClientSerializer(updated_client, context={"request": request}) return Response(output_serializer.data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): diff --git a/apps/clients/migrations/0002_client_thumbnail.py b/apps/clients/migrations/0002_client_thumbnail.py new file mode 100644 index 0000000..30ad42a --- /dev/null +++ b/apps/clients/migrations/0002_client_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-05-26 08:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('clients', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='client', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to='profile/clients/'), + ), + ] diff --git a/apps/clients/models.py b/apps/clients/models.py index 29066f7..f861152 100644 --- a/apps/clients/models.py +++ b/apps/clients/models.py @@ -17,6 +17,8 @@ class Client(BaseModel): notes = models.TextField(blank=True) + thumbnail = models.ImageField(upload_to="profile/clients/", blank=True, null=True) + class Meta: db_table = "client" ordering = ("-updated_at", "-created_at") diff --git a/apps/clients/services/clients.py b/apps/clients/services/clients.py index 93aeb4d..654e14d 100644 --- a/apps/clients/services/clients.py +++ b/apps/clients/services/clients.py @@ -3,7 +3,7 @@ from apps.clients.models import Client from apps.workspaces.models import WorkspaceMembership -def create_client(user, workspace_id, name, notes=""): +def create_client(user, workspace_id, name, notes="", thumbnail=None): """ Creates a new client after validating workspace membership and name uniqueness. """ @@ -23,12 +23,13 @@ def create_client(user, workspace_id, name, notes=""): workspace_id=workspace_id, name=name, notes=notes, + thumbnail=thumbnail, created_by=user, updated_by=user, ) - - -def update_client(client, name=None, notes=None): + + +def update_client(client, name=None, notes=None, thumbnail=None, clear_thumbnail=False): """ Updates an existing client while validating name uniqueness within the workspace. """ @@ -37,8 +38,20 @@ def update_client(client, name=None, notes=None): raise ValidationError({"name": "مشتری با این نام در این فضای کاری وجود دارد."}) client.name = name - if notes is not None: - client.notes = notes - - client.save(update_fields=["name", "notes", "updated_at"]) - return client + if notes is not None: + client.notes = notes + + old_thumbnail_name = client.thumbnail.name if client.thumbnail else None + if clear_thumbnail and client.thumbnail: + client.thumbnail.delete(save=False) + client.thumbnail = None + + if thumbnail is not None: + client.thumbnail = thumbnail + + client.save(update_fields=["name", "notes", "thumbnail", "updated_at"]) + if old_thumbnail_name and client.thumbnail and client.thumbnail.name != old_thumbnail_name: + storage = client.thumbnail.storage + if storage.exists(old_thumbnail_name): + storage.delete(old_thumbnail_name) + return client diff --git a/apps/projects/api/serializers.py b/apps/projects/api/serializers.py index 7eda12b..9caba39 100644 --- a/apps/projects/api/serializers.py +++ b/apps/projects/api/serializers.py @@ -6,6 +6,19 @@ from apps.projects.models import Project from apps.workspaces.models import PriceUnit +def validate_thumbnail(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 + + class ProjectSerializer(BaseModelSerializer): class Meta: model = Project @@ -14,6 +27,7 @@ class ProjectSerializer(BaseModelSerializer): "name", "client", "description", + "thumbnail", "is_archived", "color", ) @@ -21,12 +35,25 @@ class ProjectSerializer(BaseModelSerializer): def to_representation(self, instance): representation = super().to_representation(instance) + request = self.context.get("request") + if instance.thumbnail: + thumbnail_url = instance.thumbnail.url + representation["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url + else: + representation["thumbnail"] = None if instance.client: representation['client'] = { - 'id': instance.client.id, - 'name': instance.client.name - } - return representation + 'id': instance.client.id, + 'name': instance.client.name, + 'thumbnail': ( + request.build_absolute_uri(instance.client.thumbnail.url) + if request and instance.client.thumbnail + else instance.client.thumbnail.url + if instance.client.thumbnail + else None + ), + } + return representation class ProjectCreateSerializer(serializers.Serializer): @@ -34,16 +61,25 @@ class ProjectCreateSerializer(serializers.Serializer): name = serializers.CharField(max_length=255) client = serializers.UUIDField(required=False, allow_null=True) description = serializers.CharField(required=False, allow_blank=True, default="") + thumbnail = serializers.ImageField(required=False, allow_null=True) color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="") + def validate_thumbnail(self, value): + return validate_thumbnail(value) + class ProjectUpdateSerializer(serializers.Serializer): name = serializers.CharField(max_length=255, required=False) client = serializers.UUIDField(required=False, allow_null=True) description = serializers.CharField(required=False, allow_blank=True) + thumbnail = serializers.ImageField(required=False, allow_null=True) + clear_thumbnail = serializers.BooleanField(required=False, default=False) color = serializers.CharField(max_length=7, required=False, allow_blank=True) is_archived = serializers.BooleanField(required=False) + def validate_thumbnail(self, value): + return validate_thumbnail(value) + class ProjectAccessQuerySerializer(serializers.Serializer): workspace = serializers.UUIDField() diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index 1c7f56a..7b7de81 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -89,6 +89,9 @@ class ProjectViewSet(ModelViewSet): if client_ids: queryset = queryset.filter(client_id__in=client_ids) + if "is_archived" not in self.request.query_params: + queryset = queryset.filter(is_archived=False) + return queryset def get_serializer_class(self): @@ -120,13 +123,14 @@ class ProjectViewSet(ModelViewSet): project = create_project( user=request.user, workspace=workspace, - name=serializer.validated_data["name"], - client=client, + name=serializer.validated_data["name"], + client=client, description=serializer.validated_data.get("description", ""), - color=serializer.validated_data.get("color", "") + color=serializer.validated_data.get("color", ""), + thumbnail=serializer.validated_data.get("thumbnail"), ) - output_serializer = ProjectSerializer(project) + output_serializer = ProjectSerializer(project, context={"request": request}) return Response(output_serializer.data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): @@ -144,7 +148,7 @@ class ProjectViewSet(ModelViewSet): **serializer.validated_data ) - output_serializer = ProjectSerializer(updated_project) + output_serializer = ProjectSerializer(updated_project, context={"request": request}) return Response(output_serializer.data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): @@ -169,7 +173,7 @@ class ProjectViewSet(ModelViewSet): project = self.get_object() updated_project = toggle_project_archive(project) - output_serializer = ProjectSerializer(updated_project) + output_serializer = ProjectSerializer(updated_project, context={"request": request}) return Response(output_serializer.data, status=status.HTTP_200_OK) @action(detail=False, methods=["get"], url_path="access") diff --git a/apps/projects/migrations/0005_project_thumbnail.py b/apps/projects/migrations/0005_project_thumbnail.py new file mode 100644 index 0000000..d0ba0a4 --- /dev/null +++ b/apps/projects/migrations/0005_project_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.12 on 2026-05-26 08:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_projectaccess'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to='profile/projects/'), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 39cc0a7..df105b4 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -28,6 +28,8 @@ class Project(BaseModel): description = models.TextField(blank=True) + thumbnail = models.ImageField(upload_to="profile/projects/", blank=True, null=True) + is_archived = models.BooleanField(default=False) color = models.CharField(max_length=7, blank=True) diff --git a/apps/projects/services/projects.py b/apps/projects/services/projects.py index eaaaedd..d8d757d 100644 --- a/apps/projects/services/projects.py +++ b/apps/projects/services/projects.py @@ -7,8 +7,8 @@ from apps.projects.models import Project from apps.workspaces.models import WorkspaceMembership -@transaction.atomic -def create_project(user, workspace, name, client=None, description="", color=""): +@transaction.atomic +def create_project(user, workspace, name, client=None, description="", color="", thumbnail=None): """ Creates a new workspace-shared project. """ @@ -30,6 +30,7 @@ def create_project(user, workspace, name, client=None, description="", color="") name=name, client=client, description=description, + thumbnail=thumbnail, color=color, created_by=user, updated_by=user, @@ -38,7 +39,7 @@ def create_project(user, workspace, name, client=None, description="", color="") return project -def update_project(project, **kwargs): +def update_project(project, **kwargs): """ Updates specific fields of an existing project. """ @@ -49,20 +50,32 @@ def update_project(project, **kwargs): if Project.objects.filter(workspace=project.workspace, name=kwargs["name"], is_deleted=False).exists(): raise ValidationError({"name": "A project with this name already exists in the workspace."}) - client_id = kwargs.pop("client") - client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None - kwargs["client"] = client + if "client" in kwargs: + client_id = kwargs.pop("client") + client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None + kwargs["client"] = client + + clear_thumbnail = kwargs.pop("clear_thumbnail", False) + old_thumbnail_name = project.thumbnail.name if project.thumbnail else None + if clear_thumbnail and project.thumbnail: + project.thumbnail.delete(save=False) + project.thumbnail = None + update_fields.append("thumbnail") - for field, value in kwargs.items(): - if hasattr(project, field) and getattr(project, field) != value: - setattr(project, field, value) - update_fields.append(field) + for field, value in kwargs.items(): + if hasattr(project, field) and getattr(project, field) != value: + setattr(project, field, value) + update_fields.append(field) if update_fields: - update_fields.append("updated_at") - project.save(update_fields=update_fields) - - return project + update_fields.append("updated_at") + project.save(update_fields=update_fields) + if old_thumbnail_name and project.thumbnail and project.thumbnail.name != old_thumbnail_name: + storage = project.thumbnail.storage + if storage.exists(old_thumbnail_name): + storage.delete(old_thumbnail_name) + + return project def toggle_project_archive(project) -> Project: