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

@@ -13,9 +13,33 @@ class ClientSerializer(BaseModelSerializer):
"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):
"""
@@ -24,6 +48,10 @@ class ClientCreateSerializer(serializers.Serializer):
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):
@@ -32,3 +60,8 @@ class ClientUpdateSerializer(serializers.Serializer):
"""
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)

View File

@@ -63,10 +63,11 @@ class ClientViewSet(ModelViewSet):
user=request.user,
workspace_id=serializer.validated_data["workspace_id"],
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)
def update(self, request, *args, **kwargs):
@@ -82,10 +83,12 @@ class ClientViewSet(ModelViewSet):
updated_client = update_client(
client=client,
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)
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)
thumbnail = models.ImageField(upload_to="profile/clients/", blank=True, null=True)
class Meta:
db_table = "client"
ordering = ("-updated_at", "-created_at")

View File

@@ -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.
"""
@@ -40,5 +41,17 @@ def update_client(client, name=None, notes=None):
if notes is not None:
client.notes = notes
client.save(update_fields=["name", "notes", "updated_at"])
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

View File

@@ -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,10 +35,23 @@ 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
'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
@@ -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()

View File

@@ -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):
@@ -123,10 +126,11 @@ class ProjectViewSet(ModelViewSet):
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")

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)
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)

View File

@@ -8,7 +8,7 @@ from apps.workspaces.models import WorkspaceMembership
@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.
"""
@@ -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,
@@ -49,9 +50,17 @@ 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:
@@ -61,6 +70,10 @@ def update_project(project, **kwargs):
if update_fields:
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