feat(media): add client and project thumbnails

This commit is contained in:
2026-05-26 12:15:09 +03:30
parent f99e883f12
commit e42e0612aa
10 changed files with 199 additions and 57 deletions

View File

@@ -3,32 +3,65 @@ from apps.clients.models import Client
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
class ClientSerializer(BaseModelSerializer): class ClientSerializer(BaseModelSerializer):
""" """
Serializer for retrieving and representing client details. Serializer for retrieving and representing client details.
""" """
class Meta: class Meta:
model = Client model = Client
fields = BaseModelSerializer.Meta.fields + ( fields = BaseModelSerializer.Meta.fields + (
"workspace", "workspace",
"name", "name",
"notes", "notes",
) "thumbnail",
read_only_fields = fields )
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): class ClientCreateSerializer(serializers.Serializer):
""" """
Serializer for handling input data during client creation. Serializer for handling input data during client creation.
""" """
workspace_id = serializers.UUIDField() workspace_id = serializers.UUIDField()
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
notes = serializers.CharField(allow_blank=True, required=False, default="") 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): class ClientUpdateSerializer(serializers.Serializer):
""" """
Serializer for handling input data during client updates. Serializer for handling input data during client updates.
""" """
name = serializers.CharField(max_length=255, required=False) name = serializers.CharField(max_length=255, required=False)
notes = serializers.CharField(allow_blank=True, 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)

View File

@@ -61,12 +61,13 @@ class ClientViewSet(ModelViewSet):
client = create_client( client = create_client(
user=request.user, user=request.user,
workspace_id=serializer.validated_data["workspace_id"], workspace_id=serializer.validated_data["workspace_id"],
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
notes=serializer.validated_data.get("notes", "") notes=serializer.validated_data.get("notes", ""),
) thumbnail=serializer.validated_data.get("thumbnail"),
)
output_serializer = ClientSerializer(client)
output_serializer = ClientSerializer(client, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_201_CREATED) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -80,12 +81,14 @@ class ClientViewSet(ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
updated_client = update_client( updated_client = update_client(
client=client, client=client,
name=serializer.validated_data.get("name"), name=serializer.validated_data.get("name"),
notes=serializer.validated_data.get("notes") 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) )
output_serializer = ClientSerializer(updated_client, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):

View File

@@ -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/'),
),
]

View File

@@ -17,6 +17,8 @@ class Client(BaseModel):
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
thumbnail = models.ImageField(upload_to="profile/clients/", blank=True, null=True)
class Meta: class Meta:
db_table = "client" db_table = "client"
ordering = ("-updated_at", "-created_at") ordering = ("-updated_at", "-created_at")

View File

@@ -3,7 +3,7 @@ from apps.clients.models import Client
from apps.workspaces.models import WorkspaceMembership 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. 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, workspace_id=workspace_id,
name=name, name=name,
notes=notes, notes=notes,
thumbnail=thumbnail,
created_by=user, created_by=user,
updated_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. 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": "مشتری با این نام در این فضای کاری وجود دارد."}) raise ValidationError({"name": "مشتری با این نام در این فضای کاری وجود دارد."})
client.name = name client.name = name
if notes is not None: if notes is not None:
client.notes = notes client.notes = notes
client.save(update_fields=["name", "notes", "updated_at"]) old_thumbnail_name = client.thumbnail.name if client.thumbnail else None
return client 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

View File

@@ -6,6 +6,19 @@ from apps.projects.models import Project
from apps.workspaces.models import PriceUnit 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 ProjectSerializer(BaseModelSerializer):
class Meta: class Meta:
model = Project model = Project
@@ -14,6 +27,7 @@ class ProjectSerializer(BaseModelSerializer):
"name", "name",
"client", "client",
"description", "description",
"thumbnail",
"is_archived", "is_archived",
"color", "color",
) )
@@ -21,12 +35,25 @@ class ProjectSerializer(BaseModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
representation = super().to_representation(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: if instance.client:
representation['client'] = { representation['client'] = {
'id': instance.client.id, 'id': instance.client.id,
'name': instance.client.name 'name': instance.client.name,
} 'thumbnail': (
return representation 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): class ProjectCreateSerializer(serializers.Serializer):
@@ -34,16 +61,25 @@ class ProjectCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="") 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="") 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): class ProjectUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False) name = serializers.CharField(max_length=255, required=False)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=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) color = serializers.CharField(max_length=7, required=False, allow_blank=True)
is_archived = serializers.BooleanField(required=False) is_archived = serializers.BooleanField(required=False)
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ProjectAccessQuerySerializer(serializers.Serializer): class ProjectAccessQuerySerializer(serializers.Serializer):
workspace = serializers.UUIDField() workspace = serializers.UUIDField()

View File

@@ -89,6 +89,9 @@ class ProjectViewSet(ModelViewSet):
if client_ids: if client_ids:
queryset = queryset.filter(client_id__in=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 return queryset
def get_serializer_class(self): def get_serializer_class(self):
@@ -120,13 +123,14 @@ class ProjectViewSet(ModelViewSet):
project = create_project( project = create_project(
user=request.user, user=request.user,
workspace=workspace, workspace=workspace,
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
client=client, client=client,
description=serializer.validated_data.get("description", ""), 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) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -144,7 +148,7 @@ class ProjectViewSet(ModelViewSet):
**serializer.validated_data **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) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
@@ -169,7 +173,7 @@ class ProjectViewSet(ModelViewSet):
project = self.get_object() project = self.get_object()
updated_project = toggle_project_archive(project) 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) return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="access") @action(detail=False, methods=["get"], url_path="access")

View File

@@ -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/'),
),
]

View File

@@ -28,6 +28,8 @@ class Project(BaseModel):
description = models.TextField(blank=True) description = models.TextField(blank=True)
thumbnail = models.ImageField(upload_to="profile/projects/", blank=True, null=True)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
color = models.CharField(max_length=7, blank=True) color = models.CharField(max_length=7, blank=True)

View File

@@ -7,8 +7,8 @@ from apps.projects.models import Project
from apps.workspaces.models import WorkspaceMembership from apps.workspaces.models import WorkspaceMembership
@transaction.atomic @transaction.atomic
def create_project(user, workspace, name, client=None, description="", color=""): def create_project(user, workspace, name, client=None, description="", color="", thumbnail=None):
""" """
Creates a new workspace-shared project. Creates a new workspace-shared project.
""" """
@@ -30,6 +30,7 @@ def create_project(user, workspace, name, client=None, description="", color="")
name=name, name=name,
client=client, client=client,
description=description, description=description,
thumbnail=thumbnail,
color=color, color=color,
created_by=user, created_by=user,
updated_by=user, updated_by=user,
@@ -38,7 +39,7 @@ def create_project(user, workspace, name, client=None, description="", color="")
return project return project
def update_project(project, **kwargs): def update_project(project, **kwargs):
""" """
Updates specific fields of an existing project. 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(): 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."}) raise ValidationError({"name": "A project with this name already exists in the workspace."})
client_id = kwargs.pop("client") if "client" in kwargs:
client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None client_id = kwargs.pop("client")
kwargs["client"] = 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(): for field, value in kwargs.items():
if hasattr(project, field) and getattr(project, field) != value: if hasattr(project, field) and getattr(project, field) != value:
setattr(project, field, value) setattr(project, field, value)
update_fields.append(field) update_fields.append(field)
if update_fields: if update_fields:
update_fields.append("updated_at") update_fields.append("updated_at")
project.save(update_fields=update_fields) project.save(update_fields=update_fields)
if old_thumbnail_name and project.thumbnail and project.thumbnail.name != old_thumbnail_name:
return project storage = project.thumbnail.storage
if storage.exists(old_thumbnail_name):
storage.delete(old_thumbnail_name)
return project
def toggle_project_archive(project) -> Project: def toggle_project_archive(project) -> Project: