Compare commits
17 Commits
b79fd73403
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 027afb7e23 | |||
| 170ec90ec1 | |||
| 30a324c6f4 | |||
| da40720a0f | |||
| 948a8e1b75 | |||
| b5ddcb76aa | |||
| 20874b9968 | |||
| af9facce7e | |||
| e42e0612aa | |||
| f99e883f12 | |||
| d18fdb1454 | |||
| 5500badc6a | |||
| 2a0fa22be6 | |||
| 22e08a099c | |||
| 59cf62bc73 | |||
| 0d6c6a4f09 | |||
| 181a135df9 |
@@ -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
|
||||||
|
|||||||
1
apps/contacts/__init__.py
Normal file
1
apps/contacts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
56
apps/contacts/admin.py
Normal file
56
apps/contacts/admin.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ContactSubmission)
|
||||||
|
class ContactSubmissionAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"full_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"is_deleted",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Full name")
|
||||||
|
def full_name(self, obj):
|
||||||
|
return f"{obj.first_name} {obj.last_name}".strip()
|
||||||
1
apps/contacts/api/__init__.py
Normal file
1
apps/contacts/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
43
apps/contacts/api/serializers.py
Normal file
43
apps/contacts/api/serializers.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_mobile(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 8:
|
||||||
|
raise serializers.ValidationError("Enter a valid mobile number.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
def validate_message(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 10:
|
||||||
|
raise serializers.ValidationError("Message must be at least 10 characters.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionResponseSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
5
apps/contacts/api/throttles.py
Normal file
5
apps/contacts/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionThrottle(AnonRateThrottle):
|
||||||
|
scope = "contact_submission"
|
||||||
9
apps/contacts/api/urls.py
Normal file
9
apps/contacts/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.contacts.api.views import ContactSubmissionView
|
||||||
|
|
||||||
|
app_name = "contacts"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", ContactSubmissionView.as_view(), name="contact-submit"),
|
||||||
|
]
|
||||||
40
apps/contacts/api/views.py
Normal file
40
apps/contacts/api/views.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.contacts.api.serializers import (
|
||||||
|
ContactSubmissionCreateSerializer,
|
||||||
|
ContactSubmissionResponseSerializer,
|
||||||
|
)
|
||||||
|
from apps.contacts.api.throttles import ContactSubmissionThrottle
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request):
|
||||||
|
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if forwarded_for:
|
||||||
|
return forwarded_for.split(",")[0].strip()
|
||||||
|
return request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = (ContactSubmissionThrottle,)
|
||||||
|
serializer_class = ContactSubmissionCreateSerializer
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=ContactSubmissionCreateSerializer,
|
||||||
|
responses={201: ContactSubmissionResponseSerializer},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.serializer_class(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
submission = serializer.save(
|
||||||
|
ip_address=_get_client_ip(request),
|
||||||
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
ContactSubmissionResponseSerializer(submission).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
6
apps/contacts/apps.py
Normal file
6
apps/contacts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ContactsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.contacts"
|
||||||
85
apps/contacts/migrations/0001_initial.py
Normal file
85
apps/contacts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Generated manually for contact submissions.
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ContactSubmission",
|
||||||
|
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)),
|
||||||
|
("first_name", models.CharField(max_length=120)),
|
||||||
|
("last_name", models.CharField(max_length=120)),
|
||||||
|
("email", models.EmailField(max_length=254)),
|
||||||
|
("mobile", models.CharField(max_length=32)),
|
||||||
|
("message", models.TextField()),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("new", "New"),
|
||||||
|
("contacted", "Contacted"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("spam", "Spam"),
|
||||||
|
],
|
||||||
|
default="new",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
("user_agent", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "contact_submission",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["id"], name="contactsubmission_id_idx"),
|
||||||
|
models.Index(fields=["created_at"], name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=["status"], name="contact_status_idx"),
|
||||||
|
models.Index(fields=["email"], name="contact_email_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/contacts/migrations/__init__.py
Normal file
1
apps/contacts/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
36
apps/contacts/models.py
Normal file
36
apps/contacts/models.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmission(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
NEW = "new", "New"
|
||||||
|
CONTACTED = "contacted", "Contacted"
|
||||||
|
CLOSED = "closed", "Closed"
|
||||||
|
SPAM = "spam", "Spam"
|
||||||
|
|
||||||
|
first_name = models.CharField(max_length=120)
|
||||||
|
last_name = models.CharField(max_length=120)
|
||||||
|
email = models.EmailField()
|
||||||
|
mobile = models.CharField(max_length=32)
|
||||||
|
message = models.TextField()
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.NEW,
|
||||||
|
)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "contact_submission"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = (
|
||||||
|
models.Index(fields=("created_at",), name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=("status",), name="contact_status_idx"),
|
||||||
|
models.Index(fields=("email",), name="contact_email_idx"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name} - {self.email}"
|
||||||
1
apps/contacts/tests/__init__.py
Normal file
1
apps/contacts/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
44
apps/contacts/tests/test_api_views.py
Normal file
44
apps/contacts/tests/test_api_views.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionApiTests(APITestCase):
|
||||||
|
def test_public_user_can_submit_contact_form(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "I need help with Qlockify reports.",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
HTTP_X_FORWARDED_FOR="203.0.113.10",
|
||||||
|
HTTP_USER_AGENT="test-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
submission = ContactSubmission.objects.get()
|
||||||
|
self.assertEqual(submission.email, "amin@example.com")
|
||||||
|
self.assertEqual(submission.ip_address, "203.0.113.10")
|
||||||
|
self.assertEqual(submission.user_agent, "test-agent")
|
||||||
|
|
||||||
|
def test_rejects_short_message(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "Hi",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertFalse(ContactSubmission.objects.exists())
|
||||||
1
apps/demos/__init__.py
Normal file
1
apps/demos/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
apps/demos/api/__init__.py
Normal file
1
apps/demos/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
apps/demos/api/throttles.py
Normal file
5
apps/demos/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
|
||||||
|
|
||||||
|
class DemoStartThrottle(AnonRateThrottle):
|
||||||
|
scope = "demo_start"
|
||||||
9
apps/demos/api/urls.py
Normal file
9
apps/demos/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.demos.api.views import DemoStartView
|
||||||
|
|
||||||
|
app_name = "demos"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("start/", DemoStartView.as_view(), name="demo-start"),
|
||||||
|
]
|
||||||
29
apps/demos/api/views.py
Normal file
29
apps/demos/api/views.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.demos.api.throttles import DemoStartThrottle
|
||||||
|
from apps.demos.services import create_demo_environment
|
||||||
|
|
||||||
|
|
||||||
|
class DemoStartView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = (DemoStartThrottle,)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=None,
|
||||||
|
responses=inline_serializer(
|
||||||
|
name="DemoStartResponse",
|
||||||
|
fields={
|
||||||
|
"access": serializers.CharField(),
|
||||||
|
"refresh": serializers.CharField(),
|
||||||
|
"workspace_id": serializers.CharField(),
|
||||||
|
"expires_at": serializers.DateTimeField(),
|
||||||
|
"demo_environment_id": serializers.CharField(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
return Response(create_demo_environment(), status=status.HTTP_201_CREATED)
|
||||||
6
apps/demos/apps.py
Normal file
6
apps/demos/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DemosConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.demos"
|
||||||
1
apps/demos/management/__init__.py
Normal file
1
apps/demos/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
apps/demos/management/commands/__init__.py
Normal file
1
apps/demos/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Clean up expired isolated demo environments."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--expired", action="store_true", help="Clean expired demo environments.")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=None, help="Maximum number of environments to clean.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not options["expired"]:
|
||||||
|
self.stderr.write("Only --expired cleanup is supported.")
|
||||||
|
return
|
||||||
|
cleaned = cleanup_expired_demo_environments(batch_size=options["batch_size"])
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Cleaned {cleaned} expired demo environment(s)."))
|
||||||
97
apps/demos/migrations/0001_initial.py
Normal file
97
apps/demos/migrations/0001_initial.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("workspaces", "0008_hourlyratehistory"),
|
||||||
|
("users", "0004_user_demo_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DemoEnvironment",
|
||||||
|
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)),
|
||||||
|
("expires_at", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("active", "Active"), ("expired", "Expired"), ("cleaned", "Cleaned")],
|
||||||
|
default="active",
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("seed_version", models.CharField(default="v1", max_length=32)),
|
||||||
|
("cleaned_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("cleanup_error", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="created_demos_demoenvironment_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"owner_user",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="updated_demos_demoenvironment_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workspace",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
to="workspaces.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "demo_environment",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["id"], name="demoenvironment_id_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-06-06 21:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('demos', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='demoenvironment_id_idx',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='created_by',
|
||||||
|
field=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),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='updated_by',
|
||||||
|
field=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),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/demos/migrations/__init__.py
Normal file
1
apps/demos/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
39
apps/demos/models.py
Normal file
39
apps/demos/models.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DemoEnvironment(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
EXPIRED = "expired", "Expired"
|
||||||
|
CLEANED = "cleaned", "Cleaned"
|
||||||
|
|
||||||
|
owner_user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
)
|
||||||
|
workspace = models.OneToOneField(
|
||||||
|
"workspaces.Workspace",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
|
||||||
|
seed_version = models.CharField(max_length=32, default="v1")
|
||||||
|
cleaned_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
cleanup_error = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "demo_environment"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||||
|
models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||||
|
models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Demo {self.workspace_id} for {self.owner_user_id}"
|
||||||
273
apps/demos/services.py
Normal file
273
apps/demos/services.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.demos.models import DemoEnvironment
|
||||||
|
from apps.notifications.services import RedisNotificationStore
|
||||||
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
|
from apps.reports.models import ReportExportJob
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.users.services.auth import get_tokens_for_user
|
||||||
|
from apps.workspaces.models import HourlyRateHistory, PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
DEMO_SEED_VERSION = "v1"
|
||||||
|
DEMO_RATE_CURRENCY = "IRT"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_mobile(prefix: str) -> str:
|
||||||
|
for _ in range(50):
|
||||||
|
mobile = f"09{prefix}{''.join(random.choices(string.digits, k=7))}"
|
||||||
|
if not User.all_objects.filter(mobile=mobile).exists():
|
||||||
|
return mobile
|
||||||
|
raise ValidationError({"detail": "Could not allocate a unique demo mobile number."})
|
||||||
|
|
||||||
|
|
||||||
|
def _create_demo_user(*, prefix: str, first_name: str, last_name: str, expires_at):
|
||||||
|
mobile = _unique_mobile(prefix)
|
||||||
|
user = User.objects.create_user(
|
||||||
|
mobile=mobile,
|
||||||
|
password=None,
|
||||||
|
email=f"demo-{mobile}@demo.qlockify.local",
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
is_demo=True,
|
||||||
|
demo_expires_at=expires_at,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_price_units() -> None:
|
||||||
|
PriceUnit.get_or_restore(
|
||||||
|
code="IRT",
|
||||||
|
defaults={"name": "Iranian Toman", "local_name": "تومان", "symbol": "تومان", "is_active": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_workspace_rate(*, workspace, user, amount: str, effective_from):
|
||||||
|
rate = WorkspaceUserRate.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=Decimal(amount),
|
||||||
|
currency=DEMO_RATE_CURRENCY,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
HourlyRateHistory.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _create_project_rate(*, project, user, amount: str, effective_from):
|
||||||
|
rate = ProjectUserRate.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=Decimal(amount),
|
||||||
|
currency=DEMO_RATE_CURRENCY,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
HourlyRateHistory.objects.create(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.PROJECT,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _create_entry(*, workspace, user, project, tags, days_ago: int, hour: int, duration_hours: float, description: str, billable: bool):
|
||||||
|
start_time = timezone.now().replace(hour=hour, minute=0, second=0, microsecond=0) - timedelta(days=days_ago)
|
||||||
|
end_time = start_time + timedelta(hours=duration_hours)
|
||||||
|
rate = None
|
||||||
|
currency = DEMO_RATE_CURRENCY
|
||||||
|
if billable and project:
|
||||||
|
rate = (
|
||||||
|
ProjectUserRate.objects.filter(project=project, user=user, is_deleted=False, is_active=True)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not rate:
|
||||||
|
rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(workspace=workspace, user=user, is_deleted=False)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if rate:
|
||||||
|
currency = rate.currency
|
||||||
|
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
project=project,
|
||||||
|
description=description,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
duration=end_time - start_time,
|
||||||
|
is_billable=billable,
|
||||||
|
hourly_rate=rate.hourly_rate if rate else None,
|
||||||
|
currency=currency,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
entry.tags.set(tags)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_demo_environment():
|
||||||
|
if not getattr(settings, "DEMO_ENABLED", True):
|
||||||
|
raise ValidationError({"detail": "Demo environments are currently disabled."})
|
||||||
|
|
||||||
|
_ensure_price_units()
|
||||||
|
expires_at = timezone.now() + timedelta(hours=settings.DEMO_ENVIRONMENT_TTL_HOURS)
|
||||||
|
owner = _create_demo_user(prefix="70", first_name="Demo", last_name="Owner", expires_at=expires_at)
|
||||||
|
admin = _create_demo_user(prefix="71", first_name="Nika", last_name="Admin", expires_at=expires_at)
|
||||||
|
member = _create_demo_user(prefix="72", first_name="Arman", last_name="Member", expires_at=expires_at)
|
||||||
|
guest = _create_demo_user(prefix="73", first_name="Sara", last_name="Guest", expires_at=expires_at)
|
||||||
|
|
||||||
|
workspace = Workspace.objects.create(
|
||||||
|
name="Qlockify Demo Workspace",
|
||||||
|
description="A temporary sandbox workspace with seeded data for exploring Qlockify.",
|
||||||
|
owner=owner,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.bulk_create(
|
||||||
|
[
|
||||||
|
WorkspaceMembership(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True),
|
||||||
|
WorkspaceMembership(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True),
|
||||||
|
WorkspaceMembership(workspace=workspace, user=guest, role=WorkspaceMembership.Role.GUEST, is_active=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
for user, amount in ((owner, "750000"), (admin, "650000"), (member, "520000"), (guest, "350000")):
|
||||||
|
_create_workspace_rate(workspace=workspace, user=user, amount=amount, effective_from=now - timedelta(days=60))
|
||||||
|
|
||||||
|
college = Client.objects.create(workspace=workspace, name="Kanoon College", notes="Education client", is_active=True)
|
||||||
|
studio = Client.objects.create(workspace=workspace, name="Nova Studio", notes="Design and product client", is_active=True)
|
||||||
|
internal = Client.objects.create(workspace=workspace, name="Internal Ops", notes="Non-client internal work", is_active=True)
|
||||||
|
|
||||||
|
projects = {
|
||||||
|
"portal": Project.objects.create(workspace=workspace, client=college, name="Student Portal", color="#0891b2", is_active=True),
|
||||||
|
"bootcamp": Project.objects.create(workspace=workspace, client=college, name="Bootcamp Analytics", color="#14b8a6", is_active=True),
|
||||||
|
"brand": Project.objects.create(workspace=workspace, client=studio, name="Brand Refresh", color="#f97316", is_active=True),
|
||||||
|
"ops": Project.objects.create(workspace=workspace, client=internal, name="Operations Automation", color="#6366f1", is_active=True),
|
||||||
|
"archive": Project.objects.create(workspace=workspace, client=studio, name="Archived Campaign", color="#94a3b8", is_archived=True, is_active=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"design": Tag.objects.create(workspace=workspace, name="Design", color="#f97316", is_active=True),
|
||||||
|
"backend": Tag.objects.create(workspace=workspace, name="Backend", color="#0ea5e9", is_active=True),
|
||||||
|
"meeting": Tag.objects.create(workspace=workspace, name="Meeting", color="#8b5cf6", is_active=True),
|
||||||
|
"qa": Tag.objects.create(workspace=workspace, name="QA", color="#22c55e", is_active=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in (member, guest):
|
||||||
|
for project in (projects["portal"], projects["bootcamp"], projects["brand"]):
|
||||||
|
ProjectAccess.objects.create(project=project, user=user, is_active=True)
|
||||||
|
|
||||||
|
_create_project_rate(project=projects["brand"], user=owner, amount="950000", effective_from=now - timedelta(days=30))
|
||||||
|
_create_project_rate(project=projects["portal"], user=member, amount="610000", effective_from=now - timedelta(days=20))
|
||||||
|
_create_project_rate(project=projects["bootcamp"], user=guest, amount="420000", effective_from=now - timedelta(days=15))
|
||||||
|
|
||||||
|
entry_templates = [
|
||||||
|
(owner, projects["brand"], [tags["design"]], 1, 9, 2.5, "Review landing page motion", True),
|
||||||
|
(owner, projects["ops"], [tags["backend"], tags["qa"]], 2, 10, 3.0, "Improve export pipeline", True),
|
||||||
|
(owner, None, [tags["meeting"]], 3, 13, 1.0, "Weekly planning", False),
|
||||||
|
(admin, projects["portal"], [tags["backend"]], 1, 8, 4.0, "API access checks", True),
|
||||||
|
(admin, projects["bootcamp"], [tags["qa"]], 4, 11, 2.0, "Report QA pass", True),
|
||||||
|
(member, projects["portal"], [tags["backend"], tags["qa"]], 2, 9, 5.0, "Timesheet improvements", True),
|
||||||
|
(member, projects["brand"], [tags["design"]], 6, 14, 2.5, "Design polish", True),
|
||||||
|
(guest, projects["bootcamp"], [tags["meeting"]], 3, 10, 1.5, "Client sync", True),
|
||||||
|
(guest, None, [], 5, 15, 1.0, "Uncategorized admin work", False),
|
||||||
|
]
|
||||||
|
for entry in entry_templates:
|
||||||
|
_create_entry(workspace=workspace, user=entry[0], project=entry[1], tags=entry[2], days_ago=entry[3], hour=entry[4], duration_hours=entry[5], description=entry[6], billable=entry[7])
|
||||||
|
|
||||||
|
environment = DemoEnvironment.objects.create(
|
||||||
|
owner_user=owner,
|
||||||
|
workspace=workspace,
|
||||||
|
expires_at=expires_at,
|
||||||
|
seed_version=DEMO_SEED_VERSION,
|
||||||
|
status=DemoEnvironment.Status.ACTIVE,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
tokens = get_tokens_for_user(owner)
|
||||||
|
return {
|
||||||
|
**tokens,
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"demo_environment_id": str(environment.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_demo_environment(environment: DemoEnvironment) -> bool:
|
||||||
|
workspace = environment.workspace
|
||||||
|
users = list(
|
||||||
|
User.all_objects.filter(
|
||||||
|
is_demo=True,
|
||||||
|
workspace_memberships__workspace=workspace,
|
||||||
|
).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
for job in ReportExportJob.all_objects.filter(workspace=workspace):
|
||||||
|
if job.file:
|
||||||
|
job.file.delete(save=False)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
RedisNotificationStore.clear_user(str(user.id))
|
||||||
|
|
||||||
|
workspace.hard_delete()
|
||||||
|
for user in users:
|
||||||
|
user.hard_delete()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_demo_environments(*, batch_size: int | None = None) -> int:
|
||||||
|
batch_size = batch_size or settings.DEMO_CLEANUP_BATCH_SIZE
|
||||||
|
expired = list(
|
||||||
|
DemoEnvironment.objects.filter(
|
||||||
|
status=DemoEnvironment.Status.ACTIVE,
|
||||||
|
expires_at__lte=timezone.now(),
|
||||||
|
)
|
||||||
|
.select_related("workspace", "owner_user")
|
||||||
|
.order_by("expires_at")[:batch_size]
|
||||||
|
)
|
||||||
|
cleaned = 0
|
||||||
|
for environment in expired:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
cleanup_demo_environment(environment)
|
||||||
|
cleaned += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
DemoEnvironment.all_objects.filter(id=environment.id).update(
|
||||||
|
status=DemoEnvironment.Status.EXPIRED,
|
||||||
|
cleanup_error=str(exc)[:2000],
|
||||||
|
updated_at=timezone.now(),
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
8
apps/demos/tasks.py
Normal file
8
apps/demos/tasks.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="demos.cleanup_expired_environments")
|
||||||
|
def cleanup_expired_demo_environments_task():
|
||||||
|
return cleanup_expired_demo_environments()
|
||||||
1
apps/demos/tests/__init__.py
Normal file
1
apps/demos/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
77
apps/demos/tests/test_demo_api.py
Normal file
77
apps/demos/tests/test_demo_api.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.demos.models import DemoEnvironment
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
from apps.projects.models import Project, ProjectAccess
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.workspaces.models import WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
DEMO_START_URL = "/api/demo/start/"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEMO_ENABLED=True, DEMO_ENVIRONMENT_TTL_HOURS=24, DEMO_CLEANUP_BATCH_SIZE=100)
|
||||||
|
class DemoStartApiTests(APITestCase):
|
||||||
|
def test_demo_start_creates_isolated_seeded_environment(self):
|
||||||
|
response = self.client.post(DEMO_START_URL)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
self.assertIn("refresh", response.data)
|
||||||
|
self.assertEqual(DemoEnvironment.objects.count(), 1)
|
||||||
|
|
||||||
|
environment = DemoEnvironment.objects.select_related("owner_user", "workspace").get()
|
||||||
|
self.assertTrue(environment.owner_user.is_demo)
|
||||||
|
self.assertEqual(environment.owner_user.demo_expires_at, environment.expires_at)
|
||||||
|
self.assertGreaterEqual(WorkspaceMembership.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(Client.objects.filter(workspace=environment.workspace).count(), 3)
|
||||||
|
self.assertGreaterEqual(Project.objects.filter(workspace=environment.workspace).count(), 5)
|
||||||
|
self.assertGreaterEqual(Tag.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(TimeEntry.objects.filter(workspace=environment.workspace).count(), 8)
|
||||||
|
self.assertGreaterEqual(WorkspaceUserRate.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(ProjectAccess.objects.filter(project__workspace=environment.workspace).count(), 1)
|
||||||
|
|
||||||
|
def test_two_demo_starts_do_not_share_workspace_data(self):
|
||||||
|
first = self.client.post(DEMO_START_URL)
|
||||||
|
second = self.client.post(DEMO_START_URL)
|
||||||
|
|
||||||
|
self.assertEqual(first.status_code, 201)
|
||||||
|
self.assertEqual(second.status_code, 201)
|
||||||
|
environments = list(DemoEnvironment.objects.order_by("created_at"))
|
||||||
|
self.assertEqual(len(environments), 2)
|
||||||
|
self.assertNotEqual(environments[0].workspace_id, environments[1].workspace_id)
|
||||||
|
self.assertNotEqual(environments[0].owner_user_id, environments[1].owner_user_id)
|
||||||
|
|
||||||
|
def test_demo_user_cannot_search_external_users_or_send_otp(self):
|
||||||
|
self.client.post(DEMO_START_URL)
|
||||||
|
environment = DemoEnvironment.objects.select_related("owner_user").get()
|
||||||
|
real_user = User.objects.create_user(mobile="09111111111", password="Testpass123!")
|
||||||
|
self.client.force_authenticate(environment.owner_user)
|
||||||
|
|
||||||
|
search_response = self.client.get(f"/api/users/search/?mobile={real_user.mobile}")
|
||||||
|
self.assertEqual(search_response.status_code, 403)
|
||||||
|
|
||||||
|
otp_response = self.client.post(
|
||||||
|
"/api/users/otp/send/",
|
||||||
|
{"mobile": environment.owner_user.mobile, "mode": "login"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(otp_response.status_code, 400)
|
||||||
|
|
||||||
|
def test_cleanup_deletes_expired_demo_and_keeps_real_users(self):
|
||||||
|
self.client.post(DEMO_START_URL)
|
||||||
|
environment = DemoEnvironment.objects.select_related("workspace").get()
|
||||||
|
real_user = User.objects.create_user(mobile="09122222222", password="Testpass123!")
|
||||||
|
DemoEnvironment.objects.filter(id=environment.id).update(expires_at=timezone.now() - timezone.timedelta(minutes=1))
|
||||||
|
|
||||||
|
cleaned = cleanup_expired_demo_environments()
|
||||||
|
|
||||||
|
self.assertEqual(cleaned, 1)
|
||||||
|
self.assertFalse(DemoEnvironment.all_objects.filter(id=environment.id).exists())
|
||||||
|
self.assertFalse(TimeEntry.all_objects.filter(workspace_id=environment.workspace_id).exists())
|
||||||
|
self.assertTrue(User.objects.filter(id=real_user.id).exists())
|
||||||
@@ -205,6 +205,18 @@ class RedisNotificationStore:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_user(cls, user_id: str) -> int:
|
||||||
|
ids_key = cls._ids_key(user_id)
|
||||||
|
data_key = cls._data_key(user_id)
|
||||||
|
count = redis_client.zcard(ids_key)
|
||||||
|
pipe = redis_client.pipeline()
|
||||||
|
pipe.delete(ids_key)
|
||||||
|
pipe.delete(data_key)
|
||||||
|
pipe.srem(cls.USERS_KEY, user_id)
|
||||||
|
pipe.execute()
|
||||||
|
return int(count or 0)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
|
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
|
||||||
data = cls.get(user_id, notif_id)
|
data = cls.get(user_id, notif_id)
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.projects.models import Project
|
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 ProjectSerializer(BaseModelSerializer):
|
||||||
@@ -11,6 +27,7 @@ class ProjectSerializer(BaseModelSerializer):
|
|||||||
"name",
|
"name",
|
||||||
"client",
|
"client",
|
||||||
"description",
|
"description",
|
||||||
|
"thumbnail",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
"color",
|
"color",
|
||||||
)
|
)
|
||||||
@@ -18,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):
|
||||||
@@ -31,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()
|
||||||
@@ -54,3 +93,23 @@ class ProjectAccessMutationSerializer(serializers.Serializer):
|
|||||||
child=serializers.UUIDField(),
|
child=serializers.UUIDField(),
|
||||||
allow_empty=False,
|
allow_empty=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectAccessRateMutationSerializer(serializers.Serializer):
|
||||||
|
workspace = serializers.UUIDField()
|
||||||
|
user = serializers.UUIDField()
|
||||||
|
project = serializers.UUIDField()
|
||||||
|
hourly_rate = serializers.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
min_value=Decimal("0.01"),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
currency = serializers.CharField(max_length=3, required=False, default="USD")
|
||||||
|
|
||||||
|
def validate_currency(self, value):
|
||||||
|
code = value.upper()
|
||||||
|
if not PriceUnit.objects.filter(code=code, is_deleted=False).exists():
|
||||||
|
raise serializers.ValidationError("Selected price unit is invalid.")
|
||||||
|
return code
|
||||||
|
|||||||
@@ -16,16 +16,20 @@ from apps.projects.models import Project
|
|||||||
from apps.projects.api.serializers import (
|
from apps.projects.api.serializers import (
|
||||||
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
|
||||||
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
|
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
|
||||||
|
ProjectAccessRateMutationSerializer,
|
||||||
)
|
)
|
||||||
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
|
||||||
from apps.projects.services.access import (
|
from apps.projects.services.access import (
|
||||||
|
build_project_access_item,
|
||||||
build_project_access_items,
|
build_project_access_items,
|
||||||
ensure_workspace_project_access,
|
ensure_workspace_project_access,
|
||||||
filter_projects_for_user,
|
filter_projects_for_user,
|
||||||
get_access_managed_membership,
|
get_project_access_target_membership,
|
||||||
grant_project_accesses,
|
grant_project_accesses,
|
||||||
revoke_project_accesses,
|
revoke_project_accesses,
|
||||||
|
user_has_project_access,
|
||||||
)
|
)
|
||||||
|
from apps.projects.services.rates import get_current_project_user_rate, remove_project_user_rate, upsert_project_user_rate
|
||||||
from apps.projects.services.projects import (
|
from apps.projects.services.projects import (
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
@@ -85,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):
|
||||||
@@ -116,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):
|
||||||
@@ -140,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):
|
||||||
@@ -165,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")
|
||||||
@@ -179,7 +187,7 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
ensure_workspace_project_access(request.user, workspace)
|
ensure_workspace_project_access(request.user, workspace)
|
||||||
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@@ -204,7 +212,7 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
id=serializer.validated_data["workspace"],
|
id=serializer.validated_data["workspace"],
|
||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
changed = grant_project_accesses(
|
changed = grant_project_accesses(
|
||||||
actor=request.user,
|
actor=request.user,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -223,7 +231,7 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
id=serializer.validated_data["workspace"],
|
id=serializer.validated_data["workspace"],
|
||||||
is_deleted=False,
|
is_deleted=False,
|
||||||
)
|
)
|
||||||
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
changed = revoke_project_accesses(
|
changed = revoke_project_accesses(
|
||||||
actor=request.user,
|
actor=request.user,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
@@ -231,3 +239,54 @@ class ProjectViewSet(ModelViewSet):
|
|||||||
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
|
||||||
)
|
)
|
||||||
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
return Response({"changed": changed}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="access/rate")
|
||||||
|
def set_access_rate(self, request):
|
||||||
|
serializer = ProjectAccessRateMutationSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(
|
||||||
|
Workspace,
|
||||||
|
id=serializer.validated_data["workspace"],
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
ensure_workspace_project_access(request.user, workspace)
|
||||||
|
membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
|
||||||
|
project = get_object_or_404(
|
||||||
|
Project,
|
||||||
|
id=serializer.validated_data["project"],
|
||||||
|
workspace=workspace,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
has_access = user_has_project_access(membership.user, project)
|
||||||
|
if not has_access:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Grant project access before setting a project-specific rate."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
removed = serializer.validated_data.get("hourly_rate") is None
|
||||||
|
if removed:
|
||||||
|
remove_project_user_rate(project=project, user=membership.user)
|
||||||
|
else:
|
||||||
|
upsert_project_user_rate(
|
||||||
|
project=project,
|
||||||
|
user=membership.user,
|
||||||
|
hourly_rate=serializer.validated_data["hourly_rate"],
|
||||||
|
currency=serializer.validated_data.get("currency", "USD"),
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace_rate = (
|
||||||
|
workspace.user_rates.filter(user=membership.user, is_deleted=False)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
project_rate = get_current_project_user_rate(project=project, user=membership.user)
|
||||||
|
item = build_project_access_item(
|
||||||
|
project=project,
|
||||||
|
has_access=True,
|
||||||
|
workspace_rate=workspace_rate,
|
||||||
|
project_rate=project_rate,
|
||||||
|
)
|
||||||
|
return Response({"removed": removed, "item": item}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
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)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from django.db.models import Q, QuerySet
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
|
|
||||||
from apps.projects.models import Project, ProjectAccess
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
|
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -66,7 +66,7 @@ def ensure_workspace_project_access(user, workspace: Workspace) -> None:
|
|||||||
raise PermissionDenied("You do not have permission to manage project access in this workspace.")
|
raise PermissionDenied("You do not have permission to manage project access in this workspace.")
|
||||||
|
|
||||||
|
|
||||||
def get_access_managed_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership:
|
def get_project_access_target_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership:
|
||||||
membership = WorkspaceMembership.objects.filter(
|
membership = WorkspaceMembership.objects.filter(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -75,41 +75,88 @@ def get_access_managed_membership(workspace: Workspace, user_id: str) -> Workspa
|
|||||||
).select_related("user").first()
|
).select_related("user").first()
|
||||||
if not membership:
|
if not membership:
|
||||||
raise ValidationError({"user": "Selected user is not an active member of this workspace."})
|
raise ValidationError({"user": "Selected user is not an active member of this workspace."})
|
||||||
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
|
|
||||||
raise ValidationError({"user": "Owners and admins have implicit access to all projects."})
|
|
||||||
return membership
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_rate(rate) -> dict | None:
|
||||||
|
if not rate:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": str(rate.id),
|
||||||
|
"hourly_rate": str(rate.hourly_rate),
|
||||||
|
"currency": rate.currency,
|
||||||
|
"effective_from": rate.effective_from.isoformat() if rate.effective_from else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_project_access_item(*, project: Project, has_access: bool, workspace_rate, project_rate) -> dict:
|
||||||
|
return {
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"description": project.description,
|
||||||
|
"color": project.color,
|
||||||
|
"is_archived": project.is_archived,
|
||||||
|
"client": (
|
||||||
|
{"id": str(project.client_id), "name": project.client.name}
|
||||||
|
if project.client_id and project.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"has_access": has_access,
|
||||||
|
"workspace_rate": serialize_rate(workspace_rate),
|
||||||
|
"project_rate": serialize_rate(project_rate),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]:
|
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]:
|
||||||
explicit_access_ids = set(
|
explicit_access_ids = {
|
||||||
ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True)
|
str(project_id)
|
||||||
|
for project_id in ProjectAccess.objects.filter(
|
||||||
|
project__workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
).values_list("project_id", flat=True)
|
||||||
|
}
|
||||||
|
workspace_rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
|
project_rates: dict[str, ProjectUserRate] = {}
|
||||||
|
for rate in (
|
||||||
|
ProjectUserRate.objects.filter(
|
||||||
|
project__workspace=workspace,
|
||||||
|
user=target_user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.select_related("project")
|
||||||
|
.order_by("project_id", "-effective_from", "-updated_at")
|
||||||
|
):
|
||||||
|
project_rates.setdefault(str(rate.project_id), rate)
|
||||||
projects = (
|
projects = (
|
||||||
Project.objects.filter(workspace=workspace, is_deleted=False)
|
Project.objects.filter(workspace=workspace, is_deleted=False)
|
||||||
.select_related("client")
|
.select_related("client")
|
||||||
.order_by("client__name", "name")
|
.order_by("client__name", "name")
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
{
|
build_project_access_item(
|
||||||
"id": str(project.id),
|
project=project,
|
||||||
"name": project.name,
|
has_access=user_has_project_access(target_user, project) if user_has_implicit_project_access(target_user, workspace) else str(project.id) in explicit_access_ids,
|
||||||
"description": project.description,
|
workspace_rate=workspace_rate,
|
||||||
"color": project.color,
|
project_rate=project_rates.get(str(project.id)),
|
||||||
"is_archived": project.is_archived,
|
)
|
||||||
"client": (
|
|
||||||
{"id": str(project.client_id), "name": project.client.name}
|
|
||||||
if project.client_id and project.client
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"has_access": str(project.id) in {str(project_id) for project_id in explicit_access_ids},
|
|
||||||
}
|
|
||||||
for project in projects
|
for project in projects
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||||
ensure_workspace_project_access(actor, workspace)
|
ensure_workspace_project_access(actor, workspace)
|
||||||
get_access_managed_membership(workspace, str(target_user.id))
|
membership = get_project_access_target_membership(workspace, str(target_user.id))
|
||||||
|
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
|
||||||
|
raise ValidationError({"user": "Owners and admins already have access to all projects."})
|
||||||
|
|
||||||
projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False))
|
projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False))
|
||||||
if len(projects) != len(set(project_ids)):
|
if len(projects) != len(set(project_ids)):
|
||||||
@@ -128,7 +175,9 @@ def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_
|
|||||||
|
|
||||||
def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
|
||||||
ensure_workspace_project_access(actor, workspace)
|
ensure_workspace_project_access(actor, workspace)
|
||||||
get_access_managed_membership(workspace, str(target_user.id))
|
membership = get_project_access_target_membership(workspace, str(target_user.id))
|
||||||
|
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
|
||||||
|
raise ValidationError({"user": "Owners and admins always keep project access."})
|
||||||
|
|
||||||
accesses = list(
|
accesses = list(
|
||||||
ProjectAccess.objects.filter(
|
ProjectAccess.objects.filter(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
108
apps/projects/services/rates.py
Normal file
108
apps/projects/services/rates.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
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):
|
||||||
|
return (
|
||||||
|
ProjectUserRate.objects.filter(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
|
||||||
|
currency = currency.upper()
|
||||||
|
rate = (
|
||||||
|
ProjectUserRate.all_objects.filter(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
.order_by("-updated_at", "-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
effective_from = timezone.now()
|
||||||
|
if rate:
|
||||||
|
update_fields = []
|
||||||
|
if rate.is_deleted:
|
||||||
|
rate.restore()
|
||||||
|
if rate.hourly_rate != hourly_rate:
|
||||||
|
rate.hourly_rate = hourly_rate
|
||||||
|
update_fields.append("hourly_rate")
|
||||||
|
if rate.currency != currency:
|
||||||
|
rate.currency = currency
|
||||||
|
update_fields.append("currency")
|
||||||
|
if not rate.is_active:
|
||||||
|
rate.is_active = True
|
||||||
|
update_fields.append("is_active")
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
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
|
||||||
|
|
||||||
|
rate = ProjectUserRate.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=hourly_rate,
|
||||||
|
currency=currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
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):
|
||||||
|
rate = get_current_project_user_rate(project=project, user=user)
|
||||||
|
if not rate:
|
||||||
|
return False
|
||||||
|
rate.delete()
|
||||||
|
return True
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.clients.models import Client
|
from apps.clients.models import Client
|
||||||
from apps.projects.models import Project, ProjectAccess
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
class ProjectViewTests(APITestCase):
|
class ProjectViewTests(APITestCase):
|
||||||
@@ -15,6 +17,7 @@ class ProjectViewTests(APITestCase):
|
|||||||
first_name="Owner",
|
first_name="Owner",
|
||||||
)
|
)
|
||||||
cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner)
|
cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner)
|
||||||
|
PriceUnit.objects.create(code="USD", name="US Dollar", local_name="Dollar", symbol="$")
|
||||||
cls.member = User.objects.create_user(
|
cls.member = User.objects.create_user(
|
||||||
mobile="09121110002",
|
mobile="09121110002",
|
||||||
password="secret123",
|
password="secret123",
|
||||||
@@ -47,6 +50,14 @@ class ProjectViewTests(APITestCase):
|
|||||||
cls.first_project = Project.objects.get(name="Alpha")
|
cls.first_project = Project.objects.get(name="Alpha")
|
||||||
ProjectAccess.objects.create(project=cls.first_project, user=cls.member)
|
ProjectAccess.objects.create(project=cls.first_project, user=cls.member)
|
||||||
ProjectAccess.objects.create(project=cls.second_project, user=cls.member)
|
ProjectAccess.objects.create(project=cls.second_project, user=cls.member)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
hourly_rate=Decimal("25.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=cls.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_project_list_supports_multi_client_filter(self):
|
def test_project_list_supports_multi_client_filter(self):
|
||||||
self.client.force_authenticate(user=self.member)
|
self.client.force_authenticate(user=self.member)
|
||||||
@@ -84,6 +95,9 @@ class ProjectViewTests(APITestCase):
|
|||||||
items = access_response.data["items"]
|
items = access_response.data["items"]
|
||||||
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
|
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
|
||||||
self.assertFalse(gamma_item["has_access"])
|
self.assertFalse(gamma_item["has_access"])
|
||||||
|
alpha_item = next(item for item in items if item["id"] == str(self.first_project.id))
|
||||||
|
self.assertEqual(alpha_item["workspace_rate"]["hourly_rate"], "25.00")
|
||||||
|
self.assertIsNone(alpha_item["project_rate"])
|
||||||
|
|
||||||
grant_response = self.client.post(
|
grant_response = self.client.post(
|
||||||
"/api/projects/access/grant/",
|
"/api/projects/access/grant/",
|
||||||
@@ -114,3 +128,92 @@ class ProjectViewTests(APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(revoke_response.status_code, 200)
|
self.assertEqual(revoke_response.status_code, 200)
|
||||||
self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists())
|
self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists())
|
||||||
|
|
||||||
|
def test_project_access_rate_endpoint_saves_override_and_keeps_it_dormant_after_revoke(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
save_response = self.client.post(
|
||||||
|
"/api/projects/access/rate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project": str(self.first_project.id),
|
||||||
|
"hourly_rate": "44.50",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(save_response.status_code, 200)
|
||||||
|
self.assertFalse(save_response.data["removed"])
|
||||||
|
self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "44.50")
|
||||||
|
self.assertTrue(
|
||||||
|
ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
revoke_response = self.client.post(
|
||||||
|
"/api/projects/access/revoke/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project_ids": [str(self.first_project.id)],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(revoke_response.status_code, 200)
|
||||||
|
self.assertTrue(
|
||||||
|
ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
|
||||||
|
)
|
||||||
|
self.assertEqual(access_response.status_code, 200)
|
||||||
|
alpha_item = next(item for item in access_response.data["items"] if item["id"] == str(self.first_project.id))
|
||||||
|
self.assertFalse(alpha_item["has_access"])
|
||||||
|
self.assertEqual(alpha_item["project_rate"]["hourly_rate"], "44.50")
|
||||||
|
|
||||||
|
def test_project_access_rate_endpoint_rejects_projects_without_access(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/projects/access/rate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.member.id),
|
||||||
|
"project": str(self.third_project.id),
|
||||||
|
"hourly_rate": "44.50",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertIn("Grant project access", response.data["detail"])
|
||||||
|
|
||||||
|
def test_owner_access_state_marks_all_projects_as_accessible_and_allows_project_rate_override(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
access_response = self.client.get(
|
||||||
|
"/api/projects/access/",
|
||||||
|
{"workspace": str(self.workspace.id), "user": str(self.owner.id)},
|
||||||
|
)
|
||||||
|
self.assertEqual(access_response.status_code, 200)
|
||||||
|
self.assertTrue(all(item["has_access"] for item in access_response.data["items"]))
|
||||||
|
|
||||||
|
save_response = self.client.post(
|
||||||
|
"/api/projects/access/rate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
"project": str(self.first_project.id),
|
||||||
|
"hourly_rate": "60.00",
|
||||||
|
"currency": "USD",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(save_response.status_code, 200)
|
||||||
|
self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "60.00")
|
||||||
|
self.assertTrue(ProjectUserRate.objects.filter(project=self.first_project, user=self.owner, is_deleted=False).exists())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from apps.reports.api.views import (
|
|||||||
ReportDayDetailsView,
|
ReportDayDetailsView,
|
||||||
ReportExportJobViewSet,
|
ReportExportJobViewSet,
|
||||||
ReportTableView,
|
ReportTableView,
|
||||||
|
ReportUserSummaryView,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -15,6 +16,6 @@ urlpatterns = [
|
|||||||
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
||||||
path("table/", ReportTableView.as_view(), name="report-table"),
|
path("table/", ReportTableView.as_view(), name="report-table"),
|
||||||
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
||||||
|
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from apps.reports.services import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
from apps.reports.tasks import generate_report_export_task
|
from apps.reports.tasks import generate_report_export_task
|
||||||
@@ -83,6 +84,24 @@ class ReportDayDetailsView(APIView):
|
|||||||
return Response(payload)
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportUserSummaryView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(responses=dict)
|
||||||
|
def get(self, request):
|
||||||
|
workspace_id = request.query_params.get("workspace")
|
||||||
|
payload = get_or_set_cache_payload(
|
||||||
|
CACHE_NAMESPACE_REPORTS,
|
||||||
|
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
|
||||||
|
builder=lambda: build_user_summary_report(request.user, request.query_params),
|
||||||
|
resource="user-summary",
|
||||||
|
user_id=request.user.id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
params=request.query_params,
|
||||||
|
)
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class ReportExportJobViewSet(
|
class ReportExportJobViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
build_user_scoped_table_reports,
|
build_user_scoped_table_reports,
|
||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,7 @@ __all__ = [
|
|||||||
"load_report_filters",
|
"load_report_filters",
|
||||||
"build_chart_report",
|
"build_chart_report",
|
||||||
"build_table_report",
|
"build_table_report",
|
||||||
|
"build_user_summary_report",
|
||||||
"build_user_scoped_table_reports",
|
"build_user_scoped_table_reports",
|
||||||
"build_day_details_report",
|
"build_day_details_report",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,7 +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.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()
|
||||||
@@ -52,9 +52,9 @@ UNCATEGORIZED_LABELS = {
|
|||||||
"tags": "No tag",
|
"tags": "No tag",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
"clients": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
|
"clients": "بدون مشتری",
|
||||||
"projects": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
|
"projects": "بدون پروژه",
|
||||||
"tags": "\u0628\u062f\u0648\u0646 \u062a\u06af",
|
"tags": "بدون تگ",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,73 +110,83 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _serialize_distinct_rates(entries: list[TimeEntry]) -> list[dict]:
|
def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
|
||||||
unique_rates: set[tuple[str, str]] = set()
|
unique_rates: set[tuple[str, str]] = set()
|
||||||
for entry in entries:
|
for row in rate_rows:
|
||||||
if not entry.hourly_rate:
|
unique_rates.add((row["amount"], row["currency"]))
|
||||||
continue
|
|
||||||
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
|
|
||||||
return [
|
return [
|
||||||
{"amount": amount, "currency": currency}
|
{"amount": amount, "currency": currency}
|
||||||
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
|
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
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)
|
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:
|
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 = {
|
return sorted(
|
||||||
"amount": amount,
|
serialized,
|
||||||
"currency": currency,
|
key=lambda item: (
|
||||||
"from_date": start_date,
|
item["from_date"],
|
||||||
"to_date": end_date,
|
item["scope"],
|
||||||
}
|
item.get("project_name") or "",
|
||||||
|
Decimal(item["amount"]),
|
||||||
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 _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]
|
||||||
|
|
||||||
@@ -371,8 +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)
|
||||||
|
rate_rows = _serialize_rate_history_rows(
|
||||||
|
user=user,
|
||||||
|
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)
|
||||||
@@ -386,8 +410,8 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic
|
|||||||
"name": _user_display(user),
|
"name": _user_display(user),
|
||||||
"mobile": user.mobile,
|
"mobile": user.mobile,
|
||||||
},
|
},
|
||||||
"hourly_rates": _serialize_distinct_rates(entries),
|
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
|
||||||
"rate_periods": _serialize_rate_periods(entries),
|
"rate_periods": rate_rows,
|
||||||
"total_seconds": summary["billable_seconds"],
|
"total_seconds": summary["billable_seconds"],
|
||||||
"total_duration": summary["total_duration"],
|
"total_duration": summary["total_duration"],
|
||||||
"billable_seconds": summary["billable_seconds"],
|
"billable_seconds": summary["billable_seconds"],
|
||||||
@@ -404,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
|
||||||
]
|
]
|
||||||
@@ -745,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
|
||||||
|
|
||||||
@@ -988,19 +1016,48 @@ def build_table_report(actor, raw_filters) -> dict:
|
|||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
if filters.is_workspace_scope and not filters.user_id:
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
return _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
|
||||||
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
|
||||||
)
|
)
|
||||||
return _table_report_payload(filters, entries, user_summary=user_summary)
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_summary_report(actor, raw_filters) -> dict:
|
||||||
|
filters = load_report_filters(actor, raw_filters)
|
||||||
|
if not filters.user_id:
|
||||||
|
raise serializers.ValidationError("A user is required.")
|
||||||
|
|
||||||
|
entries = list(_base_queryset(filters))
|
||||||
|
user_summary = (
|
||||||
|
_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
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return _table_report_payload(filters, entries, user_summary=user_summary)
|
||||||
|
|
||||||
|
|
||||||
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
if not (filters.is_workspace_scope and not filters.user_id):
|
if not (filters.is_workspace_scope and not filters.user_id):
|
||||||
@@ -1026,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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import jdatetime
|
|||||||
from arabic_reshaper import reshape
|
from arabic_reshaper import reshape
|
||||||
from bidi.algorithm import get_display
|
from bidi.algorithm import get_display
|
||||||
|
|
||||||
PERSIAN_DIGITS = str.maketrans("0123456789", "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9")
|
PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
|
||||||
ARABIC_RANGES = (
|
ARABIC_RANGES = (
|
||||||
(0x0600, 0x06FF),
|
(0x0600, 0x06FF),
|
||||||
(0x0750, 0x077F),
|
(0x0750, 0x077F),
|
||||||
@@ -49,9 +49,13 @@ TRANSLATIONS = {
|
|||||||
"rate_history": "Hourly rate history",
|
"rate_history": "Hourly rate history",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"to": "To",
|
"to": "To",
|
||||||
|
"now": "Now",
|
||||||
|
"project": "Project",
|
||||||
"percentage": "Percentage",
|
"percentage": "Percentage",
|
||||||
"hour_percentage": "Hour %",
|
"hour_percentage": "Hour %",
|
||||||
"income_percentage": "Income %",
|
"income_percentage": "Income %",
|
||||||
|
"multiple_rates": "Multiple rates - see details",
|
||||||
|
"variable_rate": "Variable rate",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"daily_summary": "Daily Summary",
|
"daily_summary": "Daily Summary",
|
||||||
"clients": "Clients",
|
"clients": "Clients",
|
||||||
@@ -66,48 +70,52 @@ TRANSLATIONS = {
|
|||||||
"uncategorized_tag": "No tag",
|
"uncategorized_tag": "No tag",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
"report_title": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
|
"report_title": "گزارش فضای کاری",
|
||||||
"overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc",
|
"overall_sheet": "گزارش کلی",
|
||||||
"users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
"users_summary_sheet": "خلاصه کاربران",
|
||||||
"workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
|
"workspace": "فضای کاری",
|
||||||
"period": "\u0628\u0627\u0632\u0647",
|
"period": "بازه",
|
||||||
"from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e",
|
"from_date": "از تاریخ",
|
||||||
"to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e",
|
"to_date": "تا تاریخ",
|
||||||
"user": "\u06a9\u0627\u0631\u0628\u0631",
|
"user": "کاربر",
|
||||||
"mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644",
|
"mobile": "موبایل",
|
||||||
"all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
"all_users": "همه کاربران",
|
||||||
"generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f",
|
"generated_at": "تاریخ تولید",
|
||||||
"summary": "\u062e\u0644\u0627\u0635\u0647",
|
"summary": "خلاصه",
|
||||||
"total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a",
|
"total_hours": "کل ساعات",
|
||||||
"billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
|
"billable_hours": "ساعات کاری",
|
||||||
"non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc",
|
"non_billable_hours": "ساعات غیر کاری",
|
||||||
"hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
"hourly_rate": "نرخ ساعتی",
|
||||||
"income": "\u06a9\u0627\u0631\u06a9\u0631\u062f",
|
"income": "کارکرد",
|
||||||
"working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
|
"working_hours": "ساعات کاری",
|
||||||
"non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc",
|
"non_working_hours": "ساعات غیرکاری",
|
||||||
"hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc",
|
"hourly_rates": "نرخهای ساعتی",
|
||||||
"project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
|
"project_percentages": "درصد پروژهها",
|
||||||
"client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627",
|
"client_percentages": "درصد مشتریها",
|
||||||
"tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627",
|
"tag_percentages": "درصد تگها",
|
||||||
"summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
|
"summary_by_user": "خلاصه کاربران",
|
||||||
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
|
"rate_history": "تاریخچه نرخ ساعتی",
|
||||||
"from": "\u0627\u0632",
|
"from": "از",
|
||||||
"to": "\u062a\u0627",
|
"to": "تا",
|
||||||
"percentage": "\u062f\u0631\u0635\u062f",
|
"now": "حال",
|
||||||
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
|
"project": "پروژه",
|
||||||
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
|
"percentage": "درصد",
|
||||||
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
|
"hour_percentage": "درصد ساعت",
|
||||||
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
|
"income_percentage": "درصد کارکرد",
|
||||||
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
|
"multiple_rates": "چند نرخ - جزئیات در گزارش کاربر",
|
||||||
"projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
|
"variable_rate": "نرخ متغیر",
|
||||||
"tags": "\u062a\u06af\u200c\u0647\u0627",
|
"none": "بدون مورد",
|
||||||
"date": "\u062a\u0627\u0631\u06cc\u062e",
|
"daily_summary": "خلاصه روزانه",
|
||||||
"name": "\u0646\u0627\u0645",
|
"clients": "مشتریان",
|
||||||
"total": "\u062c\u0645\u0639",
|
"projects": "پروژهها",
|
||||||
"no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647",
|
"tags": "تگها",
|
||||||
"uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
|
"date": "تاریخ",
|
||||||
"uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
|
"name": "نام",
|
||||||
"uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af",
|
"total": "جمع",
|
||||||
|
"no_data": "بدون داده",
|
||||||
|
"uncategorized_client": "بدون مشتری",
|
||||||
|
"uncategorized_project": "بدون پروژه",
|
||||||
|
"uncategorized_tag": "بدون تگ",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,25 +129,27 @@ PERIOD_LABELS = {
|
|||||||
"period": "Custom period",
|
"period": "Custom period",
|
||||||
},
|
},
|
||||||
"fa": {
|
"fa": {
|
||||||
"this_week": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647",
|
"this_week": "این هفته",
|
||||||
"this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647",
|
"this_month": "این ماه",
|
||||||
"this_year": "\u0627\u0645\u0633\u0627\u0644",
|
"this_year": "امسال",
|
||||||
"half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644",
|
"half_year_first": "نیمه اول سال",
|
||||||
"half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644",
|
"half_year_second": "نیمه دوم سال",
|
||||||
"period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647",
|
"period": "بازه دلخواه",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
CURRENCY_LABELS = {
|
CURRENCY_LABELS = {
|
||||||
"USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"},
|
"USD": {"en": "USD", "fa": "دلار آمریکا"},
|
||||||
"EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"},
|
"EUR": {"en": "EUR", "fa": "یورو"},
|
||||||
"GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"},
|
"GBP": {"en": "GBP", "fa": "پوند"},
|
||||||
"IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"},
|
"IRR": {"en": "IRR", "fa": "ریال"},
|
||||||
"IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"},
|
"IRT": {"en": "IRT", "fa": "تومان"},
|
||||||
"AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"},
|
"AED": {"en": "AED", "fa": "درهم"},
|
||||||
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
|
"TRY": {"en": "TRY", "fa": "لیر"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ExportLocale:
|
class ExportLocale:
|
||||||
@@ -174,6 +184,15 @@ class ExportLocale:
|
|||||||
return self.format_number(value, ascii_digits=ascii_digits)
|
return self.format_number(value, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
def format_amount(self, value: object, *, ascii_digits: bool = False) -> str:
|
||||||
|
return self.format_amount_for_currency(value, None, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
|
def format_amount_for_currency(
|
||||||
|
self,
|
||||||
|
value: object,
|
||||||
|
currency: str | None,
|
||||||
|
*,
|
||||||
|
ascii_digits: bool = False,
|
||||||
|
) -> str:
|
||||||
raw = str(value).strip()
|
raw = str(value).strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return raw
|
return raw
|
||||||
@@ -189,18 +208,24 @@ class ExportLocale:
|
|||||||
grouped_integer = f"{int(integer_part):,}"
|
grouped_integer = f"{int(integer_part):,}"
|
||||||
formatted = f"{sign}{grouped_integer}"
|
formatted = f"{sign}{grouped_integer}"
|
||||||
if fractional_part:
|
if fractional_part:
|
||||||
trimmed_fraction = fractional_part.rstrip("0")
|
trimmed_fraction = (
|
||||||
|
""
|
||||||
|
if str(currency or "").upper() in DECIMAL_TRIM_CURRENCIES
|
||||||
|
else fractional_part.rstrip("0")
|
||||||
|
)
|
||||||
if trimmed_fraction:
|
if trimmed_fraction:
|
||||||
formatted = f"{formatted}.{trimmed_fraction}"
|
formatted = f"{formatted}.{trimmed_fraction}"
|
||||||
return self.format_number(formatted, ascii_digits=ascii_digits)
|
return self.format_number(formatted, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
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"])
|
||||||
parts.append(f"{self.format_amount(item['amount'], ascii_digits=ascii_digits)} {currency}")
|
parts.append(
|
||||||
|
f"{self.format_amount_for_currency(item['amount'], item['currency'], ascii_digits=ascii_digits)} {currency}"
|
||||||
|
)
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|
||||||
def currency_label(self, code: str | None) -> str:
|
def currency_label(self, code: str | None) -> str:
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ BORDER = Border(
|
|||||||
top=Side(style="thin", color="D0D7DE"),
|
top=Side(style="thin", color="D0D7DE"),
|
||||||
bottom=Side(style="thin", color="D0D7DE"),
|
bottom=Side(style="thin", color="D0D7DE"),
|
||||||
)
|
)
|
||||||
|
USER_SUMMARY_SEPARATOR_BORDER = Border(
|
||||||
|
left=Side(style="thin", color="D0D7DE"),
|
||||||
|
right=Side(style="thin", color="D0D7DE"),
|
||||||
|
top=Side(style="medium", color="94A3B8"),
|
||||||
|
bottom=Side(style="thin", color="D0D7DE"),
|
||||||
|
)
|
||||||
|
EMPTY_BORDER = Border()
|
||||||
|
|
||||||
|
|
||||||
class BookmarkDocTemplate(SimpleDocTemplate):
|
class BookmarkDocTemplate(SimpleDocTemplate):
|
||||||
@@ -65,20 +72,24 @@ def _autosize_columns(worksheet) -> None:
|
|||||||
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
|
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze_first_column(worksheet) -> None:
|
||||||
|
worksheet.freeze_panes = "B1"
|
||||||
|
|
||||||
|
|
||||||
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
|
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
|
||||||
return locale.format_money_label(income_totals)
|
return locale.format_money_label(income_totals)
|
||||||
|
|
||||||
|
|
||||||
def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
|
def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
|
||||||
value = locale.format_money_label(income_totals, ascii_digits=True)
|
value = locale.format_money_label(income_totals, ascii_digits=True)
|
||||||
return f"\u202B{value}\u202C" if locale.is_rtl else value
|
return f"\u202B{value}\u202C" if locale.is_rtl else value # Unicode bidi control characters
|
||||||
|
|
||||||
|
|
||||||
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 locale.t("none")
|
return locale.format_number("0", ascii_digits=ascii_digits)
|
||||||
items = [
|
items = [
|
||||||
f"{locale.format_amount(rate['amount'], 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
|
||||||
]
|
]
|
||||||
return ", ".join(items)
|
return ", ".join(items)
|
||||||
@@ -96,22 +107,49 @@ def _percentages_label(locale: ExportLocale, rows: list[dict], *, ascii_digits:
|
|||||||
|
|
||||||
def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str:
|
def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str:
|
||||||
return (
|
return (
|
||||||
f"{locale.format_amount(row['amount'], ascii_digits=ascii_digits)} "
|
f"{locale.format_amount_for_currency(row['amount'], row['currency'], ascii_digits=ascii_digits)} "
|
||||||
f"{locale.currency_label(row['currency'])}"
|
f"{locale.currency_label(row['currency'])}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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(rate['amount'])} {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 = f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}"
|
value = (
|
||||||
return f"\u202B{value}\u202C" if locale.is_rtl else value
|
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
|
||||||
|
f"{locale.currency_label(rate['currency'])}"
|
||||||
|
)
|
||||||
|
return f"\u202B{value}\u202C" if locale.is_rtl else value # Unicode bidi control characters
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_summary_rate_label(locale: ExportLocale, rates: list[dict]) -> str:
|
||||||
|
if len(rates) > 1:
|
||||||
|
return locale.t("variable_rate")
|
||||||
|
return _rates_label(locale, rates)
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_rate_rows(locale: ExportLocale, summary: dict) -> list[list[str]]:
|
||||||
|
rate_periods = summary.get("rate_periods") or []
|
||||||
|
if not rate_periods:
|
||||||
|
return [[locale.t("none"), locale.t("none")]]
|
||||||
|
if len(rate_periods) > 1:
|
||||||
|
return [[locale.t("variable_rate"), _summary_period_label(locale, rate_periods, ascii_digits=True)]]
|
||||||
|
row = rate_periods[0]
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
_rate_period_label(locale, row, ascii_digits=True),
|
||||||
|
(
|
||||||
|
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
|
||||||
|
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _section_headers(locale: ExportLocale) -> list[str]:
|
def _section_headers(locale: ExportLocale) -> list[str]:
|
||||||
@@ -186,7 +224,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:
|
||||||
@@ -195,12 +240,47 @@ 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:
|
||||||
|
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
|
||||||
|
row_name = row_data.get("name")
|
||||||
|
for row in rows or []:
|
||||||
|
if row_id is not None and str(row.get("id")) == row_id:
|
||||||
|
try:
|
||||||
|
return float(row.get("percentage") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
if row_name and row.get("name") == row_name:
|
||||||
|
try:
|
||||||
|
return float(row.get("percentage") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _percentage_sort_value(row: dict) -> float:
|
||||||
|
try:
|
||||||
|
return float(row.get("percentage") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_breakdown_rows(rows: list[dict], hour_percentages: list[dict] | None) -> list[dict]:
|
||||||
|
return sorted(
|
||||||
|
rows,
|
||||||
|
key=lambda row: (
|
||||||
|
-_percentage_number(hour_percentages, row),
|
||||||
|
-(row.get("billable_seconds") or 0),
|
||||||
|
row.get("name") or "",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str:
|
def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str:
|
||||||
value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%"
|
value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%"
|
||||||
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value
|
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value # Unicode bidi control characters
|
||||||
|
|
||||||
|
|
||||||
def _summary_breakdown_rows(
|
def _summary_breakdown_rows(
|
||||||
@@ -215,9 +295,9 @@ 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 hour_rows
|
for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or ""))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -227,19 +307,26 @@ def _summary_period_label(locale: ExportLocale, rate_periods: list[dict], *, asc
|
|||||||
|
|
||||||
first_row = rate_periods[0]
|
first_row = rate_periods[0]
|
||||||
last_row = rate_periods[-1]
|
last_row = rate_periods[-1]
|
||||||
|
last_to_date = last_row.get("to_date")
|
||||||
return (
|
return (
|
||||||
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
|
f"{locale.format_date(first_row['from_date'], ascii_digits=ascii_digits)} - "
|
||||||
f"{locale.format_date(last_row['to_date'], ascii_digits=ascii_digits)}"
|
f"{(_rate_to_label(locale, last_to_date, ascii_digits=ascii_digits))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: bool = False) -> str:
|
||||||
|
if not to_date:
|
||||||
|
return locale.t("now")
|
||||||
|
return locale.format_date(to_date, ascii_digits=ascii_digits)
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
@@ -257,7 +344,8 @@ 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),
|
||||||
locale.format_date(row["to_date"], ascii_digits=True),
|
_rate_to_label(locale, row.get("to_date"), ascii_digits=True),
|
||||||
|
row.get("project_name") or "-",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -441,21 +529,27 @@ def _append_breakdown_table(
|
|||||||
rows: list[dict],
|
rows: list[dict],
|
||||||
hour_percentages: list[dict] | None = None,
|
hour_percentages: list[dict] | None = None,
|
||||||
income_percentages: list[dict] | None = None,
|
income_percentages: list[dict] | None = None,
|
||||||
|
financial_only: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
worksheet.append([])
|
worksheet.append([])
|
||||||
_append_merged_heading(
|
_append_merged_heading(
|
||||||
worksheet,
|
worksheet,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
title=locale.t(title_key),
|
title=locale.t(title_key),
|
||||||
span=7 if hour_percentages is not None else 5,
|
span=(
|
||||||
|
5
|
||||||
|
if hour_percentages is not None and financial_only
|
||||||
|
else 7
|
||||||
|
if hour_percentages is not None
|
||||||
|
else 5
|
||||||
|
),
|
||||||
)
|
)
|
||||||
header_row = worksheet.max_row + 1
|
header_row = worksheet.max_row + 1
|
||||||
headers = [
|
headers = [
|
||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
|
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
|
||||||
locale.t("non_billable_hours"),
|
*( [] if financial_only else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
|
||||||
locale.t("total_hours"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
|
*( [locale.t("income_percentage")] if hour_percentages is not None else [] ),
|
||||||
]
|
]
|
||||||
@@ -468,7 +562,7 @@ def _append_breakdown_table(
|
|||||||
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
|
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
|
||||||
return
|
return
|
||||||
|
|
||||||
for row in rows:
|
for row in _sort_breakdown_rows(rows, hour_percentages):
|
||||||
values = [
|
values = [
|
||||||
row["name"],
|
row["name"],
|
||||||
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
locale.format_duration(row["billable_duration"], ascii_digits=True),
|
||||||
@@ -477,11 +571,17 @@ def _append_breakdown_table(
|
|||||||
if hour_percentages is not None
|
if hour_percentages is not None
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
|
*(
|
||||||
locale.format_duration(row["total_duration"], ascii_digits=True),
|
[]
|
||||||
|
if financial_only
|
||||||
|
else [
|
||||||
|
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
|
||||||
|
locale.format_duration(row["total_duration"], 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="-")]
|
||||||
if hour_percentages is not None
|
if hour_percentages is not None
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
@@ -530,6 +630,8 @@ def _append_user_details_block_excel(
|
|||||||
|
|
||||||
|
|
||||||
def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value: str, rtl: bool) -> None:
|
def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value: str, rtl: bool) -> None:
|
||||||
|
if end_col < start_col:
|
||||||
|
end_col = start_col
|
||||||
worksheet.merge_cells(start_row=row, start_column=start_col, end_row=row, end_column=end_col)
|
worksheet.merge_cells(start_row=row, start_column=start_col, end_row=row, end_column=end_col)
|
||||||
cell = worksheet.cell(row=row, column=start_col)
|
cell = worksheet.cell(row=row, column=start_col)
|
||||||
cell.value = value
|
cell.value = value
|
||||||
@@ -539,12 +641,19 @@ def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value
|
|||||||
def _append_merged_heading(worksheet, *, locale: ExportLocale, title: str, span: int) -> int:
|
def _append_merged_heading(worksheet, *, locale: ExportLocale, title: str, span: int) -> int:
|
||||||
worksheet.append([title])
|
worksheet.append([title])
|
||||||
row = worksheet.max_row
|
row = worksheet.max_row
|
||||||
|
span = max(int(span), 1)
|
||||||
if span > 1:
|
if span > 1:
|
||||||
worksheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=span)
|
worksheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=span)
|
||||||
_apply_cell_style(worksheet.cell(row=row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
_apply_cell_style(worksheet.cell(row=row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_excel_cell_style(cell) -> None:
|
||||||
|
cell.value = None
|
||||||
|
cell.fill = PatternFill(fill_type=None)
|
||||||
|
cell.border = EMPTY_BORDER
|
||||||
|
|
||||||
|
|
||||||
def _write_table_row(
|
def _write_table_row(
|
||||||
worksheet,
|
worksheet,
|
||||||
*,
|
*,
|
||||||
@@ -562,16 +671,7 @@ def _write_table_row(
|
|||||||
|
|
||||||
|
|
||||||
def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]:
|
def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]:
|
||||||
rate_rows = [
|
rate_rows = _summary_rate_rows(locale, summary)
|
||||||
[
|
|
||||||
_rate_period_label(locale, row, ascii_digits=True),
|
|
||||||
(
|
|
||||||
f"{locale.format_date(row['from_date'], ascii_digits=True)} - "
|
|
||||||
f"{locale.format_date(row['to_date'], ascii_digits=True)}"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
for row in (summary.get("rate_periods") or [])
|
|
||||||
]
|
|
||||||
client_rows = _summary_breakdown_rows(
|
client_rows = _summary_breakdown_rows(
|
||||||
locale,
|
locale,
|
||||||
summary.get("client_percentages") or [],
|
summary.get("client_percentages") or [],
|
||||||
@@ -595,11 +695,13 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
|
|||||||
summary["user"]["name"] if index == 0 else None,
|
summary["user"]["name"] if index == 0 else None,
|
||||||
locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None,
|
locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None,
|
||||||
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,
|
||||||
locale.format_duration(summary["non_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]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -624,6 +726,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
) -> None:
|
) -> None:
|
||||||
if locale.is_rtl:
|
if locale.is_rtl:
|
||||||
worksheet.sheet_view.rightToLeft = True
|
worksheet.sheet_view.rightToLeft = True
|
||||||
|
_freeze_first_column(worksheet)
|
||||||
|
|
||||||
scope = report_data["scope"]
|
scope = report_data["scope"]
|
||||||
summary = report_data["summary"]
|
summary = report_data["summary"]
|
||||||
@@ -658,28 +761,26 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
for row_index, values in enumerate(summary_rows, start=10):
|
for row_index, values in enumerate(summary_rows, start=10):
|
||||||
_write_table_row(worksheet, row=row_index, start_col=1, values=values, rtl=locale.is_rtl)
|
_write_table_row(worksheet, row=row_index, start_col=1, values=values, rtl=locale.is_rtl)
|
||||||
|
|
||||||
_merge_and_style(
|
_merge_and_style(worksheet, row=15, start_col=1, end_col=6, value=locale.t("users_summary_sheet"), rtl=locale.is_rtl)
|
||||||
worksheet,
|
_merge_and_style(worksheet, row=15, start_col=8, end_col=10, value=locale.t("clients"), rtl=locale.is_rtl)
|
||||||
row=15,
|
_merge_and_style(worksheet, row=15, start_col=12, end_col=14, value=locale.t("projects"), rtl=locale.is_rtl)
|
||||||
start_col=1,
|
_merge_and_style(worksheet, row=15, start_col=16, end_col=18, value=locale.t("tags"), rtl=locale.is_rtl)
|
||||||
end_col=16,
|
|
||||||
value=locale.t("users_summary_sheet"),
|
|
||||||
rtl=locale.is_rtl,
|
|
||||||
)
|
|
||||||
summary_headers = [
|
summary_headers = [
|
||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("mobile"),
|
locale.t("mobile"),
|
||||||
locale.t("working_hours"),
|
locale.t("working_hours"),
|
||||||
locale.t("non_working_hours"),
|
|
||||||
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"),
|
||||||
@@ -704,29 +805,37 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
values=values,
|
values=values,
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
for column in (1, 2, 3, 4, 7):
|
if offset == 0:
|
||||||
|
for cell in worksheet[current_row]:
|
||||||
|
if cell.column not in (7, 11, 15):
|
||||||
|
cell.border = USER_SUMMARY_SEPARATOR_BORDER
|
||||||
|
for column in (1, 2, 3, 6):
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
|
||||||
rate_rows = user_summary.get("rate_periods") or []
|
rate_rows = user_summary.get("rate_periods") or []
|
||||||
client_rows = user_summary.get("client_percentages") or []
|
client_rows = user_summary.get("client_percentages") or []
|
||||||
project_rows = user_summary.get("project_percentages") or []
|
project_rows = user_summary.get("project_percentages") or []
|
||||||
tag_rows = user_summary.get("tag_percentages") or []
|
tag_rows = user_summary.get("tag_percentages") or []
|
||||||
if len(rate_rows) == 1:
|
if len(rate_rows) == 1:
|
||||||
|
_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)
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, 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=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)
|
||||||
_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)
|
||||||
if len(project_rows) == 1:
|
if len(project_rows) == 1:
|
||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True)
|
|
||||||
_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)
|
||||||
_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)
|
||||||
if len(tag_rows) == 1:
|
|
||||||
_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=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(15, current_row):
|
||||||
|
for column_index in (7, 11, 15):
|
||||||
|
_clear_excel_cell_style(worksheet.cell(row=row_index, column=column_index))
|
||||||
|
|
||||||
current_row += 2
|
current_row += 2
|
||||||
for title_key, rows, hour_percentages, income_percentages in (
|
for title_key, rows, hour_percentages, income_percentages in (
|
||||||
(
|
(
|
||||||
@@ -752,7 +861,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
worksheet,
|
worksheet,
|
||||||
row=current_row,
|
row=current_row,
|
||||||
start_col=1,
|
start_col=1,
|
||||||
end_col=7,
|
end_col=5,
|
||||||
value=locale.t(title_key),
|
value=locale.t(title_key),
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
@@ -765,8 +874,6 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
locale.t("hour_percentage"),
|
locale.t("hour_percentage"),
|
||||||
locale.t("non_billable_hours"),
|
|
||||||
locale.t("total_hours"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
locale.t("income_percentage"),
|
locale.t("income_percentage"),
|
||||||
],
|
],
|
||||||
@@ -775,8 +882,9 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
fill=HEADER_FILL,
|
fill=HEADER_FILL,
|
||||||
)
|
)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
if rows:
|
sorted_rows = _sort_breakdown_rows(rows, hour_percentages)
|
||||||
for row in rows:
|
if sorted_rows:
|
||||||
|
for row in sorted_rows:
|
||||||
_write_table_row(
|
_write_table_row(
|
||||||
worksheet,
|
worksheet,
|
||||||
row=current_row,
|
row=current_row,
|
||||||
@@ -785,10 +893,8 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
row["name"],
|
row["name"],
|
||||||
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),
|
||||||
locale.format_duration(row["non_billable_duration"], ascii_digits=True),
|
|
||||||
locale.format_duration(row["total_duration"], 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,
|
||||||
)
|
)
|
||||||
@@ -798,7 +904,7 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
worksheet,
|
worksheet,
|
||||||
row=current_row,
|
row=current_row,
|
||||||
start_col=1,
|
start_col=1,
|
||||||
values=[locale.t("no_data"), None, None, None, None, None, None],
|
values=[locale.t("no_data"), None, None, None, None],
|
||||||
rtl=locale.is_rtl,
|
rtl=locale.is_rtl,
|
||||||
)
|
)
|
||||||
current_row += 1
|
current_row += 1
|
||||||
@@ -808,30 +914,33 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
"A": 31.57,
|
"A": 31.57,
|
||||||
"B": 19.86,
|
"B": 19.86,
|
||||||
"C": 18.0,
|
"C": 18.0,
|
||||||
"D": 17.0,
|
"D": 18.0,
|
||||||
"E": 18.0,
|
"E": 26.0,
|
||||||
"F": 26.0,
|
"F": 24.0,
|
||||||
"G": 24.0,
|
"G": 28.0,
|
||||||
"H": 28.0,
|
"H": 14.0,
|
||||||
"I": 14.0,
|
"I": 16.0,
|
||||||
"J": 16.0,
|
"J": 28.0,
|
||||||
"K": 28.0,
|
"K": 14.0,
|
||||||
"L": 14.0,
|
"L": 16.0,
|
||||||
"M": 16.0,
|
"M": 24.0,
|
||||||
"N": 24.0,
|
"N": 14.0,
|
||||||
"O": 14.0,
|
"O": 16.0,
|
||||||
"P": 16.0,
|
|
||||||
}
|
}
|
||||||
for column, width in overall_widths.items():
|
for column, width in overall_widths.items():
|
||||||
worksheet.column_dimensions[column].width = width
|
worksheet.column_dimensions[column].width = width
|
||||||
|
|
||||||
|
|
||||||
def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None:
|
def _render_excel_sheet(
|
||||||
|
worksheet,
|
||||||
|
*,
|
||||||
|
locale: ExportLocale,
|
||||||
|
report_data: dict,
|
||||||
|
financial_only_breakdowns: bool = False,
|
||||||
|
) -> None:
|
||||||
if locale.is_rtl:
|
if locale.is_rtl:
|
||||||
worksheet.sheet_view.rightToLeft = True
|
worksheet.sheet_view.rightToLeft = True
|
||||||
worksheet.freeze_panes = "E4"
|
_freeze_first_column(worksheet)
|
||||||
else:
|
|
||||||
worksheet.freeze_panes = "A4"
|
|
||||||
_append_meta_block(worksheet, locale=locale, report_data=report_data)
|
_append_meta_block(worksheet, locale=locale, report_data=report_data)
|
||||||
if report_data.get("user_summaries"):
|
if report_data.get("user_summaries"):
|
||||||
worksheet.append([])
|
worksheet.append([])
|
||||||
@@ -860,6 +969,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
if user_summary
|
if user_summary
|
||||||
else report_data.get("client_income_percentages")
|
else report_data.get("client_income_percentages")
|
||||||
),
|
),
|
||||||
|
financial_only=financial_only_breakdowns,
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
@@ -876,6 +986,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
if user_summary
|
if user_summary
|
||||||
else report_data.get("project_income_percentages")
|
else report_data.get("project_income_percentages")
|
||||||
),
|
),
|
||||||
|
financial_only=financial_only_breakdowns,
|
||||||
)
|
)
|
||||||
_append_breakdown_table(
|
_append_breakdown_table(
|
||||||
worksheet,
|
worksheet,
|
||||||
@@ -892,6 +1003,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
|
|||||||
if user_summary
|
if user_summary
|
||||||
else report_data.get("tag_income_percentages")
|
else report_data.get("tag_income_percentages")
|
||||||
),
|
),
|
||||||
|
financial_only=financial_only_breakdowns,
|
||||||
)
|
)
|
||||||
_autosize_columns(worksheet)
|
_autosize_columns(worksheet)
|
||||||
|
|
||||||
@@ -935,7 +1047,12 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
|
|||||||
used_titles,
|
used_titles,
|
||||||
)
|
)
|
||||||
worksheet = workbook.create_sheet(title=user_title)
|
worksheet = workbook.create_sheet(title=user_title)
|
||||||
_render_excel_sheet(worksheet, locale=locale, report_data=user_report)
|
_render_excel_sheet(
|
||||||
|
worksheet,
|
||||||
|
locale=locale,
|
||||||
|
report_data=user_report,
|
||||||
|
financial_only_breakdowns=True,
|
||||||
|
)
|
||||||
used_titles.add(user_title)
|
used_titles.add(user_title)
|
||||||
else:
|
else:
|
||||||
overall_sheet = workbook.active
|
overall_sheet = workbook.active
|
||||||
@@ -1041,22 +1158,31 @@ 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,
|
||||||
[
|
[
|
||||||
_rate_period_label(locale, row),
|
_rate_period_label(locale, row),
|
||||||
locale.format_date(row["from_date"]),
|
locale.format_date(row["from_date"]),
|
||||||
locale.format_date(row["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"), "", "", ""]))
|
||||||
return _styled_table(data, locale=locale, column_widths=[doc_width * 0.34, doc_width * 0.33, doc_width * 0.33])
|
fixed_widths = [doc_width * 0.18, doc_width * 0.18, doc_width * 0.24]
|
||||||
|
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||||
|
if locale.is_rtl:
|
||||||
|
column_widths = list(reversed(column_widths))
|
||||||
|
return _styled_table(
|
||||||
|
data,
|
||||||
|
locale=locale,
|
||||||
|
column_widths=column_widths,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table:
|
def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table:
|
||||||
@@ -1083,6 +1209,7 @@ def _append_pdf_report_sections(
|
|||||||
doc_width: float,
|
doc_width: float,
|
||||||
section_style: ParagraphStyle,
|
section_style: ParagraphStyle,
|
||||||
user_summary: dict | None = None,
|
user_summary: dict | None = None,
|
||||||
|
financial_only_breakdowns: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
sections = [
|
sections = [
|
||||||
("daily_summary", report_data["days"], True),
|
("daily_summary", report_data["days"], True),
|
||||||
@@ -1107,8 +1234,7 @@ def _append_pdf_report_sections(
|
|||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("billable_hours"),
|
locale.t("billable_hours"),
|
||||||
locale.t("hour_percentage"),
|
locale.t("hour_percentage"),
|
||||||
locale.t("non_billable_hours"),
|
*( [] if financial_only_breakdowns else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
|
||||||
locale.t("total_hours"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
locale.t("income_percentage"),
|
locale.t("income_percentage"),
|
||||||
]
|
]
|
||||||
@@ -1120,7 +1246,8 @@ def _append_pdf_report_sections(
|
|||||||
hour_percentage_rows = user_summary[f"{prefix}_percentages"]
|
hour_percentage_rows = user_summary[f"{prefix}_percentages"]
|
||||||
income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
|
income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
|
||||||
header = _rtl_row(locale, header_values)
|
header = _rtl_row(locale, header_values)
|
||||||
body_rows = _report_table_rows(locale, rows, is_daily=is_daily)
|
sorted_rows = rows if is_daily else _sort_breakdown_rows(rows, hour_percentage_rows)
|
||||||
|
body_rows = _report_table_rows(locale, sorted_rows, is_daily=is_daily)
|
||||||
if hour_percentage_rows is not None:
|
if hour_percentage_rows is not None:
|
||||||
body_rows = [
|
body_rows = [
|
||||||
_rtl_row(
|
_rtl_row(
|
||||||
@@ -1129,49 +1256,69 @@ def _append_pdf_report_sections(
|
|||||||
row["name"],
|
row["name"],
|
||||||
locale.format_duration(row["billable_duration"]),
|
locale.format_duration(row["billable_duration"]),
|
||||||
_percentage_display(locale, hour_percentage_rows, row),
|
_percentage_display(locale, hour_percentage_rows, row),
|
||||||
locale.format_duration(row["non_billable_duration"]),
|
*(
|
||||||
locale.format_duration(row["total_duration"]),
|
[]
|
||||||
|
if financial_only_breakdowns
|
||||||
|
else [
|
||||||
|
locale.format_duration(row["non_billable_duration"]),
|
||||||
|
locale.format_duration(row["total_duration"]),
|
||||||
|
]
|
||||||
|
),
|
||||||
_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 rows
|
for row in sorted_rows
|
||||||
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
|
] or [
|
||||||
|
_rtl_row(
|
||||||
|
locale,
|
||||||
|
[locale.t("no_data"), "", "", *( [] if financial_only_breakdowns else ["", ""] ), "", ""],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if is_daily:
|
||||||
|
column_widths = [
|
||||||
|
doc_width * 0.20,
|
||||||
|
doc_width * 0.12,
|
||||||
|
doc_width * 0.15,
|
||||||
|
doc_width * 0.13,
|
||||||
|
doc_width * 0.16,
|
||||||
|
doc_width * 0.24,
|
||||||
|
]
|
||||||
|
elif hour_percentage_rows is not None:
|
||||||
|
fixed_widths = (
|
||||||
|
[
|
||||||
|
doc_width * 0.11,
|
||||||
|
doc_width * 0.11,
|
||||||
|
doc_width * 0.19,
|
||||||
|
doc_width * 0.15,
|
||||||
|
]
|
||||||
|
if financial_only_breakdowns
|
||||||
|
else [
|
||||||
|
doc_width * 0.11,
|
||||||
|
doc_width * 0.11,
|
||||||
|
doc_width * 0.12,
|
||||||
|
doc_width * 0.12,
|
||||||
|
doc_width * 0.19,
|
||||||
|
doc_width * 0.15,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||||
|
if locale.is_rtl:
|
||||||
|
column_widths = list(reversed(column_widths))
|
||||||
|
else:
|
||||||
|
fixed_widths = [
|
||||||
|
doc_width * 0.15,
|
||||||
|
doc_width * 0.17,
|
||||||
|
doc_width * 0.14,
|
||||||
|
doc_width * 0.28,
|
||||||
|
]
|
||||||
|
column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
|
||||||
|
if locale.is_rtl:
|
||||||
|
column_widths = list(reversed(column_widths))
|
||||||
table = _styled_table(
|
table = _styled_table(
|
||||||
[header, *body_rows],
|
[header, *body_rows],
|
||||||
locale=locale,
|
locale=locale,
|
||||||
column_widths=(
|
column_widths=column_widths,
|
||||||
[
|
|
||||||
doc_width * 0.20,
|
|
||||||
doc_width * 0.12,
|
|
||||||
doc_width * 0.15,
|
|
||||||
doc_width * 0.13,
|
|
||||||
doc_width * 0.16,
|
|
||||||
doc_width * 0.24,
|
|
||||||
]
|
|
||||||
if is_daily
|
|
||||||
else [
|
|
||||||
*(
|
|
||||||
[
|
|
||||||
doc_width * 0.20,
|
|
||||||
doc_width * 0.11,
|
|
||||||
doc_width * 0.11,
|
|
||||||
doc_width * 0.12,
|
|
||||||
doc_width * 0.12,
|
|
||||||
doc_width * 0.19,
|
|
||||||
doc_width * 0.15,
|
|
||||||
]
|
|
||||||
if hour_percentage_rows is not None
|
|
||||||
else [
|
|
||||||
doc_width * 0.13,
|
|
||||||
doc_width * 0.15,
|
|
||||||
doc_width * 0.17,
|
|
||||||
doc_width * 0.14,
|
|
||||||
doc_width * 0.28,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
story.extend([table, Spacer(1, 5 * mm)])
|
story.extend([table, Spacer(1, 5 * mm)])
|
||||||
|
|
||||||
@@ -1313,9 +1460,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
locale.t("name"),
|
locale.t("name"),
|
||||||
locale.t("mobile"),
|
locale.t("mobile"),
|
||||||
locale.t("working_hours"),
|
locale.t("working_hours"),
|
||||||
locale.t("non_working_hours"),
|
|
||||||
locale.t("hourly_rate"),
|
locale.t("hourly_rate"),
|
||||||
locale.t("period"),
|
|
||||||
locale.t("income"),
|
locale.t("income"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1326,9 +1471,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
summary["user"]["name"],
|
summary["user"]["name"],
|
||||||
locale.format_number(summary["user"]["mobile"]),
|
locale.format_number(summary["user"]["mobile"]),
|
||||||
locale.format_duration(summary["billable_duration"]),
|
locale.format_duration(summary["billable_duration"]),
|
||||||
locale.format_duration(summary["non_billable_duration"]),
|
_pdf_summary_rate_label(locale, summary.get("hourly_rates") or []),
|
||||||
_rates_label(locale, summary.get("hourly_rates") or []),
|
|
||||||
_summary_period_label(locale, summary.get("rate_periods") or []),
|
|
||||||
_money_label(locale, summary["income_totals"]),
|
_money_label(locale, summary["income_totals"]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -1339,13 +1482,11 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
[user_summary_header, *user_summary_rows],
|
[user_summary_header, *user_summary_rows],
|
||||||
locale=locale,
|
locale=locale,
|
||||||
column_widths=[
|
column_widths=[
|
||||||
doc.width * 0.18,
|
doc.width * 0.25,
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.13,
|
|
||||||
doc.width * 0.16,
|
doc.width * 0.16,
|
||||||
doc.width * 0.14,
|
doc.width * 0.16,
|
||||||
|
doc.width * 0.19,
|
||||||
|
doc.width * 0.24,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1379,6 +1520,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
doc_width=doc.width,
|
doc_width=doc.width,
|
||||||
section_style=section_style,
|
section_style=section_style,
|
||||||
user_summary=user_report.get("user_summary"),
|
user_summary=user_report.get("user_summary"),
|
||||||
|
financial_only_breakdowns=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
_append_pdf_report_sections(
|
_append_pdf_report_sections(
|
||||||
@@ -1388,6 +1530,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
|
|||||||
doc_width=doc.width,
|
doc_width=doc.width,
|
||||||
section_style=section_style,
|
section_style=section_style,
|
||||||
user_summary=report_data.get("user_summary"),
|
user_summary=report_data.get("user_summary"),
|
||||||
|
financial_only_breakdowns=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
doc.build(story)
|
doc.build(story)
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ from django.test import TestCase
|
|||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
from apps.reports.services.export_i18n import build_export_locale
|
from apps.reports.services.export_i18n import build_export_locale
|
||||||
from apps.reports.services.exporters import build_excel_report, build_pdf_report
|
from apps.reports.services.exporters import (
|
||||||
|
_pdf_summary_rate_label,
|
||||||
|
_rate_label,
|
||||||
|
_sort_breakdown_rows,
|
||||||
|
build_excel_report,
|
||||||
|
build_pdf_report,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None):
|
def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rate=None):
|
||||||
@@ -74,14 +80,84 @@ def make_user_summary(*, name: str, mobile: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_variable_user_summary(*, name: str, mobile: str):
|
||||||
|
summary = make_user_summary(name=name, mobile=mobile)
|
||||||
|
summary["hourly_rates"] = [
|
||||||
|
{"amount": "15.00", "currency": "USD"},
|
||||||
|
{"amount": "18.00", "currency": "USD"},
|
||||||
|
]
|
||||||
|
summary["rate_periods"] = [
|
||||||
|
{
|
||||||
|
"amount": "15.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-01",
|
||||||
|
"to_date": "2026-04-14",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "18.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-15",
|
||||||
|
"to_date": "2026-04-30",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
class ReportExporterTests(TestCase):
|
class ReportExporterTests(TestCase):
|
||||||
|
def test_export_rate_labels_trim_rial_and_toman_decimals(self):
|
||||||
|
locale = build_export_locale("en")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "1250.75", "currency": "USD"}),
|
||||||
|
"1,250.75 USD",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "1250.75", "currency": "IRR"}),
|
||||||
|
"1,250 IRR",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
_rate_label(locale, {"amount": "9800.50", "currency": "IRT"}),
|
||||||
|
"9,800 IRT",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pdf_summary_uses_multiple_rates_label(self):
|
||||||
|
locale = build_export_locale("en")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
_pdf_summary_rate_label(
|
||||||
|
locale,
|
||||||
|
[
|
||||||
|
{"amount": "15.00", "currency": "USD"},
|
||||||
|
{"amount": "18.00", "currency": "USD"},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
"Variable rate",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_breakdown_rows_are_sorted_by_hour_percentage(self):
|
||||||
|
rows = [
|
||||||
|
{"id": "low", "name": "Low", "billable_seconds": 7200},
|
||||||
|
{"id": "high", "name": "High", "billable_seconds": 3600},
|
||||||
|
{"id": "tie", "name": "Tie", "billable_seconds": 10800},
|
||||||
|
]
|
||||||
|
percentages = [
|
||||||
|
{"id": "low", "name": "Low", "percentage": "20"},
|
||||||
|
{"id": "high", "name": "High", "percentage": "70"},
|
||||||
|
{"id": "tie", "name": "Tie", "percentage": "20"},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[row["name"] for row in _sort_breakdown_rows(rows, percentages)],
|
||||||
|
["High", "Tie", "Low"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
|
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
|
||||||
locale = build_export_locale("en")
|
locale = build_export_locale("en")
|
||||||
report_data = make_report_data(
|
report_data = make_report_data(
|
||||||
hourly_rate={"amount": "15.00", "currency": "USD"},
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
)
|
)
|
||||||
report_data["user_summaries"] = [
|
report_data["user_summaries"] = [
|
||||||
make_user_summary(name="Owner User", mobile="09129990001"),
|
make_variable_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
make_user_summary(name="Team Mate", mobile="09129990002"),
|
make_user_summary(name="Team Mate", mobile="09129990002"),
|
||||||
]
|
]
|
||||||
per_user_reports = [
|
per_user_reports = [
|
||||||
@@ -91,7 +167,7 @@ class ReportExporterTests(TestCase):
|
|||||||
mobile="09129990001",
|
mobile="09129990001",
|
||||||
hourly_rate={"amount": "15.00", "currency": "USD"},
|
hourly_rate={"amount": "15.00", "currency": "USD"},
|
||||||
),
|
),
|
||||||
"user_summary": make_user_summary(name="Owner User", mobile="09129990001"),
|
"user_summary": make_variable_user_summary(name="Owner User", mobile="09129990001"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
**make_report_data(
|
**make_report_data(
|
||||||
@@ -120,26 +196,38 @@ class ReportExporterTests(TestCase):
|
|||||||
summary_sheet = workbook[workbook.sheetnames[0]]
|
summary_sheet = workbook[workbook.sheetnames[0]]
|
||||||
summary_values = list(summary_sheet.iter_rows(values_only=True))
|
summary_values = list(summary_sheet.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
self.assertEqual(summary_sheet.freeze_panes, "B1")
|
||||||
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:P15", {str(item) for item in summary_sheet.merged_cells.ranges})
|
merged_ranges = {str(item) for item in summary_sheet.merged_cells.ranges}
|
||||||
|
self.assertIn("A15:F15", merged_ranges)
|
||||||
|
self.assertIn("H15:J15", merged_ranges)
|
||||||
|
self.assertIn("L15:N15", merged_ranges)
|
||||||
|
self.assertIn("P15:R15", merged_ranges)
|
||||||
|
self.assertNotIn("A15:R15", merged_ranges)
|
||||||
|
self.assertIsNone(summary_sheet["G15"].fill.fill_type)
|
||||||
|
self.assertIsNone(summary_sheet["G16"].fill.fill_type)
|
||||||
|
self.assertIsNone(summary_sheet["K15"].fill.fill_type)
|
||||||
|
self.assertIsNone(summary_sheet["O15"].fill.fill_type)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:16],
|
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18],
|
||||||
(
|
(
|
||||||
"Name",
|
"Name",
|
||||||
"Mobile",
|
"Mobile",
|
||||||
"Working hours",
|
"Working hours",
|
||||||
"Non-working hours",
|
|
||||||
"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 %",
|
||||||
@@ -147,10 +235,15 @@ class ReportExporterTests(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
|
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
|
||||||
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
|
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
|
||||||
|
self.assertTrue(any(row and "Variable rate" in row for row in summary_values))
|
||||||
|
self.assertEqual(summary_sheet["A17"].border.top.style, "medium")
|
||||||
|
self.assertEqual(summary_sheet["A18"].border.top.style, "medium")
|
||||||
|
self.assertIsNone(summary_sheet["G17"].border.top)
|
||||||
|
|
||||||
user_sheet = workbook[workbook.sheetnames[1]]
|
user_sheet = workbook[workbook.sheetnames[1]]
|
||||||
user_values = list(user_sheet.iter_rows(values_only=True))
|
user_values = list(user_sheet.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
self.assertEqual(user_sheet.freeze_panes, "B1")
|
||||||
daily_header = next(row[:6] for row in user_values if row and "Date" in row)
|
daily_header = next(row[:6] for row in user_values if row and "Date" in row)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
daily_header,
|
daily_header,
|
||||||
@@ -169,13 +262,11 @@ class ReportExporterTests(TestCase):
|
|||||||
|
|
||||||
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
|
breakdown_header = next(row[:7] for row in user_values if row and row[0] == "Name" and row[2] == "Hour %")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
breakdown_header,
|
breakdown_header[:5],
|
||||||
(
|
(
|
||||||
"Name",
|
"Name",
|
||||||
"Billable hours",
|
"Billable hours",
|
||||||
"Hour %",
|
"Hour %",
|
||||||
"Non-billable hours",
|
|
||||||
"Total hours",
|
|
||||||
"Income",
|
"Income",
|
||||||
"Income %",
|
"Income %",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from apps.projects.models import Project
|
|||||||
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.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
class ReportViewTests(APITestCase):
|
class ReportViewTests(APITestCase):
|
||||||
@@ -320,6 +320,75 @@ class ReportViewTests(APITestCase):
|
|||||||
{"amount": "35.00", "currency": "USD"},
|
{"amount": "35.00", "currency": "USD"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_user_summary_endpoint_keeps_workspace_rate_history_and_marks_current_row_open(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=None,
|
||||||
|
description="Legacy workspace rate",
|
||||||
|
start_time="2026-04-08T08:00:00+03:30",
|
||||||
|
end_time="2026-04-08T09:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=self.project,
|
||||||
|
description="Current project rate",
|
||||||
|
start_time="2026-04-12T08:00:00+03:30",
|
||||||
|
end_time="2026-04-12T10:00:00+03:30",
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("25.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from="2026-04-01T00:00:00+03:30",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/user-summary/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
rate_periods = response.data["user_summary"]["rate_periods"]
|
||||||
|
self.assertEqual(
|
||||||
|
rate_periods,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": "12.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-08",
|
||||||
|
"to_date": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "25.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-10",
|
||||||
|
"to_date": "2026-04-12",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_custom_period_longer_than_31_days_is_rejected(self):
|
def test_custom_period_longer_than_31_days_is_rejected(self):
|
||||||
self.client.force_authenticate(user=self.owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
@@ -31,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer):
|
|||||||
tag_details = serializers.SerializerMethodField()
|
tag_details = serializers.SerializerMethodField()
|
||||||
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
||||||
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
||||||
|
start_time_ms = serializers.SerializerMethodField()
|
||||||
|
end_time_ms = serializers.SerializerMethodField()
|
||||||
|
server_now_ms = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _epoch_ms(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if timezone.is_naive(value):
|
||||||
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
return int(value.timestamp() * 1000)
|
||||||
|
|
||||||
|
def get_start_time_ms(self, obj):
|
||||||
|
return self._epoch_ms(obj.start_time)
|
||||||
|
|
||||||
|
def get_end_time_ms(self, obj):
|
||||||
|
return self._epoch_ms(obj.end_time)
|
||||||
|
|
||||||
|
def get_server_now_ms(self, obj):
|
||||||
|
server_now = self.context.get("server_now") or timezone.now()
|
||||||
|
return self._epoch_ms(server_now)
|
||||||
|
|
||||||
def get_tags(self, obj):
|
def get_tags(self, obj):
|
||||||
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
|
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
|
||||||
@@ -76,7 +98,10 @@ class TimeEntrySerializer(BaseModelSerializer):
|
|||||||
"project_details",
|
"project_details",
|
||||||
"description",
|
"description",
|
||||||
"start_time",
|
"start_time",
|
||||||
|
"start_time_ms",
|
||||||
"end_time",
|
"end_time",
|
||||||
|
"end_time_ms",
|
||||||
|
"server_now_ms",
|
||||||
"duration",
|
"duration",
|
||||||
"tags",
|
"tags",
|
||||||
"tag_details",
|
"tag_details",
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
filterset_class = TimeEntryFilter
|
filterset_class = TimeEntryFilter
|
||||||
search_fields = ["description", "project__name", "project__client__name", "tags__name"]
|
search_fields = ["description", "project__name", "project__client__name", "tags__name"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _epoch_ms(value):
|
||||||
|
if timezone.is_naive(value):
|
||||||
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
return int(value.timestamp() * 1000)
|
||||||
|
|
||||||
|
def _serializer_context(self, *, server_now=None):
|
||||||
|
context = self.get_serializer_context()
|
||||||
|
context["server_now"] = server_now or timezone.now()
|
||||||
|
return context
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_duration_ms(entry):
|
def _serialize_duration_ms(entry):
|
||||||
if entry.duration is not None:
|
if entry.duration is not None:
|
||||||
@@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
days_since_sunday = (local_dt.weekday() + 1) % 7
|
days_since_sunday = (local_dt.weekday() + 1) % 7
|
||||||
return (local_dt - timedelta(days=days_since_sunday)).date()
|
return (local_dt - timedelta(days=days_since_sunday)).date()
|
||||||
|
|
||||||
def _build_grouped_entries(self, entries):
|
def _build_grouped_entries(self, entries, *, server_now):
|
||||||
serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data
|
serialized_entries = TimeEntrySerializer(
|
||||||
|
entries,
|
||||||
|
many=True,
|
||||||
|
context=self._serializer_context(server_now=server_now),
|
||||||
|
).data
|
||||||
serialized_by_id = {item["id"]: item for item in serialized_entries}
|
serialized_by_id = {item["id"]: item for item in serialized_entries}
|
||||||
weeks = []
|
weeks = []
|
||||||
weeks_by_key = {}
|
weeks_by_key = {}
|
||||||
@@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
paginator = self.pagination_class()
|
paginator = self.pagination_class()
|
||||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||||
|
server_now = timezone.now()
|
||||||
current_items_count = len(page)
|
current_items_count = len(page)
|
||||||
has_more = (paginator.offset + current_items_count) < paginator.count
|
has_more = (paginator.offset + current_items_count) < paginator.count
|
||||||
|
|
||||||
@@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
"offset": paginator.offset,
|
"offset": paginator.offset,
|
||||||
"next_offset": paginator.offset + current_items_count if has_more else None,
|
"next_offset": paginator.offset + current_items_count if has_more else None,
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
"groups": self._build_grouped_entries(page),
|
"server_now_ms": self._epoch_ms(server_now),
|
||||||
|
"server_now": server_now.isoformat(),
|
||||||
|
"groups": self._build_grouped_entries(page, server_now=server_now),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], url_path="debug-time")
|
||||||
|
def debug_time(self, request):
|
||||||
|
server_now = timezone.now()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"server_now_ms": self._epoch_ms(server_now),
|
||||||
|
"server_now": server_now.isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,7 +176,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(entry, context=self._serializer_context())
|
||||||
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):
|
||||||
@@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(updated_entry, context=self._serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
@@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
end_time = serializer.validated_data.get("end_time")
|
end_time = serializer.validated_data.get("end_time")
|
||||||
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(stopped_entry, context=self._serializer_context())
|
||||||
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):
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
from apps.projects.services.access import user_has_project_access
|
||||||
|
from apps.projects.services.rates import get_current_project_user_rate
|
||||||
from apps.workspaces.models import WorkspaceUserRate
|
from apps.workspaces.models import WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
def resolve_rate(user, project):
|
def resolve_rate(user, project):
|
||||||
|
if user_has_project_access(user, project):
|
||||||
|
project_user_rate = get_current_project_user_rate(project=project, user=user)
|
||||||
|
if project_user_rate:
|
||||||
|
return project_user_rate.hourly_rate, project_user_rate.currency
|
||||||
|
|
||||||
workspace_user_rate = WorkspaceUserRate.objects.filter(
|
workspace_user_rate = WorkspaceUserRate.objects.filter(
|
||||||
user=user,
|
user=user,
|
||||||
workspace=project.workspace,
|
workspace=project.workspace,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.services.time_entries import (
|
from apps.time_entries.services.time_entries import (
|
||||||
create_time_entry,
|
create_time_entry,
|
||||||
@@ -12,14 +13,21 @@ from apps.time_entries.services.time_entries import (
|
|||||||
update_time_entry,
|
update_time_entry,
|
||||||
)
|
)
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace
|
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
class TimeEntryServiceTests(TestCase):
|
class TimeEntryServiceTests(TestCase):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
cls.user = User.objects.create_user(mobile="09121111111", password="secret123")
|
||||||
|
cls.member = User.objects.create_user(mobile="09121111112", password="secret123")
|
||||||
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
|
cls.workspace = Workspace.objects.create(name="Core", owner=cls.user)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
|
def test_create_time_entry_allows_only_one_running_timer_per_workspace(self):
|
||||||
create_time_entry(
|
create_time_entry(
|
||||||
@@ -97,3 +105,36 @@ class TimeEntryServiceTests(TestCase):
|
|||||||
),
|
),
|
||||||
[tag.id],
|
[tag.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_create_billable_time_entry_uses_project_user_rate_override(self):
|
||||||
|
project = Project.objects.create(workspace=self.workspace, name="Override project")
|
||||||
|
ProjectAccess.objects.create(project=project, user=self.member)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("10.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=self.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
ProjectUserRate.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("20.00"),
|
||||||
|
currency="EUR",
|
||||||
|
effective_from=self.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = create_time_entry(
|
||||||
|
user=self.member,
|
||||||
|
workspace_id=self.workspace.id,
|
||||||
|
start_time=timezone.now() - timedelta(minutes=30),
|
||||||
|
end_time=timezone.now(),
|
||||||
|
project=project,
|
||||||
|
description="Billable work",
|
||||||
|
is_billable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(entry.hourly_rate, Decimal("20.00"))
|
||||||
|
self.assertEqual(entry.currency, "EUR")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -40,6 +40,9 @@ class TimeEntryViewTests(APITestCase):
|
|||||||
self.assertIsNone(entry.end_time)
|
self.assertIsNone(entry.end_time)
|
||||||
self.assertGreaterEqual(entry.start_time, before)
|
self.assertGreaterEqual(entry.start_time, before)
|
||||||
self.assertLessEqual(entry.start_time, after)
|
self.assertLessEqual(entry.start_time, after)
|
||||||
|
self.assertIsInstance(response.data["start_time_ms"], int)
|
||||||
|
self.assertIsNone(response.data["end_time_ms"])
|
||||||
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
|
|
||||||
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
|
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
|
||||||
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
user = User.objects.create_user(mobile="09126666666", password="secret123")
|
||||||
@@ -72,6 +75,8 @@ class TimeEntryViewTests(APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.data["current_page_items_count"], 1)
|
self.assertEqual(response.data["current_page_items_count"], 1)
|
||||||
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
|
self.assertIn("server_now", response.data)
|
||||||
self.assertFalse(response.data["has_more"])
|
self.assertFalse(response.data["has_more"])
|
||||||
self.assertEqual(len(response.data["groups"]), 1)
|
self.assertEqual(len(response.data["groups"]), 1)
|
||||||
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
|
self.assertEqual(len(response.data["groups"][0]["days"]), 1)
|
||||||
@@ -79,6 +84,41 @@ class TimeEntryViewTests(APITestCase):
|
|||||||
response.data["groups"][0]["days"][0]["entries"][0]["id"],
|
response.data["groups"][0]["days"][0]["entries"][0]["id"],
|
||||||
str(first_entry.id),
|
str(first_entry.id),
|
||||||
)
|
)
|
||||||
|
entry_payload = response.data["groups"][0]["days"][0]["entries"][0]
|
||||||
|
self.assertIsInstance(entry_payload["start_time_ms"], int)
|
||||||
|
self.assertIsInstance(entry_payload["end_time_ms"], int)
|
||||||
|
self.assertIsInstance(entry_payload["server_now_ms"], int)
|
||||||
|
|
||||||
|
def test_debug_time_returns_server_clock_payload(self):
|
||||||
|
user = User.objects.create_user(mobile="09126666667", password="secret123")
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/time-entries/debug-time/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
|
self.assertIn("server_now", response.data)
|
||||||
|
|
||||||
|
def test_stop_running_time_entry_returns_server_epoch_fields(self):
|
||||||
|
user = User.objects.create_user(mobile="09126666668", password="secret123")
|
||||||
|
workspace = Workspace.objects.create(name="Core", owner=user)
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
description="Running work",
|
||||||
|
start_time=timezone.now() - timedelta(seconds=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
response = self.client.post(f"/api/time-entries/{entry.id}/stop/", {}, format="json")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsInstance(response.data["start_time_ms"], int)
|
||||||
|
self.assertIsInstance(response.data["end_time_ms"], int)
|
||||||
|
self.assertIsInstance(response.data["server_now_ms"], int)
|
||||||
|
entry.refresh_from_db()
|
||||||
|
self.assertIsNotNone(entry.duration)
|
||||||
|
self.assertGreaterEqual(entry.duration.total_seconds(), 5)
|
||||||
|
|
||||||
def test_time_entry_update_preserves_current_deleted_tags(self):
|
def test_time_entry_update_preserves_current_deleted_tags(self):
|
||||||
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
user = User.objects.create_user(mobile="09127777777", password="secret123")
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ from apps.users.email_identity import normalize_email_identity
|
|||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
INVALID_MOBILE_FORMAT_MESSAGE = "\u0641\u0631\u0645\u062a \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 \u0646\u0627\u062f\u0631\u0633\u062a \u0627\u0633\u062a."
|
INVALID_MOBILE_FORMAT_MESSAGE = "فرمت شماره موبایل نادرست است."
|
||||||
INVALID_MOBILE_NUMBER_MESSAGE = "\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 \u0645\u0639\u062a\u0628\u0631 \u0646\u06cc\u0633\u062a."
|
INVALID_MOBILE_NUMBER_MESSAGE = "شماره موبایل معتبر نیست."
|
||||||
PASSWORD_MISMATCH_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0645\u0637\u0627\u0628\u0642\u062a \u0646\u062f\u0627\u0631\u062f."
|
PASSWORD_MISMATCH_MESSAGE = "رمز عبور مطابقت ندارد."
|
||||||
NEW_PASSWORD_MISMATCH_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0648 \u062a\u06a9\u0631\u0627\u0631 \u0622\u0646 \u0645\u0637\u0627\u0628\u0642\u062a \u0646\u062f\u0627\u0631\u0646\u062f."
|
NEW_PASSWORD_MISMATCH_MESSAGE = "رمز عبور جدید و تکرار آن مطابقت ندارند."
|
||||||
PASSWORD_REUSE_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0646\u0628\u0627\u06cc\u062f \u0628\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0642\u0628\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0628\u0627\u0634\u062f."
|
PASSWORD_REUSE_MESSAGE = "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد."
|
||||||
|
|
||||||
|
|
||||||
def _raise_password_validation_error(password: str, *, user, field_name: str) -> None:
|
def _raise_password_validation_error(password: str, *, user, field_name: str) -> None:
|
||||||
@@ -212,10 +212,12 @@ class UserProfileSerializer(BaseModelSerializer):
|
|||||||
"profile_picture",
|
"profile_picture",
|
||||||
"birth_date",
|
"birth_date",
|
||||||
"is_verified",
|
"is_verified",
|
||||||
|
"is_demo",
|
||||||
|
"demo_expires_at",
|
||||||
"full_name",
|
"full_name",
|
||||||
"age",
|
"age",
|
||||||
)
|
)
|
||||||
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified")
|
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified", "is_demo", "demo_expires_at")
|
||||||
|
|
||||||
|
|
||||||
class UserSearchSerializer(serializers.ModelSerializer):
|
class UserSearchSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -293,6 +293,11 @@ class ChangePasswordView(APIView):
|
|||||||
|
|
||||||
@extend_schema(request=ChangePasswordSerializer)
|
@extend_schema(request=ChangePasswordSerializer)
|
||||||
def patch(self, request, *args, **kwargs):
|
def patch(self, request, *args, **kwargs):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot change passwords."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
|
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@@ -327,6 +332,11 @@ class SetPasswordView(UpdateAPIView):
|
|||||||
|
|
||||||
@extend_schema(request=ChangePasswordSerializer, responses=None)
|
@extend_schema(request=ChangePasswordSerializer, responses=None)
|
||||||
def patch(self, request, *args, **kwargs):
|
def patch(self, request, *args, **kwargs):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot change passwords."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
return super().patch(request, *args, **kwargs)
|
return super().patch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
@@ -347,6 +357,11 @@ class ProfilePictureView(APIView):
|
|||||||
operation_id="users_profile_picture_self_create",
|
operation_id="users_profile_picture_self_create",
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot upload profile pictures."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
serializer = UserProfilePictureSerializer(
|
serializer = UserProfilePictureSerializer(
|
||||||
instance=request.user,
|
instance=request.user,
|
||||||
data=request.data,
|
data=request.data,
|
||||||
@@ -362,6 +377,11 @@ class ProfilePictureView(APIView):
|
|||||||
operation_id="users_profile_picture_self_delete",
|
operation_id="users_profile_picture_self_delete",
|
||||||
)
|
)
|
||||||
def delete(self, request):
|
def delete(self, request):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot remove profile pictures."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
request.user.profile_picture.delete(save=False)
|
request.user.profile_picture.delete(save=False)
|
||||||
request.user.profile_picture = None
|
request.user.profile_picture = None
|
||||||
request.user.save(update_fields=["profile_picture", "updated_at"])
|
request.user.save(update_fields=["profile_picture", "updated_at"])
|
||||||
@@ -401,6 +421,11 @@ class UserSearchAPIView(APIView):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot search external users."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
mobile = request.query_params.get('mobile')
|
mobile = request.query_params.get('mobile')
|
||||||
if not mobile:
|
if not mobile:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
25
apps/users/migrations/0004_user_demo_fields.py
Normal file
25
apps/users/migrations/0004_user_demo_fields.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0003_normalize_user_email_identity"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="demo_expires_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="is_demo",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(fields=["is_demo", "demo_expires_at"], name="user_demo_expires_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,6 +22,8 @@ class User(AbstractUser, BaseModel):
|
|||||||
|
|
||||||
password_updated_at = models.DateTimeField(blank=True, null=True)
|
password_updated_at = models.DateTimeField(blank=True, null=True)
|
||||||
is_verified = models.BooleanField(default=False)
|
is_verified = models.BooleanField(default=False)
|
||||||
|
is_demo = models.BooleanField(default=False)
|
||||||
|
demo_expires_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
USERNAME_FIELD = "mobile"
|
USERNAME_FIELD = "mobile"
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
@@ -63,6 +65,7 @@ class User(AbstractUser, BaseModel):
|
|||||||
indexes = (
|
indexes = (
|
||||||
models.Index(fields=["id"], name="user_id_idx"),
|
models.Index(fields=["id"], name="user_id_idx"),
|
||||||
models.Index(fields=["mobile"], name="user_mobile_idx"),
|
models.Index(fields=["mobile"], name="user_mobile_idx"),
|
||||||
|
models.Index(fields=["is_demo", "demo_expires_at"], name="user_demo_expires_idx"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from apps.users.utils import record_login_attempt
|
|||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
USER_ALREADY_EXISTS_MESSAGE = "User already exists."
|
USER_ALREADY_EXISTS_MESSAGE = "User already exists."
|
||||||
PASSWORD_REUSE_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0646\u0628\u0627\u06cc\u062f \u0628\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0642\u0628\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0628\u0627\u0634\u062f."
|
PASSWORD_REUSE_MESSAGE = "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد."
|
||||||
OTP_EXPIRY_SECONDS = 120
|
OTP_EXPIRY_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
@@ -55,15 +55,15 @@ def register_user_with_password(mobile, password):
|
|||||||
def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
|
def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
|
||||||
"""Business logic for verifying OTP and registering a user."""
|
"""Business logic for verifying OTP and registering a user."""
|
||||||
if User.objects.filter(mobile=mobile).exists():
|
if User.objects.filter(mobile=mobile).exists():
|
||||||
raise ValidationError({"mobile": "\u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u0642\u0628\u0644\u0627\u064b \u062b\u0628\u062a \u0634\u062f\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"mobile": "این شماره قبلاً ثبت شده است."})
|
||||||
|
|
||||||
redis_conn = get_redis_connection("default")
|
redis_conn = get_redis_connection("default")
|
||||||
stored_code = redis_conn.get(f"verification_code:{mobile}")
|
stored_code = redis_conn.get(f"verification_code:{mobile}")
|
||||||
|
|
||||||
if not stored_code:
|
if not stored_code:
|
||||||
raise ValidationError({"code": "\u06a9\u062f \u062a\u0623\u06cc\u06cc\u062f \u06cc\u0627\u0641\u062a \u0646\u0634\u062f."})
|
raise ValidationError({"code": "کد تأیید یافت نشد."})
|
||||||
if stored_code.decode("utf-8") != code:
|
if stored_code.decode("utf-8") != code:
|
||||||
raise ValidationError({"code": "\u06a9\u062f \u062a\u0623\u06cc\u06cc\u062f \u0627\u0634\u062a\u0628\u0627\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"code": "کد تأیید اشتباه است."})
|
||||||
|
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
mobile=mobile,
|
mobile=mobile,
|
||||||
@@ -81,13 +81,17 @@ def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
|
|||||||
|
|
||||||
def generate_and_send_otp(mobile, mode):
|
def generate_and_send_otp(mobile, mode):
|
||||||
"""Business logic for generating OTP, checking existence rules, and sending SMS."""
|
"""Business logic for generating OTP, checking existence rules, and sending SMS."""
|
||||||
user_exists = User.objects.filter(mobile=mobile).exists()
|
user = User.objects.filter(mobile=mobile).only("is_demo").first()
|
||||||
|
user_exists = user is not None
|
||||||
|
|
||||||
|
if user and user.is_demo:
|
||||||
|
raise ValidationError({"mobile": "Demo accounts cannot use SMS verification."})
|
||||||
|
|
||||||
if mode == "register" and user_exists:
|
if mode == "register" and user_exists:
|
||||||
raise ValidationError({"mobile": "\u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u0642\u0628\u0644\u0627\u064b \u062b\u0628\u062a\u200c\u0646\u0627\u0645 \u0634\u062f\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"mobile": "این شماره قبلاً ثبتنام شده است."})
|
||||||
|
|
||||||
if mode in ["login", "forget_password"] and not user_exists:
|
if mode in ["login", "forget_password"] and not user_exists:
|
||||||
raise ValidationError({"mobile": "\u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u06cc\u0627\u0641\u062a \u0646\u0634\u062f."})
|
raise ValidationError({"mobile": "این شماره یافت نشد."})
|
||||||
|
|
||||||
verification_code = "".join(random.choices(string.digits, k=5))
|
verification_code = "".join(random.choices(string.digits, k=5))
|
||||||
|
|
||||||
@@ -109,10 +113,10 @@ def login_with_password(mobile, password, request=None):
|
|||||||
|
|
||||||
if not user or not user.check_password(password):
|
if not user or not user.check_password(password):
|
||||||
record_login_attempt(request, user, LoginAttempt.StatusType.FAILED)
|
record_login_attempt(request, user, LoginAttempt.StatusType.FAILED)
|
||||||
raise ValidationError({"detail": "\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 \u06cc\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0627\u0634\u062a\u0628\u0627\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"detail": "شماره موبایل یا رمز عبور اشتباه است."})
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ValidationError({"detail": "\u062d\u0633\u0627\u0628 \u06a9\u0627\u0631\u0628\u0631\u06cc \u0634\u0645\u0627 \u063a\u06cc\u0631\u0641\u0639\u0627\u0644 \u0634\u062f\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"detail": "حساب کاربری شما غیرفعال شده است."})
|
||||||
|
|
||||||
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
|
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
|
||||||
return get_tokens_for_user(user)
|
return get_tokens_for_user(user)
|
||||||
@@ -125,7 +129,7 @@ def login_with_otp(mobile, code, request=None):
|
|||||||
|
|
||||||
if not stored_code or stored_code.decode("utf-8") != code:
|
if not stored_code or stored_code.decode("utf-8") != code:
|
||||||
record_login_attempt(request, None, LoginAttempt.StatusType.FAILED)
|
record_login_attempt(request, None, LoginAttempt.StatusType.FAILED)
|
||||||
raise ValidationError({"code": "\u06a9\u062f \u062a\u0627\u06cc\u06cc\u062f \u0646\u0627\u0645\u0639\u062a\u0628\u0631 \u0627\u0633\u062a \u06cc\u0627 \u0645\u0646\u0642\u0636\u06cc \u0634\u062f\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"code": "کد تایید نامعتبر است یا منقضی شده است."})
|
||||||
|
|
||||||
user, created = User.objects.get_or_create(mobile=mobile)
|
user, created = User.objects.get_or_create(mobile=mobile)
|
||||||
if created:
|
if created:
|
||||||
@@ -133,7 +137,7 @@ def login_with_otp(mobile, code, request=None):
|
|||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ValidationError({"detail": "\u062d\u0633\u0627\u0628 \u06a9\u0627\u0631\u0628\u0631\u06cc \u0634\u0645\u0627 \u063a\u06cc\u0631\u0641\u0639\u0627\u0644 \u0634\u062f\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"detail": "حساب کاربری شما غیرفعال شده است."})
|
||||||
|
|
||||||
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
|
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
|
||||||
redis_conn.delete(f"verification_code:{mobile}")
|
redis_conn.delete(f"verification_code:{mobile}")
|
||||||
@@ -145,13 +149,13 @@ def reset_password_with_otp(mobile, code, password):
|
|||||||
"""Verify OTP and change forgotten password."""
|
"""Verify OTP and change forgotten password."""
|
||||||
user = User.objects.filter(mobile=mobile).first()
|
user = User.objects.filter(mobile=mobile).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise ValidationError({"mobile": "\u06a9\u0627\u0631\u0628\u0631\u06cc \u0628\u0627 \u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u06cc\u0627\u0641\u062a \u0646\u0634\u062f."})
|
raise ValidationError({"mobile": "کاربری با این شماره یافت نشد."})
|
||||||
|
|
||||||
redis_conn = get_redis_connection("default")
|
redis_conn = get_redis_connection("default")
|
||||||
stored_code = redis_conn.get(f"verification_code:{mobile}")
|
stored_code = redis_conn.get(f"verification_code:{mobile}")
|
||||||
|
|
||||||
if not stored_code or stored_code.decode("utf-8") != code:
|
if not stored_code or stored_code.decode("utf-8") != code:
|
||||||
raise ValidationError({"code": "\u06a9\u062f \u062a\u0627\u06cc\u06cc\u062f \u0646\u0627\u0645\u0639\u062a\u0628\u0631 \u0627\u0633\u062a \u06cc\u0627 \u0645\u0646\u0642\u0636\u06cc \u0634\u062f\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"code": "کد تایید نامعتبر است یا منقضی شده است."})
|
||||||
|
|
||||||
_validate_new_password(password, user=user, field_name="password")
|
_validate_new_password(password, user=user, field_name="password")
|
||||||
|
|
||||||
@@ -167,7 +171,7 @@ def reset_password_with_otp(mobile, code, password):
|
|||||||
def change_password(user, old_password, new_password):
|
def change_password(user, old_password, new_password):
|
||||||
"""Change password for an already authenticated user."""
|
"""Change password for an already authenticated user."""
|
||||||
if not user.check_password(old_password):
|
if not user.check_password(old_password):
|
||||||
raise ValidationError({"old_password": "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0641\u0639\u0644\u06cc \u0627\u0634\u062a\u0628\u0627\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"old_password": "رمز عبور فعلی اشتباه است."})
|
||||||
|
|
||||||
_validate_new_password(new_password, user=user, field_name="new_password")
|
_validate_new_password(new_password, user=user, field_name="new_password")
|
||||||
|
|
||||||
@@ -182,9 +186,9 @@ def change_password(user, old_password, new_password):
|
|||||||
def logout_user(refresh_token_str):
|
def logout_user(refresh_token_str):
|
||||||
"""Blacklist the user's refresh token."""
|
"""Blacklist the user's refresh token."""
|
||||||
if not refresh_token_str:
|
if not refresh_token_str:
|
||||||
raise ValidationError({"refresh": "\u062a\u0648\u06a9\u0646 \u0631\u0641\u0631\u0634 \u0627\u0644\u0632\u0627\u0645\u06cc \u0627\u0633\u062a."})
|
raise ValidationError({"refresh": "توکن رفرش الزامی است."})
|
||||||
try:
|
try:
|
||||||
token = RefreshToken(refresh_token_str)
|
token = RefreshToken(refresh_token_str)
|
||||||
token.blacklist()
|
token.blacklist()
|
||||||
except TokenError:
|
except TokenError:
|
||||||
raise ValidationError({"detail": "\u062a\u0648\u06a9\u0646 \u0646\u0627\u0645\u0639\u062a\u0628\u0631 \u0627\u0633\u062a \u06cc\u0627 \u0642\u0628\u0644\u0627 \u0645\u0646\u0642\u0636\u06cc \u0634\u062f\u0647 \u0627\u0633\u062a."})
|
raise ValidationError({"detail": "توکن نامعتبر است یا قبلا منقضی شده است."})
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from django.shortcuts import get_object_or_404
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||||
|
from rest_framework.decorators import action
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
@@ -15,6 +16,8 @@ from apps.notifications.services import (
|
|||||||
notify_workspace_membership_removed,
|
notify_workspace_membership_removed,
|
||||||
notify_workspace_membership_role_changed,
|
notify_workspace_membership_role_changed,
|
||||||
)
|
)
|
||||||
|
from apps.projects.models import ProjectUserRate
|
||||||
|
from apps.projects.services.access import filter_projects_for_user
|
||||||
from apps.workspaces.api.permissions import (
|
from apps.workspaces.api.permissions import (
|
||||||
CanWorkspaceManageMembers,
|
CanWorkspaceManageMembers,
|
||||||
IsWorkspaceAdmin,
|
IsWorkspaceAdmin,
|
||||||
@@ -78,6 +81,8 @@ class WorkspaceViewSet(ModelViewSet):
|
|||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.action in ["list", "retrieve"]:
|
if self.action in ["list", "retrieve"]:
|
||||||
return [IsAuthenticated(), IsWorkspaceMember()]
|
return [IsAuthenticated(), IsWorkspaceMember()]
|
||||||
|
if self.action == "my_rates":
|
||||||
|
return [IsAuthenticated()]
|
||||||
if self.action in ["update", "partial_update"]:
|
if self.action in ["update", "partial_update"]:
|
||||||
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
return [IsAuthenticated(), IsWorkspaceAdmin()]
|
||||||
|
|
||||||
@@ -86,8 +91,94 @@ class WorkspaceViewSet(ModelViewSet):
|
|||||||
|
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
serializer.save(owner=self.request.user)
|
serializer.save(owner=self.request.user)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot create additional workspaces."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["get"], url_path="my-rates")
|
||||||
|
def my_rates(self, request, pk=None):
|
||||||
|
workspace = self.get_object()
|
||||||
|
if not has_workspace_capability(request.user, workspace, WORKSPACE_VIEW):
|
||||||
|
raise PermissionDenied("You do not have access to this workspace.")
|
||||||
|
|
||||||
|
def serialize_rate(rate):
|
||||||
|
if not rate:
|
||||||
|
return None
|
||||||
|
unit = PriceUnit.objects.filter(code=rate.currency, is_deleted=False).first()
|
||||||
|
return {
|
||||||
|
"id": str(rate.id),
|
||||||
|
"hourly_rate": str(rate.hourly_rate),
|
||||||
|
"currency": rate.currency,
|
||||||
|
"price_unit": PriceUnitSerializer(unit).data if unit else None,
|
||||||
|
"effective_from": rate.effective_from.isoformat() if rate.effective_from else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace_rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
user=request.user,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
accessible_projects = list(
|
||||||
|
filter_projects_for_user(
|
||||||
|
request.user,
|
||||||
|
workspace.projects.filter(is_deleted=False).select_related("client"),
|
||||||
|
).order_by("client__name", "name")
|
||||||
|
)
|
||||||
|
accessible_project_ids = [project.id for project in accessible_projects]
|
||||||
|
project_rates_by_project_id = {}
|
||||||
|
for rate in (
|
||||||
|
ProjectUserRate.objects.filter(
|
||||||
|
project_id__in=accessible_project_ids,
|
||||||
|
user=request.user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
.select_related("project", "project__client")
|
||||||
|
.order_by("project_id", "-effective_from", "-updated_at")
|
||||||
|
):
|
||||||
|
project_rates_by_project_id.setdefault(str(rate.project_id), rate)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"workspace": {
|
||||||
|
"id": str(workspace.id),
|
||||||
|
"name": workspace.name,
|
||||||
|
},
|
||||||
|
"workspace_rate": serialize_rate(workspace_rate),
|
||||||
|
"accessible_project_count": len(accessible_projects),
|
||||||
|
"project_rates": [
|
||||||
|
{
|
||||||
|
"project": {
|
||||||
|
"id": str(project.id),
|
||||||
|
"name": project.name,
|
||||||
|
"client": (
|
||||||
|
{"id": str(project.client_id), "name": project.client.name}
|
||||||
|
if project.client_id and project.client
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"rate": serialize_rate(project_rates_by_project_id[str(project.id)]),
|
||||||
|
}
|
||||||
|
for project in accessible_projects
|
||||||
|
if str(project.id) in project_rates_by_project_id
|
||||||
|
],
|
||||||
|
}
|
||||||
|
payload["project_override_count"] = len(payload["project_rates"])
|
||||||
|
payload["workspace_fallback_project_count"] = max(
|
||||||
|
payload["accessible_project_count"] - payload["project_override_count"],
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceMembershipViewSet(ModelViewSet):
|
class WorkspaceMembershipViewSet(ModelViewSet):
|
||||||
@@ -163,7 +254,12 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
workspace = get_object_or_404(Workspace, id=workspace_id)
|
workspace = get_object_or_404(Workspace, id=workspace_id)
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot add workspace members."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
permission = IsWorkspaceAdmin()
|
permission = IsWorkspaceAdmin()
|
||||||
if not permission.has_object_permission(request, self, workspace):
|
if not permission.has_object_permission(request, self, workspace):
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from django.core.cache import cache
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.api.permissions import (
|
from apps.workspaces.api.permissions import (
|
||||||
CanWorkspaceManageMembers,
|
CanWorkspaceManageMembers,
|
||||||
@@ -11,7 +12,7 @@ from apps.workspaces.api.permissions import (
|
|||||||
IsWorkspaceMember,
|
IsWorkspaceMember,
|
||||||
IsWorkspaceOwner,
|
IsWorkspaceOwner,
|
||||||
)
|
)
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
class WorkspacePermissionTests(TestCase):
|
class WorkspacePermissionTests(TestCase):
|
||||||
@@ -189,3 +190,48 @@ class WorkspaceMembershipCacheTests(APITestCase):
|
|||||||
target = next(item for item in fresh_response.data["items"] if item["id"] == str(self.membership.id))
|
target = next(item for item in fresh_response.data["items"] if item["id"] == str(self.membership.id))
|
||||||
self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST)
|
self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST)
|
||||||
self.assertFalse(target["is_active"])
|
self.assertFalse(target["is_active"])
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMyRatesApiTests(APITestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.owner = User.objects.create_user(mobile="09127770101", password="secret123")
|
||||||
|
cls.member = User.objects.create_user(mobile="09127770102", password="secret123")
|
||||||
|
cls.workspace = Workspace.objects.create(name="Rates View", owner=cls.owner)
|
||||||
|
WorkspaceMembership.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
PriceUnit.objects.create(code="USD", name="US Dollar", local_name="Dollar", symbol="$")
|
||||||
|
cls.project = Project.objects.create(workspace=cls.workspace, name="Mobile App")
|
||||||
|
ProjectAccess.objects.create(project=cls.project, user=cls.member)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=cls.workspace,
|
||||||
|
user=cls.member,
|
||||||
|
hourly_rate="10.00",
|
||||||
|
currency="USD",
|
||||||
|
effective_from=cls.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
ProjectUserRate.objects.create(
|
||||||
|
project=cls.project,
|
||||||
|
user=cls.member,
|
||||||
|
hourly_rate="18.00",
|
||||||
|
currency="USD",
|
||||||
|
effective_from=cls.workspace.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_member_can_view_own_workspace_and_project_rates(self):
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/workspaces/{self.workspace.id}/my-rates/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["workspace_rate"]["hourly_rate"], "10.00")
|
||||||
|
self.assertEqual(response.data["project_override_count"], 1)
|
||||||
|
self.assertEqual(response.data["workspace_fallback_project_count"], 0)
|
||||||
|
self.assertEqual(response.data["project_rates"][0]["project"]["name"], "Mobile App")
|
||||||
|
self.assertEqual(response.data["project_rates"][0]["rate"]["hourly_rate"], "18.00")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.core.cache import cache
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
from apps.projects.models import Project
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
from apps.time_entries.services.rates import resolve_rate
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import (
|
from apps.workspaces.models import (
|
||||||
@@ -78,6 +78,53 @@ class WorkspaceRateTests(APITestCase):
|
|||||||
self.assertIsNone(hourly_rate)
|
self.assertIsNone(hourly_rate)
|
||||||
self.assertEqual(currency, "")
|
self.assertEqual(currency, "")
|
||||||
|
|
||||||
|
def test_resolve_rate_prefers_project_user_rate_when_member_has_access(self):
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("40.00"),
|
||||||
|
currency="EUR",
|
||||||
|
effective_from=self.project.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
ProjectAccess.objects.create(project=self.project, user=self.member)
|
||||||
|
ProjectUserRate.objects.create(
|
||||||
|
project=self.project,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("55.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=self.project.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
hourly_rate, currency = resolve_rate(self.member, self.project)
|
||||||
|
|
||||||
|
self.assertEqual(hourly_rate, Decimal("55.00"))
|
||||||
|
self.assertEqual(currency, "USD")
|
||||||
|
|
||||||
|
def test_resolve_rate_ignores_project_user_rate_without_access(self):
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("40.00"),
|
||||||
|
currency="EUR",
|
||||||
|
effective_from=self.project.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
ProjectUserRate.objects.create(
|
||||||
|
project=self.project,
|
||||||
|
user=self.member,
|
||||||
|
hourly_rate=Decimal("55.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from=self.project.created_at,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
hourly_rate, currency = resolve_rate(self.member, self.project)
|
||||||
|
|
||||||
|
self.assertEqual(hourly_rate, Decimal("40.00"))
|
||||||
|
self.assertEqual(currency, "EUR")
|
||||||
|
|
||||||
def test_admin_can_manage_workspace_user_rates(self):
|
def test_admin_can_manage_workspace_user_rates(self):
|
||||||
self.client.force_authenticate(user=self.admin)
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -48,6 +49,8 @@ LOCAL_APPS = [
|
|||||||
"apps.notifications",
|
"apps.notifications",
|
||||||
"apps.reports",
|
"apps.reports",
|
||||||
"apps.logs",
|
"apps.logs",
|
||||||
|
"apps.demos",
|
||||||
|
"apps.contacts",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@@ -138,6 +141,8 @@ REST_FRAMEWORK = {
|
|||||||
"otp_send_sustained": "10/day",
|
"otp_send_sustained": "10/day",
|
||||||
"login_password": "5/10m",
|
"login_password": "5/10m",
|
||||||
"login_otp": "5/10m",
|
"login_otp": "5/10m",
|
||||||
|
"demo_start": os.getenv("DEMO_START_RATE_LIMIT", "10/hour"),
|
||||||
|
"contact_submission": os.getenv("CONTACT_SUBMISSION_RATE_LIMIT", "5/hour"),
|
||||||
},
|
},
|
||||||
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
|
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
|
||||||
}
|
}
|
||||||
@@ -243,7 +248,21 @@ NOTIFICATION_TOAST_LEVELS = tuple(
|
|||||||
|
|
||||||
REPORT_EXPORT_RETENTION_DAYS = int(os.getenv("REPORT_EXPORT_RETENTION_DAYS", "7"))
|
REPORT_EXPORT_RETENTION_DAYS = int(os.getenv("REPORT_EXPORT_RETENTION_DAYS", "7"))
|
||||||
|
|
||||||
CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks", "apps.reports.tasks")
|
DEMO_ENABLED = os.getenv("DEMO_ENABLED", "True") == "True"
|
||||||
|
DEMO_ENVIRONMENT_TTL_HOURS = int(os.getenv("DEMO_ENVIRONMENT_TTL_HOURS", "24"))
|
||||||
|
DEMO_CLEANUP_BATCH_SIZE = int(os.getenv("DEMO_CLEANUP_BATCH_SIZE", "100"))
|
||||||
|
|
||||||
|
CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks", "apps.reports.tasks", "apps.demos.tasks")
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"reports-cleanup-expired-exports": {
|
||||||
|
"task": "reports.cleanup_expired_exports",
|
||||||
|
"schedule": crontab(minute=0, hour="*/6"),
|
||||||
|
},
|
||||||
|
"demos-cleanup-expired-environments": {
|
||||||
|
"task": "demos.cleanup_expired_environments",
|
||||||
|
"schedule": crontab(minute=0, hour="*"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ urlpatterns = [
|
|||||||
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
|
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
|
||||||
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
||||||
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
|
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
|
||||||
|
path("api/demo/", include("apps.demos.api.urls"), name="demos"),
|
||||||
|
path("api/contact/", include("apps.contacts.api.urls"), name="contacts"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user