Compare commits
3 Commits
f99e883f12
...
20874b9968
| Author | SHA1 | Date | |
|---|---|---|---|
| 20874b9968 | |||
| af9facce7e | |||
| e42e0612aa |
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
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.
|
||||||
"""
|
"""
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.projects.models import ProjectUserRate
|
from apps.projects.models import ProjectUserRate
|
||||||
|
from apps.workspaces.models import HourlyRateHistory
|
||||||
|
|
||||||
|
|
||||||
|
def record_project_rate_history(*, project, user, hourly_rate, currency, effective_from=None):
|
||||||
|
currency = currency.upper()
|
||||||
|
effective_from = effective_from or timezone.now()
|
||||||
|
latest = (
|
||||||
|
HourlyRateHistory.objects.filter(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.PROJECT,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest and latest.hourly_rate == hourly_rate and latest.currency == currency:
|
||||||
|
return latest
|
||||||
|
return HourlyRateHistory.objects.create(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.PROJECT,
|
||||||
|
hourly_rate=hourly_rate,
|
||||||
|
currency=currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_current_project_user_rate(*, project, user):
|
def get_current_project_user_rate(*, project, user):
|
||||||
@@ -27,6 +56,7 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
effective_from = timezone.now()
|
||||||
if rate:
|
if rate:
|
||||||
update_fields = []
|
update_fields = []
|
||||||
if rate.is_deleted:
|
if rate.is_deleted:
|
||||||
@@ -43,16 +73,31 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
|
|||||||
if update_fields:
|
if update_fields:
|
||||||
update_fields.append("updated_at")
|
update_fields.append("updated_at")
|
||||||
rate.save(update_fields=update_fields)
|
rate.save(update_fields=update_fields)
|
||||||
|
record_project_rate_history(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
)
|
||||||
return rate
|
return rate
|
||||||
|
|
||||||
return ProjectUserRate.objects.create(
|
rate = ProjectUserRate.objects.create(
|
||||||
project=project,
|
project=project,
|
||||||
user=user,
|
user=user,
|
||||||
hourly_rate=hourly_rate,
|
hourly_rate=hourly_rate,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
effective_from=timezone.now(),
|
effective_from=effective_from,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
record_project_rate_history(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def remove_project_user_rate(*, project, user):
|
def remove_project_user_rate(*, project, user):
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ from apps.projects.models import Project
|
|||||||
from apps.projects.services.access import user_has_project_access
|
from apps.projects.services.access import user_has_project_access
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import HourlyRateHistory, Workspace
|
||||||
from apps.workspaces.models import WorkspaceUserRate
|
|
||||||
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -121,113 +120,73 @@ def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
|
def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]:
|
||||||
sorted_entries = sorted(entries, key=lambda entry: (entry.start_time, entry.end_time or entry.start_time, entry.id))
|
current_timezone = timezone.get_current_timezone()
|
||||||
periods: list[dict] = []
|
period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone)
|
||||||
current: dict | None = None
|
period_end = timezone.make_aware(datetime.combine(to_date + timedelta(days=1), time.min), current_timezone)
|
||||||
|
rows = list(
|
||||||
|
HourlyRateHistory.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
is_deleted=False,
|
||||||
|
effective_from__lt=period_end,
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.order_by("scope", "project_id", "effective_from", "created_at")
|
||||||
|
)
|
||||||
|
|
||||||
for entry in sorted_entries:
|
grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list)
|
||||||
if not entry.hourly_rate or not entry.start_time:
|
for row in rows:
|
||||||
continue
|
grouped[(row.scope, str(row.project_id) if row.project_id else None)].append(row)
|
||||||
|
|
||||||
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
|
serialized: list[dict] = []
|
||||||
currency = entry.currency or "USD"
|
for (_scope, _project_id), history_rows in grouped.items():
|
||||||
start_date = _localize_datetime(entry.start_time).date()
|
selected_indexes = {
|
||||||
end_source = entry.end_time or entry.start_time
|
index for index, row in enumerate(history_rows) if row.effective_from >= period_start
|
||||||
end_date = _localize_datetime(end_source).date()
|
}
|
||||||
|
previous_indexes = [
|
||||||
|
index for index, row in enumerate(history_rows) if row.effective_from < period_start
|
||||||
|
]
|
||||||
|
if previous_indexes:
|
||||||
|
selected_indexes.add(previous_indexes[-1])
|
||||||
|
|
||||||
if (
|
for index in sorted(selected_indexes):
|
||||||
current
|
row = history_rows[index]
|
||||||
and current["amount"] == amount
|
next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None
|
||||||
and current["currency"] == currency
|
if next_row and next_row.effective_from < period_start:
|
||||||
):
|
continue
|
||||||
if end_date > current["to_date"]:
|
from_day = max(_localize_datetime(row.effective_from).date(), from_date)
|
||||||
current["to_date"] = end_date
|
to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None
|
||||||
continue
|
serialized.append(
|
||||||
|
|
||||||
if current:
|
|
||||||
periods.append(
|
|
||||||
{
|
{
|
||||||
"amount": current["amount"],
|
"amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}",
|
||||||
"currency": current["currency"],
|
"currency": row.currency or "USD",
|
||||||
"from_date": current["from_date"].isoformat(),
|
"from_date": from_day.isoformat(),
|
||||||
"to_date": current["to_date"].isoformat(),
|
"to_date": to_day.isoformat() if to_day else None,
|
||||||
|
"scope": row.scope,
|
||||||
|
"project_name": row.project.name if row.project else None,
|
||||||
|
"is_current": next_row is None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
current = {
|
|
||||||
"amount": amount,
|
|
||||||
"currency": currency,
|
|
||||||
"from_date": start_date,
|
|
||||||
"to_date": end_date,
|
|
||||||
}
|
|
||||||
|
|
||||||
if current:
|
|
||||||
periods.append(
|
|
||||||
{
|
|
||||||
"amount": current["amount"],
|
|
||||||
"currency": current["currency"],
|
|
||||||
"from_date": current["from_date"].isoformat(),
|
|
||||||
"to_date": current["to_date"].isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return periods
|
|
||||||
|
|
||||||
|
|
||||||
def _serialize_current_rate_rows(*, user, workspace: Workspace) -> list[dict]:
|
|
||||||
workspace_rate = (
|
|
||||||
WorkspaceUserRate.objects.filter(
|
|
||||||
workspace=workspace,
|
|
||||||
user=user,
|
|
||||||
is_active=True,
|
|
||||||
is_deleted=False,
|
|
||||||
)
|
|
||||||
.order_by("-effective_from", "-updated_at")
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not workspace_rate or not workspace_rate.effective_from:
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"amount": f"{Decimal(workspace_rate.hourly_rate).quantize(Decimal('0.01'))}",
|
|
||||||
"currency": workspace_rate.currency or "USD",
|
|
||||||
"from_date": _localize_datetime(workspace_rate.effective_from).date().isoformat(),
|
|
||||||
"to_date": None,
|
|
||||||
"is_current": True,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_rate_history_rows(history_rows: list[dict], current_rows: list[dict]) -> list[dict]:
|
|
||||||
merged = [dict(row) for row in history_rows]
|
|
||||||
latest_indexes = {
|
|
||||||
(row["amount"], row["currency"]): index
|
|
||||||
for index, row in enumerate(merged)
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in current_rows:
|
|
||||||
key = (row["amount"], row["currency"])
|
|
||||||
index = latest_indexes.get(key)
|
|
||||||
if index is not None:
|
|
||||||
merged[index]["to_date"] = None
|
|
||||||
continue
|
|
||||||
|
|
||||||
merged.append(dict(row))
|
|
||||||
latest_indexes[key] = len(merged) - 1
|
|
||||||
|
|
||||||
return sorted(
|
return sorted(
|
||||||
merged,
|
serialized,
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
item["from_date"],
|
item["from_date"],
|
||||||
item["currency"],
|
item["scope"],
|
||||||
|
item.get("project_name") or "",
|
||||||
Decimal(item["amount"]),
|
Decimal(item["amount"]),
|
||||||
item.get("to_date") or "9999-12-31",
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _uncategorized_label(kind: str, language: str) -> str:
|
def _uncategorized_label(kind: str, language: str) -> str:
|
||||||
|
if language == "fa":
|
||||||
|
return {
|
||||||
|
"clients": "بدون مشتری",
|
||||||
|
"projects": "بدون پروژه",
|
||||||
|
"tags": "بدون تگ",
|
||||||
|
}[kind]
|
||||||
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
||||||
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
||||||
|
|
||||||
@@ -422,11 +381,22 @@ def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict])
|
|||||||
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
|
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
|
||||||
|
|
||||||
|
|
||||||
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
|
def _build_user_summary(
|
||||||
|
user,
|
||||||
|
entries: list[TimeEntry],
|
||||||
|
*,
|
||||||
|
workspace: Workspace,
|
||||||
|
from_date: date,
|
||||||
|
to_date: date,
|
||||||
|
language: str,
|
||||||
|
) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
summary = _summary_from_entries(entries)
|
||||||
historical_rate_rows = _serialize_rate_periods(entries)
|
rate_rows = _serialize_rate_history_rows(
|
||||||
current_rate_rows = _serialize_current_rate_rows(user=user, workspace=entries[0].workspace)
|
user=user,
|
||||||
rate_rows = _merge_rate_history_rows(historical_rate_rows, current_rate_rows)
|
workspace=workspace,
|
||||||
|
from_date=from_date,
|
||||||
|
to_date=to_date,
|
||||||
|
)
|
||||||
project_rows = _build_breakdown(entries, "projects", language=language)
|
project_rows = _build_breakdown(entries, "projects", language=language)
|
||||||
client_rows = _build_breakdown(entries, "clients", language=language)
|
client_rows = _build_breakdown(entries, "clients", language=language)
|
||||||
tag_rows = _build_breakdown(entries, "tags", language=language)
|
tag_rows = _build_breakdown(entries, "tags", language=language)
|
||||||
@@ -458,13 +428,20 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]:
|
def _build_user_summaries(entries: list[TimeEntry], *, filters: ReportFilters) -> list[dict]:
|
||||||
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
|
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
grouped[str(entry.user_id)].append(entry)
|
grouped[str(entry.user_id)].append(entry)
|
||||||
|
|
||||||
summaries = [
|
summaries = [
|
||||||
_build_user_summary(grouped_entries[0].user, grouped_entries, language=language)
|
_build_user_summary(
|
||||||
|
grouped_entries[0].user,
|
||||||
|
grouped_entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
|
language=filters.language,
|
||||||
|
)
|
||||||
for grouped_entries in grouped.values()
|
for grouped_entries in grouped.values()
|
||||||
if grouped_entries
|
if grouped_entries
|
||||||
]
|
]
|
||||||
@@ -799,9 +776,6 @@ def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]:
|
|||||||
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
|
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
|
||||||
bucket_date = local_dt.date()
|
bucket_date = local_dt.date()
|
||||||
return bucket_date.isoformat(), bucket_date
|
return bucket_date.isoformat(), bucket_date
|
||||||
if filters.language == "fa":
|
|
||||||
persian_date = jdatetime.date.fromgregorian(date=local_dt.date())
|
|
||||||
return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.date()
|
|
||||||
bucket_date = date(local_dt.year, local_dt.month, 1)
|
bucket_date = date(local_dt.year, local_dt.month, 1)
|
||||||
return bucket_date.strftime("%Y-%m"), bucket_date
|
return bucket_date.strftime("%Y-%m"), bucket_date
|
||||||
|
|
||||||
@@ -1045,11 +1019,18 @@ def build_table_report(actor, raw_filters) -> dict:
|
|||||||
payload = _table_report_payload(
|
payload = _table_report_payload(
|
||||||
filters,
|
filters,
|
||||||
entries,
|
entries,
|
||||||
user_summaries=_build_user_summaries(entries, language=filters.language),
|
user_summaries=_build_user_summaries(entries, filters=filters),
|
||||||
)
|
)
|
||||||
return payload
|
return payload
|
||||||
user_summary = (
|
user_summary = (
|
||||||
_build_user_summary(entries[0].user, entries, language=filters.language)
|
_build_user_summary(
|
||||||
|
entries[0].user,
|
||||||
|
entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
|
language=filters.language,
|
||||||
|
)
|
||||||
if entries and filters.user_id
|
if entries and filters.user_id
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -1063,7 +1044,14 @@ def build_user_summary_report(actor, raw_filters) -> dict:
|
|||||||
|
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
user_summary = (
|
user_summary = (
|
||||||
_build_user_summary(entries[0].user, entries, language=filters.language)
|
_build_user_summary(
|
||||||
|
entries[0].user,
|
||||||
|
entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
|
language=filters.language,
|
||||||
|
)
|
||||||
if entries
|
if entries
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
@@ -1095,6 +1083,9 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
|||||||
user_summary=_build_user_summary(
|
user_summary=_build_user_summary(
|
||||||
user_entries[0].user,
|
user_entries[0].user,
|
||||||
user_entries,
|
user_entries,
|
||||||
|
workspace=filters.workspace,
|
||||||
|
from_date=filters.from_date,
|
||||||
|
to_date=filters.to_date,
|
||||||
language=filters.language,
|
language=filters.language,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ class ExportLocale:
|
|||||||
|
|
||||||
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
|
||||||
if not income_totals:
|
if not income_totals:
|
||||||
return "-"
|
return self.format_number("0", ascii_digits=ascii_digits)
|
||||||
parts = []
|
parts = []
|
||||||
for item in income_totals:
|
for item in income_totals:
|
||||||
currency = self.currency_label(item["currency"])
|
currency = self.currency_label(item["currency"])
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
|
|||||||
|
|
||||||
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
|
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
|
||||||
if not rates:
|
if not rates:
|
||||||
return "-"
|
return locale.format_number("0", ascii_digits=ascii_digits)
|
||||||
items = [
|
items = [
|
||||||
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
|
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
|
||||||
for rate in rates
|
for rate in rates
|
||||||
@@ -103,13 +103,13 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool =
|
|||||||
|
|
||||||
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
|
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
|
||||||
if not rate:
|
if not rate:
|
||||||
return "-"
|
return locale.format_number("0")
|
||||||
return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}"
|
return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}"
|
||||||
|
|
||||||
|
|
||||||
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
|
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
|
||||||
if not rate:
|
if not rate:
|
||||||
return "-"
|
return locale.format_number("0", ascii_digits=True)
|
||||||
value = (
|
value = (
|
||||||
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
|
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
|
||||||
f"{locale.currency_label(rate['currency'])}"
|
f"{locale.currency_label(rate['currency'])}"
|
||||||
@@ -213,7 +213,14 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary:
|
|||||||
worksheet.append(row)
|
worksheet.append(row)
|
||||||
|
|
||||||
|
|
||||||
def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, *, ascii_digits: bool = False) -> str:
|
def _percentage_display(
|
||||||
|
locale: ExportLocale,
|
||||||
|
rows: list[dict],
|
||||||
|
row_data: dict,
|
||||||
|
*,
|
||||||
|
ascii_digits: bool = False,
|
||||||
|
default: str = "0%",
|
||||||
|
) -> str:
|
||||||
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
|
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
|
||||||
row_name = row_data.get("name")
|
row_name = row_data.get("name")
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -222,7 +229,7 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
|
|||||||
return value
|
return value
|
||||||
if row_name and row["name"] == row_name:
|
if row_name and row["name"] == row_name:
|
||||||
return value
|
return value
|
||||||
return "-"
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _percentage_number(rows: list[dict] | None, row_data: dict) -> float:
|
def _percentage_number(rows: list[dict] | None, row_data: dict) -> float:
|
||||||
@@ -277,7 +284,7 @@ def _summary_breakdown_rows(
|
|||||||
[
|
[
|
||||||
row["name"],
|
row["name"],
|
||||||
_percentage_value(locale, row["percentage"], ascii_digits=True),
|
_percentage_value(locale, row["percentage"], ascii_digits=True),
|
||||||
_percentage_display(locale, income_rows, row, ascii_digits=True),
|
_percentage_display(locale, income_rows, row, ascii_digits=True, default="-"),
|
||||||
]
|
]
|
||||||
for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or ""))
|
for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or ""))
|
||||||
]
|
]
|
||||||
@@ -304,11 +311,11 @@ def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: b
|
|||||||
|
|
||||||
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
|
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
|
||||||
worksheet.append([])
|
worksheet.append([])
|
||||||
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3)
|
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=4)
|
||||||
header_row = worksheet.max_row + 1
|
header_row = worksheet.max_row + 1
|
||||||
worksheet.append(
|
worksheet.append(
|
||||||
_excel_table_row(
|
_excel_table_row(
|
||||||
[locale.t("hourly_rate"), locale.t("from"), locale.t("to")],
|
[locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for cell in worksheet[header_row]:
|
for cell in worksheet[header_row]:
|
||||||
@@ -327,6 +334,7 @@ def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_su
|
|||||||
_rate_period_label(locale, row, ascii_digits=True),
|
_rate_period_label(locale, row, ascii_digits=True),
|
||||||
locale.format_date(row["from_date"], ascii_digits=True),
|
locale.format_date(row["from_date"], ascii_digits=True),
|
||||||
_rate_to_label(locale, row.get("to_date"), ascii_digits=True),
|
_rate_to_label(locale, row.get("to_date"), ascii_digits=True),
|
||||||
|
row.get("project_name") or "-",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -562,7 +570,7 @@ def _append_breakdown_table(
|
|||||||
),
|
),
|
||||||
_money_label_excel(locale, row["income_totals"]),
|
_money_label_excel(locale, row["income_totals"]),
|
||||||
*(
|
*(
|
||||||
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True)]
|
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-")]
|
||||||
if hour_percentages is not None
|
if hour_percentages is not None
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
@@ -669,8 +677,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
|
|||||||
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
|
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
|
||||||
*(rate_rows[index] if index < len(rate_rows) else [None, None]),
|
*(rate_rows[index] if index < len(rate_rows) else [None, None]),
|
||||||
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
|
||||||
|
None,
|
||||||
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
|
*(client_rows[index] if index < len(client_rows) else [None, None, None]),
|
||||||
|
None,
|
||||||
*(project_rows[index] if index < len(project_rows) else [None, None, None]),
|
*(project_rows[index] if index < len(project_rows) else [None, None, None]),
|
||||||
|
None,
|
||||||
*(tag_rows[index] if index < len(tag_rows) else [None, None, None]),
|
*(tag_rows[index] if index < len(tag_rows) else [None, None, None]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -733,7 +744,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
worksheet,
|
worksheet,
|
||||||
row=15,
|
row=15,
|
||||||
start_col=1,
|
start_col=1,
|
||||||
end_col=15,
|
end_col=18,
|
||||||
value=locale.t("users_summary_sheet"),
|
value=locale.t("users_summary_sheet"),
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
@@ -744,12 +755,15 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
locale.t("hourly_rate"),
|
locale.t("hourly_rate"),
|
||||||
locale.t("period"),
|
locale.t("period"),
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
|
"",
|
||||||
locale.t("clients"),
|
locale.t("clients"),
|
||||||
locale.t("hour_percentage"),
|
locale.t("hour_percentage"),
|
||||||
locale.t("income_percentage"),
|
locale.t("income_percentage"),
|
||||||
|
"",
|
||||||
locale.t("projects"),
|
locale.t("projects"),
|
||||||
locale.t("hour_percentage"),
|
locale.t("hour_percentage"),
|
||||||
locale.t("income_percentage"),
|
locale.t("income_percentage"),
|
||||||
|
"",
|
||||||
locale.t("tags"),
|
locale.t("tags"),
|
||||||
locale.t("hour_percentage"),
|
locale.t("hour_percentage"),
|
||||||
locale.t("income_percentage"),
|
locale.t("income_percentage"),
|
||||||
@@ -784,19 +798,26 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=4, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=4, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
|
||||||
if len(client_rows) == 1:
|
if len(client_rows) == 1:
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True)
|
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
|
||||||
if len(project_rows) == 1:
|
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
|
if len(project_rows) == 1:
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
|
||||||
if len(tag_rows) == 1:
|
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True)
|
if len(tag_rows) == 1:
|
||||||
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
|
||||||
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=17, value_present=True)
|
||||||
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True)
|
||||||
current_row += span
|
current_row += span
|
||||||
|
|
||||||
|
for row_index in range(16, current_row):
|
||||||
|
for column_index in (7, 11, 15):
|
||||||
|
cell = worksheet.cell(row=row_index, column=column_index)
|
||||||
|
cell.value = None
|
||||||
|
cell.fill = PatternFill(fill_type=None)
|
||||||
|
cell.border = Border()
|
||||||
|
|
||||||
current_row += 2
|
current_row += 2
|
||||||
for title_key, rows, hour_percentages, income_percentages in (
|
for title_key, rows, hour_percentages, income_percentages in (
|
||||||
(
|
(
|
||||||
@@ -855,7 +876,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
||||||
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
|
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
|
||||||
_money_label_excel(locale, row["income_totals"]),
|
_money_label_excel(locale, row["income_totals"]),
|
||||||
_percentage_display(locale, income_percentages or [], row, ascii_digits=True),
|
_percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-"),
|
||||||
],
|
],
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
@@ -1121,7 +1142,7 @@ def _build_pdf_user_summary_table(locale: ExportLocale, summary: dict, doc_width
|
|||||||
def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table:
|
def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table:
|
||||||
rows = summary.get("rate_periods") or []
|
rows = summary.get("rate_periods") or []
|
||||||
data = [
|
data = [
|
||||||
_rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to")]),
|
_rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")]),
|
||||||
*(
|
*(
|
||||||
_rtl_row(
|
_rtl_row(
|
||||||
locale,
|
locale,
|
||||||
@@ -1129,14 +1150,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
|
|||||||
_rate_period_label(locale, row),
|
_rate_period_label(locale, row),
|
||||||
locale.format_date(row["from_date"]),
|
locale.format_date(row["from_date"]),
|
||||||
_rate_to_label(locale, row.get("to_date")),
|
_rate_to_label(locale, row.get("to_date")),
|
||||||
|
row.get("project_name") or "-",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
if not rows:
|
if not rows:
|
||||||
data.append(_rtl_row(locale, [locale.t("no_data"), "", ""]))
|
data.append(_rtl_row(locale, [locale.t("no_data"), "", "", ""]))
|
||||||
fixed_widths = [doc_width * 0.18, doc_width * 0.18]
|
fixed_widths = [doc_width * 0.18, doc_width * 0.18, doc_width * 0.24]
|
||||||
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||||
if locale.is_rtl:
|
if locale.is_rtl:
|
||||||
column_widths = list(reversed(column_widths))
|
column_widths = list(reversed(column_widths))
|
||||||
@@ -1227,7 +1249,7 @@ def _append_pdf_report_sections(
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
_money_label(locale, row["income_totals"]),
|
_money_label(locale, row["income_totals"]),
|
||||||
_percentage_display(locale, income_percentage_rows or [], row),
|
_percentage_display(locale, income_percentage_rows or [], row, default="-"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
for row in sorted_rows
|
for row in sorted_rows
|
||||||
|
|||||||
@@ -199,9 +199,9 @@ class ReportExporterTests(TestCase):
|
|||||||
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
|
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
|
||||||
self.assertEqual(summary_sheet["B1"].value, "Exports")
|
self.assertEqual(summary_sheet["B1"].value, "Exports")
|
||||||
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
|
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
|
||||||
self.assertIn("A15:O15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
self.assertIn("A15:R15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:15],
|
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18],
|
||||||
(
|
(
|
||||||
"Name",
|
"Name",
|
||||||
"Mobile",
|
"Mobile",
|
||||||
@@ -209,12 +209,15 @@ class ReportExporterTests(TestCase):
|
|||||||
"Hourly rate",
|
"Hourly rate",
|
||||||
"Period",
|
"Period",
|
||||||
"Income",
|
"Income",
|
||||||
|
None,
|
||||||
"Clients",
|
"Clients",
|
||||||
"Hour %",
|
"Hour %",
|
||||||
"Income %",
|
"Income %",
|
||||||
|
None,
|
||||||
"Projects",
|
"Projects",
|
||||||
"Hour %",
|
"Hour %",
|
||||||
"Income %",
|
"Income %",
|
||||||
|
None,
|
||||||
"Tags",
|
"Tags",
|
||||||
"Hour %",
|
"Hour %",
|
||||||
"Income %",
|
"Income %",
|
||||||
|
|||||||
78
apps/workspaces/migrations/0008_hourlyratehistory.py
Normal file
78
apps/workspaces/migrations/0008_hourlyratehistory.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-05-26 08:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_rate_history(apps, schema_editor):
|
||||||
|
HourlyRateHistory = apps.get_model('workspaces', 'HourlyRateHistory')
|
||||||
|
WorkspaceUserRate = apps.get_model('workspaces', 'WorkspaceUserRate')
|
||||||
|
ProjectUserRate = apps.get_model('projects', 'ProjectUserRate')
|
||||||
|
|
||||||
|
workspace_rows = [
|
||||||
|
HourlyRateHistory(
|
||||||
|
workspace_id=rate.workspace_id,
|
||||||
|
user_id=rate.user_id,
|
||||||
|
project_id=None,
|
||||||
|
scope='workspace',
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
for rate in WorkspaceUserRate.objects.filter(is_deleted=False)
|
||||||
|
]
|
||||||
|
project_rows = [
|
||||||
|
HourlyRateHistory(
|
||||||
|
workspace_id=rate.project.workspace_id,
|
||||||
|
user_id=rate.user_id,
|
||||||
|
project_id=rate.project_id,
|
||||||
|
scope='project',
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
for rate in ProjectUserRate.objects.select_related('project').filter(is_deleted=False)
|
||||||
|
]
|
||||||
|
HourlyRateHistory.objects.bulk_create(workspace_rows + project_rows, ignore_conflicts=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('projects', '0005_project_thumbnail'),
|
||||||
|
('workspaces', '0007_workspacemembership_membership_ws_active_user_idx'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HourlyRateHistory',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('is_active', models.BooleanField(default=False)),
|
||||||
|
('scope', models.CharField(choices=[('workspace', 'Workspace'), ('project', 'Project')], max_length=16)),
|
||||||
|
('hourly_rate', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||||
|
('currency', models.CharField(default='USD', max_length=3)),
|
||||||
|
('effective_from', models.DateTimeField()),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('project', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='hourly_rate_history', to='projects.project')),
|
||||||
|
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hourly_rate_history', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hourly_rate_history', to='workspaces.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'hourly_rate_history',
|
||||||
|
'ordering': ('effective_from', 'created_at'),
|
||||||
|
'indexes': [models.Index(fields=['workspace', 'user', 'effective_from'], name='hrh_ws_user_eff_idx'), models.Index(fields=['project', 'user', 'effective_from'], name='hrh_project_user_eff_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_rate_history, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -173,3 +173,53 @@ class WorkspaceUserRate(BaseModel):
|
|||||||
"currency": self.currency,
|
"currency": self.currency,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HourlyRateHistory(BaseModel):
|
||||||
|
class Scope(models.TextChoices):
|
||||||
|
WORKSPACE = "workspace", "Workspace"
|
||||||
|
PROJECT = "project", "Project"
|
||||||
|
|
||||||
|
workspace = models.ForeignKey(
|
||||||
|
Workspace,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="hourly_rate_history",
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="hourly_rate_history",
|
||||||
|
)
|
||||||
|
project = models.ForeignKey(
|
||||||
|
"projects.Project",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="hourly_rate_history",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
scope = models.CharField(max_length=16, choices=Scope.choices)
|
||||||
|
hourly_rate = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
currency = models.CharField(max_length=3, default="USD")
|
||||||
|
effective_from = models.DateTimeField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "hourly_rate_history"
|
||||||
|
ordering = ("effective_from", "created_at")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["workspace", "user", "effective_from"], name="hrh_ws_user_eff_idx"),
|
||||||
|
models.Index(fields=["project", "user", "effective_from"], name="hrh_project_user_eff_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_additional_data(self):
|
||||||
|
return build_workspace_log_metadata(
|
||||||
|
section=SECTION_RATES,
|
||||||
|
workspace_id=self.workspace_id,
|
||||||
|
target_id=self.id,
|
||||||
|
target_label=self.user.full_name or self.user.mobile,
|
||||||
|
extra={
|
||||||
|
"rate_user_id": str(self.user_id),
|
||||||
|
"project_id": str(self.project_id) if self.project_id else None,
|
||||||
|
"scope": self.scope,
|
||||||
|
"currency": self.currency,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.workspaces.models import WorkspaceUserRate
|
from apps.workspaces.models import HourlyRateHistory, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
|
def record_workspace_rate_history(*, workspace, user_id, hourly_rate, currency, effective_from=None):
|
||||||
|
currency = currency.upper()
|
||||||
|
effective_from = effective_from or timezone.now()
|
||||||
|
latest = (
|
||||||
|
HourlyRateHistory.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||||
|
project__isnull=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if latest and latest.hourly_rate == hourly_rate and latest.currency == currency:
|
||||||
|
return latest
|
||||||
|
return HourlyRateHistory.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
project=None,
|
||||||
|
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||||
|
hourly_rate=hourly_rate,
|
||||||
|
currency=currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
||||||
@@ -11,6 +39,7 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
|||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
effective_from = timezone.now()
|
||||||
if rate:
|
if rate:
|
||||||
update_fields = []
|
update_fields = []
|
||||||
if rate.hourly_rate != hourly_rate:
|
if rate.hourly_rate != hourly_rate:
|
||||||
@@ -25,16 +54,31 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
|
|||||||
if update_fields:
|
if update_fields:
|
||||||
update_fields.append("updated_at")
|
update_fields.append("updated_at")
|
||||||
rate.save(update_fields=update_fields)
|
rate.save(update_fields=update_fields)
|
||||||
|
record_workspace_rate_history(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
)
|
||||||
return rate
|
return rate
|
||||||
|
|
||||||
return WorkspaceUserRate.objects.create(
|
rate = WorkspaceUserRate.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
hourly_rate=hourly_rate,
|
hourly_rate=hourly_rate,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
effective_from=timezone.now(),
|
effective_from=effective_from,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
|
record_workspace_rate_history(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=rate.effective_from,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def update_workspace_user_rate(rate_instance, **kwargs):
|
def update_workspace_user_rate(rate_instance, **kwargs):
|
||||||
@@ -50,5 +94,12 @@ def update_workspace_user_rate(rate_instance, **kwargs):
|
|||||||
if update_fields:
|
if update_fields:
|
||||||
update_fields.append("updated_at")
|
update_fields.append("updated_at")
|
||||||
rate_instance.save(update_fields=update_fields)
|
rate_instance.save(update_fields=update_fields)
|
||||||
|
if {"hourly_rate", "currency", "is_active"} & set(update_fields):
|
||||||
|
record_workspace_rate_history(
|
||||||
|
workspace=rate_instance.workspace,
|
||||||
|
user_id=rate_instance.user_id,
|
||||||
|
hourly_rate=rate_instance.hourly_rate,
|
||||||
|
currency=rate_instance.currency,
|
||||||
|
)
|
||||||
|
|
||||||
return rate_instance
|
return rate_instance
|
||||||
|
|||||||
Reference in New Issue
Block a user