feat(media): add client and project thumbnails
This commit is contained in:
@@ -13,9 +13,33 @@ class ClientSerializer(BaseModelSerializer):
|
|||||||
"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):
|
||||||
"""
|
"""
|
||||||
@@ -24,6 +48,10 @@ class ClientCreateSerializer(serializers.Serializer):
|
|||||||
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):
|
||||||
@@ -32,3 +60,8 @@ class ClientUpdateSerializer(serializers.Serializer):
|
|||||||
"""
|
"""
|
||||||
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)
|
||||||
|
|||||||
@@ -63,10 +63,11 @@ class ClientViewSet(ModelViewSet):
|
|||||||
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):
|
||||||
@@ -82,10 +83,12 @@ class ClientViewSet(ModelViewSet):
|
|||||||
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):
|
||||||
|
|||||||
18
apps/clients/migrations/0002_client_thumbnail.py
Normal file
18
apps/clients/migrations/0002_client_thumbnail.py
Normal 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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
@@ -40,5 +41,17 @@ def update_client(client, name=None, notes=None):
|
|||||||
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
|
||||||
|
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
|
return client
|
||||||
|
|||||||
@@ -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,10 +35,23 @@ 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': (
|
||||||
|
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
|
return representation
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -123,10 +126,11 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
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")
|
||||||
|
|||||||
18
apps/projects/migrations/0005_project_thumbnail.py
Normal file
18
apps/projects/migrations/0005_project_thumbnail.py
Normal 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/'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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,
|
||||||
@@ -49,9 +50,17 @@ 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:
|
||||||
@@ -61,6 +70,10 @@ def update_project(project, **kwargs):
|
|||||||
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:
|
||||||
|
storage = project.thumbnail.storage
|
||||||
|
if storage.exists(old_thumbnail_name):
|
||||||
|
storage.delete(old_thumbnail_name)
|
||||||
|
|
||||||
return project
|
return project
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user