Compare commits

...

17 Commits

Author SHA1 Message Date
027afb7e23 feat(contacts): store contact submissions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 14:09:38 +03:30
170ec90ec1 fix(demo): block external account actions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 00:50:42 +03:30
30a324c6f4 feat(demo): add isolated demo environments 2026-06-07 00:49:58 +03:30
da40720a0f fix(reports): freeze first excel column
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 17:22:34 +03:30
948a8e1b75 fix(reports): improve excel summary table spacing 2026-05-26 17:20:18 +03:30
b5ddcb76aa fix(timezone): fix timer clock-skew
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:59:49 +03:30
20874b9968 feat(reports): improve summary rates and export formatting
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:15:44 +03:30
af9facce7e feat(rates): record hourly rate history 2026-05-26 12:15:27 +03:30
e42e0612aa feat(media): add client and project thumbnails 2026-05-26 12:15:09 +03:30
f99e883f12 feat(reports): sort exported breakdown tables
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-25 00:10:28 +03:30
d18fdb1454 refactor(reports): replace escaped persian export labels
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:16:59 +03:30
5500badc6a refactor(users): replace escaped persian auth messages 2026-05-24 11:01:50 +03:30
2a0fa22be6 feat(projects): support implicit-access roles in rates modal 2026-05-24 10:18:31 +03:30
22e08a099c fix(reports): refine financial export summaries
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-23 20:13:35 +03:30
59cf62bc73 feat(reports): load user summaries on demand 2026-05-23 19:48:32 +03:30
0d6c6a4f09 feat(workspaces): add current user rates endpoint 2026-05-23 19:43:10 +03:30
181a135df9 feat(projects): add project-specific member rates 2026-05-23 18:29:00 +03:30
70 changed files with 2809 additions and 393 deletions

View File

@@ -3,32 +3,65 @@ from apps.clients.models import Client
from core.serializers.base import BaseModelSerializer
class ClientSerializer(BaseModelSerializer):
class ClientSerializer(BaseModelSerializer):
"""
Serializer for retrieving and representing client details.
"""
class Meta:
model = Client
fields = BaseModelSerializer.Meta.fields + (
"workspace",
"name",
"notes",
)
read_only_fields = fields
fields = BaseModelSerializer.Meta.fields + (
"workspace",
"name",
"notes",
"thumbnail",
)
read_only_fields = fields
def to_representation(self, instance):
data = super().to_representation(instance)
request = self.context.get("request")
if instance.thumbnail:
thumbnail_url = instance.thumbnail.url
data["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
else:
data["thumbnail"] = None
return data
def validate_thumbnail(value):
if value is None:
return value
max_bytes = 2 * 1024 * 1024
if getattr(value, "size", 0) > max_bytes:
raise serializers.ValidationError("Image size must be 2MB or less.")
content_type = (getattr(value, "content_type", "") or "").lower()
allowed_types = {"image/jpeg", "image/png", "image/webp"}
if content_type and content_type not in allowed_types:
raise serializers.ValidationError("Unsupported image type. Use JPG, PNG, or WebP.")
return value
class ClientCreateSerializer(serializers.Serializer):
"""
Serializer for handling input data during client creation.
"""
workspace_id = serializers.UUIDField()
name = serializers.CharField(max_length=255)
notes = serializers.CharField(allow_blank=True, required=False, default="")
workspace_id = serializers.UUIDField()
name = serializers.CharField(max_length=255)
notes = serializers.CharField(allow_blank=True, required=False, default="")
thumbnail = serializers.ImageField(required=False, allow_null=True)
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ClientUpdateSerializer(serializers.Serializer):
"""
Serializer for handling input data during client updates.
"""
name = serializers.CharField(max_length=255, required=False)
notes = serializers.CharField(allow_blank=True, required=False)
name = serializers.CharField(max_length=255, required=False)
notes = serializers.CharField(allow_blank=True, required=False)
thumbnail = serializers.ImageField(required=False, allow_null=True)
clear_thumbnail = serializers.BooleanField(required=False, default=False)
def validate_thumbnail(self, value):
return validate_thumbnail(value)

View File

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-05-26 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('clients', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='client',
name='thumbnail',
field=models.ImageField(blank=True, null=True, upload_to='profile/clients/'),
),
]

View File

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

View File

@@ -3,7 +3,7 @@ from apps.clients.models import Client
from apps.workspaces.models import WorkspaceMembership
def create_client(user, workspace_id, name, notes=""):
def create_client(user, workspace_id, name, notes="", thumbnail=None):
"""
Creates a new client after validating workspace membership and name uniqueness.
"""
@@ -23,12 +23,13 @@ def create_client(user, workspace_id, name, notes=""):
workspace_id=workspace_id,
name=name,
notes=notes,
thumbnail=thumbnail,
created_by=user,
updated_by=user,
)
def update_client(client, name=None, notes=None):
def update_client(client, name=None, notes=None, thumbnail=None, clear_thumbnail=False):
"""
Updates an existing client while validating name uniqueness within the workspace.
"""
@@ -37,8 +38,20 @@ def update_client(client, name=None, notes=None):
raise ValidationError({"name": "مشتری با این نام در این فضای کاری وجود دارد."})
client.name = name
if notes is not None:
client.notes = notes
client.save(update_fields=["name", "notes", "updated_at"])
return client
if notes is not None:
client.notes = notes
old_thumbnail_name = client.thumbnail.name if client.thumbnail else None
if clear_thumbnail and client.thumbnail:
client.thumbnail.delete(save=False)
client.thumbnail = None
if thumbnail is not None:
client.thumbnail = thumbnail
client.save(update_fields=["name", "notes", "thumbnail", "updated_at"])
if old_thumbnail_name and client.thumbnail and client.thumbnail.name != old_thumbnail_name:
storage = client.thumbnail.storage
if storage.exists(old_thumbnail_name):
storage.delete(old_thumbnail_name)
return client

View File

@@ -0,0 +1 @@

56
apps/contacts/admin.py Normal file
View 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()

View File

@@ -0,0 +1 @@

View 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

View File

@@ -0,0 +1,5 @@
from rest_framework.throttling import AnonRateThrottle
class ContactSubmissionThrottle(AnonRateThrottle):
scope = "contact_submission"

View 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"),
]

View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ContactsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.contacts"

View 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"),
],
},
),
]

View File

@@ -0,0 +1 @@

36
apps/contacts/models.py Normal file
View 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}"

View File

@@ -0,0 +1 @@

View 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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DemosConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.demos"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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)."))

View 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"),
),
]

View File

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

View File

@@ -0,0 +1 @@

39
apps/demos/models.py Normal file
View 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
View 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
View 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()

View File

@@ -0,0 +1 @@

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

View File

@@ -205,6 +205,18 @@ class RedisNotificationStore:
return True
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
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
data = cls.get(user_id, notif_id)

View File

@@ -1,6 +1,22 @@
from decimal import Decimal
from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
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):
@@ -11,6 +27,7 @@ class ProjectSerializer(BaseModelSerializer):
"name",
"client",
"description",
"thumbnail",
"is_archived",
"color",
)
@@ -18,12 +35,25 @@ class ProjectSerializer(BaseModelSerializer):
def to_representation(self, instance):
representation = super().to_representation(instance)
request = self.context.get("request")
if instance.thumbnail:
thumbnail_url = instance.thumbnail.url
representation["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
else:
representation["thumbnail"] = None
if instance.client:
representation['client'] = {
'id': instance.client.id,
'name': instance.client.name
}
return representation
'id': instance.client.id,
'name': instance.client.name,
'thumbnail': (
request.build_absolute_uri(instance.client.thumbnail.url)
if request and instance.client.thumbnail
else instance.client.thumbnail.url
if instance.client.thumbnail
else None
),
}
return representation
class ProjectCreateSerializer(serializers.Serializer):
@@ -31,16 +61,25 @@ class ProjectCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255)
client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="")
thumbnail = serializers.ImageField(required=False, allow_null=True)
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ProjectUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False)
client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True)
thumbnail = serializers.ImageField(required=False, allow_null=True)
clear_thumbnail = serializers.BooleanField(required=False, default=False)
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
is_archived = serializers.BooleanField(required=False)
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ProjectAccessQuerySerializer(serializers.Serializer):
workspace = serializers.UUIDField()
@@ -54,3 +93,23 @@ class ProjectAccessMutationSerializer(serializers.Serializer):
child=serializers.UUIDField(),
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

View File

@@ -16,16 +16,20 @@ from apps.projects.models import Project
from apps.projects.api.serializers import (
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
ProjectAccessRateMutationSerializer,
)
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
from apps.projects.services.access import (
build_project_access_item,
build_project_access_items,
ensure_workspace_project_access,
filter_projects_for_user,
get_access_managed_membership,
get_project_access_target_membership,
grant_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 (
create_project,
update_project,
@@ -85,6 +89,9 @@ class ProjectViewSet(ModelViewSet):
if client_ids:
queryset = queryset.filter(client_id__in=client_ids)
if "is_archived" not in self.request.query_params:
queryset = queryset.filter(is_archived=False)
return queryset
def get_serializer_class(self):
@@ -116,13 +123,14 @@ class ProjectViewSet(ModelViewSet):
project = create_project(
user=request.user,
workspace=workspace,
name=serializer.validated_data["name"],
client=client,
name=serializer.validated_data["name"],
client=client,
description=serializer.validated_data.get("description", ""),
color=serializer.validated_data.get("color", "")
color=serializer.validated_data.get("color", ""),
thumbnail=serializer.validated_data.get("thumbnail"),
)
output_serializer = ProjectSerializer(project)
output_serializer = ProjectSerializer(project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
@@ -140,7 +148,7 @@ class ProjectViewSet(ModelViewSet):
**serializer.validated_data
)
output_serializer = ProjectSerializer(updated_project)
output_serializer = ProjectSerializer(updated_project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
@@ -165,7 +173,7 @@ class ProjectViewSet(ModelViewSet):
project = self.get_object()
updated_project = toggle_project_archive(project)
output_serializer = ProjectSerializer(updated_project)
output_serializer = ProjectSerializer(updated_project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="access")
@@ -179,7 +187,7 @@ class ProjectViewSet(ModelViewSet):
is_deleted=False,
)
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(
{
@@ -204,7 +212,7 @@ class ProjectViewSet(ModelViewSet):
id=serializer.validated_data["workspace"],
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(
actor=request.user,
workspace=workspace,
@@ -223,7 +231,7 @@ class ProjectViewSet(ModelViewSet):
id=serializer.validated_data["workspace"],
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(
actor=request.user,
workspace=workspace,
@@ -231,3 +239,54 @@ class ProjectViewSet(ModelViewSet):
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
)
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)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-05-26 08:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0004_projectaccess'),
]
operations = [
migrations.AddField(
model_name='project',
name='thumbnail',
field=models.ImageField(blank=True, null=True, upload_to='profile/projects/'),
),
]

View File

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

View File

@@ -5,8 +5,8 @@ from django.db.models import Q, QuerySet
from django.utils import timezone
from rest_framework.exceptions import PermissionDenied, ValidationError
from apps.projects.models import Project, ProjectAccess
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
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.")
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(
workspace=workspace,
user_id=user_id,
@@ -75,41 +75,88 @@ def get_access_managed_membership(workspace: Workspace, user_id: str) -> Workspa
).select_related("user").first()
if not membership:
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
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]:
explicit_access_ids = set(
ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True)
explicit_access_ids = {
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 = (
Project.objects.filter(workspace=workspace, is_deleted=False)
.select_related("client")
.order_by("client__name", "name")
)
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": str(project.id) in {str(project_id) for project_id in explicit_access_ids},
}
build_project_access_item(
project=project,
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,
workspace_rate=workspace_rate,
project_rate=project_rates.get(str(project.id)),
)
for project in projects
]
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
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))
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:
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(
ProjectAccess.objects.filter(

View File

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

View 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

View File

@@ -1,9 +1,11 @@
from decimal import Decimal
from rest_framework.test import APITestCase
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.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
class ProjectViewTests(APITestCase):
@@ -15,6 +17,7 @@ class ProjectViewTests(APITestCase):
first_name="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(
mobile="09121110002",
password="secret123",
@@ -47,6 +50,14 @@ class ProjectViewTests(APITestCase):
cls.first_project = Project.objects.get(name="Alpha")
ProjectAccess.objects.create(project=cls.first_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):
self.client.force_authenticate(user=self.member)
@@ -84,6 +95,9 @@ class ProjectViewTests(APITestCase):
items = access_response.data["items"]
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
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(
"/api/projects/access/grant/",
@@ -114,3 +128,92 @@ class ProjectViewTests(APITestCase):
)
self.assertEqual(revoke_response.status_code, 200)
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())

View File

@@ -6,6 +6,7 @@ from apps.reports.api.views import (
ReportDayDetailsView,
ReportExportJobViewSet,
ReportTableView,
ReportUserSummaryView,
)
router = DefaultRouter()
@@ -15,6 +16,6 @@ urlpatterns = [
path("chart/", ReportChartView.as_view(), name="report-chart"),
path("table/", ReportTableView.as_view(), name="report-table"),
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
path("", include(router.urls)),
]

View File

@@ -20,6 +20,7 @@ from apps.reports.services import (
build_chart_report,
build_day_details_report,
build_table_report,
build_user_summary_report,
load_report_filters,
)
from apps.reports.tasks import generate_report_export_task
@@ -83,6 +84,24 @@ class ReportDayDetailsView(APIView):
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(
mixins.CreateModelMixin,
mixins.ListModelMixin,

View File

@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
build_chart_report,
build_day_details_report,
build_table_report,
build_user_summary_report,
build_user_scoped_table_reports,
load_report_filters,
)
@@ -10,6 +11,7 @@ __all__ = [
"load_report_filters",
"build_chart_report",
"build_table_report",
"build_user_summary_report",
"build_user_scoped_table_reports",
"build_day_details_report",
]

View File

@@ -18,7 +18,7 @@ from apps.projects.models import Project
from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag
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
User = get_user_model()
@@ -52,9 +52,9 @@ UNCATEGORIZED_LABELS = {
"tags": "No tag",
},
"fa": {
"clients": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
"projects": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
"tags": "\u0628\u062f\u0648\u0646 \u062a\u06af",
"clients": "بدون مشتری",
"projects": "بدون پروژه",
"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()
for entry in entries:
if not entry.hourly_rate:
continue
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
for row in rate_rows:
unique_rates.add((row["amount"], row["currency"]))
return [
{"amount": amount, "currency": currency}
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
]
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
sorted_entries = sorted(entries, key=lambda entry: entry.start_time)
periods: list[dict] = []
current: dict | None = None
def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]:
current_timezone = timezone.get_current_timezone()
period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone)
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:
if not entry.hourly_rate:
continue
grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list)
for row in rows:
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'))}"
currency = entry.currency or "USD"
start_date = _localize_datetime(entry.start_time).date()
end_source = entry.end_time or entry.start_time
end_date = _localize_datetime(end_source).date()
serialized: list[dict] = []
for (_scope, _project_id), history_rows in grouped.items():
selected_indexes = {
index for index, row in enumerate(history_rows) if row.effective_from >= period_start
}
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 (
current
and current["amount"] == amount
and current["currency"] == currency
):
if end_date > current["to_date"]:
current["to_date"] = end_date
continue
if current:
periods.append(
for index in sorted(selected_indexes):
row = history_rows[index]
next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None
if next_row and next_row.effective_from < period_start:
continue
from_day = max(_localize_datetime(row.effective_from).date(), from_date)
to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None
serialized.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
"amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}",
"currency": row.currency or "USD",
"from_date": from_day.isoformat(),
"to_date": to_day.isoformat() if to_day else None,
"scope": row.scope,
"project_name": row.project.name if row.project else None,
"is_current": next_row is None,
}
)
current = {
"amount": amount,
"currency": currency,
"from_date": start_date,
"to_date": end_date,
}
if current:
periods.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
}
)
return periods
return sorted(
serialized,
key=lambda item: (
item["from_date"],
item["scope"],
item.get("project_name") or "",
Decimal(item["amount"]),
),
)
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"
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))
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)
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)
client_rows = _build_breakdown(entries, "clients", 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),
"mobile": user.mobile,
},
"hourly_rates": _serialize_distinct_rates(entries),
"rate_periods": _serialize_rate_periods(entries),
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
"rate_periods": rate_rows,
"total_seconds": summary["billable_seconds"],
"total_duration": summary["total_duration"],
"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)
for entry in entries:
grouped[str(entry.user_id)].append(entry)
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()
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}:
bucket_date = local_dt.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)
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)
entries = list(_base_queryset(filters))
if filters.is_workspace_scope and not filters.user_id:
return _table_report_payload(
payload = _table_report_payload(
filters,
entries,
user_summaries=_build_user_summaries(entries, language=filters.language),
user_summaries=_build_user_summaries(entries, filters=filters),
)
return payload
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
else None
)
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]:
filters = load_report_filters(actor, raw_filters)
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_entries[0].user,
user_entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
),
)

View File

@@ -10,7 +10,7 @@ import jdatetime
from arabic_reshaper import reshape
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 = (
(0x0600, 0x06FF),
(0x0750, 0x077F),
@@ -49,9 +49,13 @@ TRANSLATIONS = {
"rate_history": "Hourly rate history",
"from": "From",
"to": "To",
"now": "Now",
"project": "Project",
"percentage": "Percentage",
"hour_percentage": "Hour %",
"income_percentage": "Income %",
"multiple_rates": "Multiple rates - see details",
"variable_rate": "Variable rate",
"none": "None",
"daily_summary": "Daily Summary",
"clients": "Clients",
@@ -66,48 +70,52 @@ TRANSLATIONS = {
"uncategorized_tag": "No tag",
},
"fa": {
"report_title": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
"overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc",
"users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc",
"period": "\u0628\u0627\u0632\u0647",
"from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e",
"to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e",
"user": "\u06a9\u0627\u0631\u0628\u0631",
"mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644",
"all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f",
"summary": "\u062e\u0644\u0627\u0635\u0647",
"total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a",
"billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
"non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc",
"hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"income": "\u06a9\u0627\u0631\u06a9\u0631\u062f",
"working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc",
"non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc",
"hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc",
"project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
"client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627",
"tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627",
"summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646",
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc",
"from": "\u0627\u0632",
"to": "\u062a\u0627",
"percentage": "\u062f\u0631\u0635\u062f",
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a",
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f",
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f",
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647",
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646",
"projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627",
"tags": "\u062a\u06af\u200c\u0647\u0627",
"date": "\u062a\u0627\u0631\u06cc\u062e",
"name": "\u0646\u0627\u0645",
"total": "\u062c\u0645\u0639",
"no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647",
"uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc",
"uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647",
"uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af",
"report_title": "گزارش فضای کاری",
"overall_sheet": "گزارش کلی",
"users_summary_sheet": "خلاصه کاربران",
"workspace": "فضای کاری",
"period": "بازه",
"from_date": "از تاریخ",
"to_date": "تا تاریخ",
"user": "کاربر",
"mobile": "موبایل",
"all_users": "همه کاربران",
"generated_at": "تاریخ تولید",
"summary": "خلاصه",
"total_hours": "کل ساعات",
"billable_hours": "ساعات کاری",
"non_billable_hours": "ساعات غیر کاری",
"hourly_rate": "نرخ ساعتی",
"income": "کارکرد",
"working_hours": "ساعات کاری",
"non_working_hours": "ساعات غیرکاری",
"hourly_rates": "نرخ‌های ساعتی",
"project_percentages": "درصد پروژه‌ها",
"client_percentages": "درصد مشتری‌ها",
"tag_percentages": "درصد تگ‌ها",
"summary_by_user": "خلاصه کاربران",
"rate_history": "تاریخچه نرخ ساعتی",
"from": "از",
"to": "تا",
"now": "حال",
"project": "پروژه",
"percentage": "درصد",
"hour_percentage": "درصد ساعت",
"income_percentage": "درصد کارکرد",
"multiple_rates": "چند نرخ - جزئیات در گزارش کاربر",
"variable_rate": "نرخ متغیر",
"none": "بدون مورد",
"daily_summary": "خلاصه روزانه",
"clients": "مشتریان",
"projects": "پروژه‌ها",
"tags": "تگ‌ها",
"date": "تاریخ",
"name": "نام",
"total": "جمع",
"no_data": "بدون داده",
"uncategorized_client": "بدون مشتری",
"uncategorized_project": "بدون پروژه",
"uncategorized_tag": "بدون تگ",
},
}
@@ -121,25 +129,27 @@ PERIOD_LABELS = {
"period": "Custom period",
},
"fa": {
"this_week": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647",
"this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647",
"this_year": "\u0627\u0645\u0633\u0627\u0644",
"half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644",
"half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644",
"period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647",
"this_week": "این هفته",
"this_month": "این ماه",
"this_year": "امسال",
"half_year_first": "نیمه اول سال",
"half_year_second": "نیمه دوم سال",
"period": "بازه دلخواه",
},
}
CURRENCY_LABELS = {
"USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"},
"EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"},
"GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"},
"IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"},
"IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"},
"AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"},
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"},
"USD": {"en": "USD", "fa": "دلار آمریکا"},
"EUR": {"en": "EUR", "fa": "یورو"},
"GBP": {"en": "GBP", "fa": "پوند"},
"IRR": {"en": "IRR", "fa": "ریال"},
"IRT": {"en": "IRT", "fa": "تومان"},
"AED": {"en": "AED", "fa": "درهم"},
"TRY": {"en": "TRY", "fa": "لیر"},
}
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
@dataclass(frozen=True)
class ExportLocale:
@@ -174,6 +184,15 @@ class ExportLocale:
return self.format_number(value, ascii_digits=ascii_digits)
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()
if not raw:
return raw
@@ -189,18 +208,24 @@ class ExportLocale:
grouped_integer = f"{int(integer_part):,}"
formatted = f"{sign}{grouped_integer}"
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:
formatted = f"{formatted}.{trimmed_fraction}"
return self.format_number(formatted, ascii_digits=ascii_digits)
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
if not income_totals:
return "-"
return self.format_number("0", ascii_digits=ascii_digits)
parts = []
for item in income_totals:
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)
def currency_label(self, code: str | None) -> str:

View File

@@ -26,6 +26,13 @@ BORDER = Border(
top=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):
@@ -65,20 +72,24 @@ def _autosize_columns(worksheet) -> None:
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:
return locale.format_money_label(income_totals)
def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
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:
if not rates:
return locale.t("none")
return locale.format_number("0", ascii_digits=ascii_digits)
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
]
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:
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'])}"
)
def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
if not rate:
return "-"
return f"{locale.format_amount(rate['amount'])} {locale.currency_label(rate['currency'])}"
return locale.format_number("0")
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:
if not rate:
return "-"
value = f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}"
return f"\u202B{value}\u202C" if locale.is_rtl else value
return locale.format_number("0", ascii_digits=True)
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]:
@@ -186,7 +224,14 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary:
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_name = row_data.get("name")
for row in rows:
@@ -195,12 +240,47 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
return value
if row_name and row["name"] == row_name:
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:
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(
@@ -215,9 +295,9 @@ def _summary_breakdown_rows(
[
row["name"],
_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]
last_row = rate_periods[-1]
last_to_date = last_row.get("to_date")
return (
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:
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
worksheet.append(
_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]:
@@ -257,7 +344,8 @@ def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_su
[
_rate_period_label(locale, row, 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],
hour_percentages: list[dict] | None = None,
income_percentages: list[dict] | None = None,
financial_only: bool = False,
) -> None:
worksheet.append([])
_append_merged_heading(
worksheet,
locale=locale,
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
headers = [
locale.t("name"),
locale.t("billable_hours"),
*( [locale.t("hour_percentage")] if hour_percentages is not None else [] ),
locale.t("non_billable_hours"),
locale.t("total_hours"),
*( [] if financial_only else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
locale.t("income"),
*( [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)
return
for row in rows:
for row in _sort_breakdown_rows(rows, hour_percentages):
values = [
row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True),
@@ -477,11 +571,17 @@ def _append_breakdown_table(
if hour_percentages is not None
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"]),
*(
[_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
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:
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)
cell = worksheet.cell(row=row, column=start_col)
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:
worksheet.append([title])
row = worksheet.max_row
span = max(int(span), 1)
if span > 1:
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)
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(
worksheet,
*,
@@ -562,16 +671,7 @@ def _write_table_row(
def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]:
rate_rows = [
[
_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 [])
]
rate_rows = _summary_rate_rows(locale, summary)
client_rows = _summary_breakdown_rows(
locale,
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,
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["non_billable_duration"], ascii_digits=True) if index == 0 else None,
*(rate_rows[index] if index < len(rate_rows) else [None, 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]),
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]),
],
)
@@ -624,6 +726,7 @@ def _render_all_users_overall_excel_sheet(
) -> None:
if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True
_freeze_first_column(worksheet)
scope = report_data["scope"]
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):
_write_table_row(worksheet, row=row_index, start_col=1, values=values, rtl=locale.is_rtl)
_merge_and_style(
worksheet,
row=15,
start_col=1,
end_col=16,
value=locale.t("users_summary_sheet"),
rtl=locale.is_rtl,
)
_merge_and_style(worksheet, row=15, start_col=1, end_col=6, value=locale.t("users_summary_sheet"), rtl=locale.is_rtl)
_merge_and_style(worksheet, row=15, start_col=8, end_col=10, value=locale.t("clients"), rtl=locale.is_rtl)
_merge_and_style(worksheet, row=15, start_col=12, end_col=14, value=locale.t("projects"), rtl=locale.is_rtl)
_merge_and_style(worksheet, row=15, start_col=16, end_col=18, value=locale.t("tags"), rtl=locale.is_rtl)
summary_headers = [
locale.t("name"),
locale.t("mobile"),
locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"),
"",
locale.t("clients"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
"",
locale.t("projects"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
"",
locale.t("tags"),
locale.t("hour_percentage"),
locale.t("income_percentage"),
@@ -704,29 +805,37 @@ def _render_all_users_overall_excel_sheet(
values=values,
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)
rate_rows = user_summary.get("rate_periods") or []
client_rows = user_summary.get("client_percentages") or []
project_rows = user_summary.get("project_percentages") or []
tag_rows = user_summary.get("tag_percentages") or []
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=6, value_present=True)
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=9, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
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=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=15, value_present=True)
if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=17, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True)
current_row += span
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
for title_key, rows, hour_percentages, income_percentages in (
(
@@ -752,7 +861,7 @@ def _render_all_users_overall_excel_sheet(
worksheet,
row=current_row,
start_col=1,
end_col=7,
end_col=5,
value=locale.t(title_key),
rtl=locale.is_rtl,
)
@@ -765,8 +874,6 @@ def _render_all_users_overall_excel_sheet(
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
locale.t("income"),
locale.t("income_percentage"),
],
@@ -775,8 +882,9 @@ def _render_all_users_overall_excel_sheet(
fill=HEADER_FILL,
)
current_row += 1
if rows:
for row in rows:
sorted_rows = _sort_breakdown_rows(rows, hour_percentages)
if sorted_rows:
for row in sorted_rows:
_write_table_row(
worksheet,
row=current_row,
@@ -785,10 +893,8 @@ def _render_all_users_overall_excel_sheet(
row["name"],
locale.format_duration(row["billable_duration"], 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"]),
_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,
)
@@ -798,7 +904,7 @@ def _render_all_users_overall_excel_sheet(
worksheet,
row=current_row,
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,
)
current_row += 1
@@ -808,30 +914,33 @@ def _render_all_users_overall_excel_sheet(
"A": 31.57,
"B": 19.86,
"C": 18.0,
"D": 17.0,
"E": 18.0,
"F": 26.0,
"G": 24.0,
"H": 28.0,
"I": 14.0,
"J": 16.0,
"K": 28.0,
"L": 14.0,
"M": 16.0,
"N": 24.0,
"O": 14.0,
"P": 16.0,
"D": 18.0,
"E": 26.0,
"F": 24.0,
"G": 28.0,
"H": 14.0,
"I": 16.0,
"J": 28.0,
"K": 14.0,
"L": 16.0,
"M": 24.0,
"N": 14.0,
"O": 16.0,
}
for column, width in overall_widths.items():
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:
worksheet.sheet_view.rightToLeft = True
worksheet.freeze_panes = "E4"
else:
worksheet.freeze_panes = "A4"
_freeze_first_column(worksheet)
_append_meta_block(worksheet, locale=locale, report_data=report_data)
if report_data.get("user_summaries"):
worksheet.append([])
@@ -860,6 +969,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary
else report_data.get("client_income_percentages")
),
financial_only=financial_only_breakdowns,
)
_append_breakdown_table(
worksheet,
@@ -876,6 +986,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary
else report_data.get("project_income_percentages")
),
financial_only=financial_only_breakdowns,
)
_append_breakdown_table(
worksheet,
@@ -892,6 +1003,7 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -
if user_summary
else report_data.get("tag_income_percentages")
),
financial_only=financial_only_breakdowns,
)
_autosize_columns(worksheet)
@@ -935,7 +1047,12 @@ def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_repo
used_titles,
)
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)
else:
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:
rows = summary.get("rate_periods") or []
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(
locale,
[
_rate_period_label(locale, row),
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
),
]
if not rows:
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])
data.append(_rtl_row(locale, [locale.t("no_data"), "", "", ""]))
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:
@@ -1083,6 +1209,7 @@ def _append_pdf_report_sections(
doc_width: float,
section_style: ParagraphStyle,
user_summary: dict | None = None,
financial_only_breakdowns: bool = False,
) -> None:
sections = [
("daily_summary", report_data["days"], True),
@@ -1107,8 +1234,7 @@ def _append_pdf_report_sections(
locale.t("name"),
locale.t("billable_hours"),
locale.t("hour_percentage"),
locale.t("non_billable_hours"),
locale.t("total_hours"),
*( [] if financial_only_breakdowns else [locale.t("non_billable_hours"), locale.t("total_hours")] ),
locale.t("income"),
locale.t("income_percentage"),
]
@@ -1120,7 +1246,8 @@ def _append_pdf_report_sections(
hour_percentage_rows = user_summary[f"{prefix}_percentages"]
income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
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:
body_rows = [
_rtl_row(
@@ -1129,49 +1256,69 @@ def _append_pdf_report_sections(
row["name"],
locale.format_duration(row["billable_duration"]),
_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"]),
_percentage_display(locale, income_percentage_rows or [], row),
_percentage_display(locale, income_percentage_rows or [], row, default="-"),
],
)
for row in rows
] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", "", ""])]
for row in sorted_rows
] 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(
[header, *body_rows],
locale=locale,
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,
]
)
]
),
column_widths=column_widths,
)
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("mobile"),
locale.t("working_hours"),
locale.t("non_working_hours"),
locale.t("hourly_rate"),
locale.t("period"),
locale.t("income"),
],
)
@@ -1326,9 +1471,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
summary["user"]["name"],
locale.format_number(summary["user"]["mobile"]),
locale.format_duration(summary["billable_duration"]),
locale.format_duration(summary["non_billable_duration"]),
_rates_label(locale, summary.get("hourly_rates") or []),
_summary_period_label(locale, summary.get("rate_periods") or []),
_pdf_summary_rate_label(locale, summary.get("hourly_rates") or []),
_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],
locale=locale,
column_widths=[
doc.width * 0.18,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.13,
doc.width * 0.25,
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,
section_style=section_style,
user_summary=user_report.get("user_summary"),
financial_only_breakdowns=True,
)
else:
_append_pdf_report_sections(
@@ -1388,6 +1530,7 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_report
doc_width=doc.width,
section_style=section_style,
user_summary=report_data.get("user_summary"),
financial_only_breakdowns=False,
)
doc.build(story)

View File

@@ -4,7 +4,13 @@ from django.test import TestCase
from openpyxl import load_workbook
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):
@@ -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):
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):
locale = build_export_locale("en")
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
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"),
]
per_user_reports = [
@@ -91,7 +167,7 @@ class ReportExporterTests(TestCase):
mobile="09129990001",
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(
@@ -120,26 +196,38 @@ class ReportExporterTests(TestCase):
summary_sheet = workbook[workbook.sheetnames[0]]
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["B1"].value, "Exports")
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(
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",
"Mobile",
"Working hours",
"Non-working hours",
"Hourly rate",
"Period",
"Income",
None,
"Clients",
"Hour %",
"Income %",
None,
"Projects",
"Hour %",
"Income %",
None,
"Tags",
"Hour %",
"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 "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_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)
self.assertEqual(
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 %")
self.assertEqual(
breakdown_header,
breakdown_header[:5],
(
"Name",
"Billable hours",
"Hour %",
"Non-billable hours",
"Total hours",
"Income",
"Income %",
),

View File

@@ -10,7 +10,7 @@ from apps.projects.models import Project
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
class ReportViewTests(APITestCase):
@@ -320,6 +320,75 @@ class ReportViewTests(APITestCase):
{"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):
self.client.force_authenticate(user=self.owner)

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers
from django.utils import timezone
from core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry
@@ -31,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer):
tag_details = serializers.SerializerMethodField()
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)
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):
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",
"description",
"start_time",
"start_time_ms",
"end_time",
"end_time_ms",
"server_now_ms",
"duration",
"tags",
"tag_details",

View File

@@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet):
filterset_class = TimeEntryFilter
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
def _serialize_duration_ms(entry):
if entry.duration is not None:
@@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet):
days_since_sunday = (local_dt.weekday() + 1) % 7
return (local_dt - timedelta(days=days_since_sunday)).date()
def _build_grouped_entries(self, entries):
serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data
def _build_grouped_entries(self, entries, *, server_now):
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}
weeks = []
weeks_by_key = {}
@@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet):
queryset = self.filter_queryset(self.get_queryset())
paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request, view=self)
server_now = timezone.now()
current_items_count = len(page)
has_more = (paginator.offset + current_items_count) < paginator.count
@@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet):
"offset": paginator.offset,
"next_offset": paginator.offset + current_items_count if has_more else None,
"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
)
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)
def update(self, request, *args, **kwargs):
@@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet):
**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)
@action(detail=True, methods=["post"])
@@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet):
end_time = serializer.validated_data.get("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)
def destroy(self, request, *args, **kwargs):

View File

@@ -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
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(
user=user,
workspace=project.workspace,

View File

@@ -1,10 +1,11 @@
from datetime import timedelta
from decimal import Decimal
from django.test import TestCase
from django.utils import timezone
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.time_entries.services.time_entries import (
create_time_entry,
@@ -12,14 +13,21 @@ from apps.time_entries.services.time_entries import (
update_time_entry,
)
from apps.users.models import User
from apps.workspaces.models import Workspace
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
class TimeEntryServiceTests(TestCase):
@classmethod
def setUpTestData(cls):
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)
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):
create_time_entry(
@@ -97,3 +105,36 @@ class TimeEntryServiceTests(TestCase):
),
[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")

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta
from django.utils import timezone
from rest_framework.test import APITestCase
@@ -40,6 +40,9 @@ class TimeEntryViewTests(APITestCase):
self.assertIsNone(entry.end_time)
self.assertGreaterEqual(entry.start_time, before)
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):
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.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.assertEqual(len(response.data["groups"]), 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"],
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):
user = User.objects.create_user(mobile="09127777777", password="secret123")

View File

@@ -8,11 +8,11 @@ from apps.users.email_identity import normalize_email_identity
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_NUMBER_MESSAGE = "\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 \u0645\u0639\u062a\u0628\u0631 \u0646\u06cc\u0633\u062a."
PASSWORD_MISMATCH_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0645\u0637\u0627\u0628\u0642\u062a \u0646\u062f\u0627\u0631\u062f."
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."
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."
INVALID_MOBILE_FORMAT_MESSAGE = "فرمت شماره موبایل نادرست است."
INVALID_MOBILE_NUMBER_MESSAGE = "شماره موبایل معتبر نیست."
PASSWORD_MISMATCH_MESSAGE = "رمز عبور مطابقت ندارد."
NEW_PASSWORD_MISMATCH_MESSAGE = "رمز عبور جدید و تکرار آن مطابقت ندارند."
PASSWORD_REUSE_MESSAGE = "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد."
def _raise_password_validation_error(password: str, *, user, field_name: str) -> None:
@@ -212,10 +212,12 @@ class UserProfileSerializer(BaseModelSerializer):
"profile_picture",
"birth_date",
"is_verified",
"is_demo",
"demo_expires_at",
"full_name",
"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):

View File

@@ -293,6 +293,11 @@ class ChangePasswordView(APIView):
@extend_schema(request=ChangePasswordSerializer)
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.is_valid(raise_exception=True)
@@ -327,6 +332,11 @@ class SetPasswordView(UpdateAPIView):
@extend_schema(request=ChangePasswordSerializer, responses=None)
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)
def get_object(self):
@@ -347,6 +357,11 @@ class ProfilePictureView(APIView):
operation_id="users_profile_picture_self_create",
)
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(
instance=request.user,
data=request.data,
@@ -362,6 +377,11 @@ class ProfilePictureView(APIView):
operation_id="users_profile_picture_self_delete",
)
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 = None
request.user.save(update_fields=["profile_picture", "updated_at"])
@@ -401,6 +421,11 @@ class UserSearchAPIView(APIView):
permission_classes = [IsAuthenticated]
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')
if not mobile:
return Response(

View 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"),
),
]

View File

@@ -22,6 +22,8 @@ class User(AbstractUser, BaseModel):
password_updated_at = models.DateTimeField(blank=True, null=True)
is_verified = models.BooleanField(default=False)
is_demo = models.BooleanField(default=False)
demo_expires_at = models.DateTimeField(blank=True, null=True)
USERNAME_FIELD = "mobile"
REQUIRED_FIELDS = []
@@ -63,6 +65,7 @@ class User(AbstractUser, BaseModel):
indexes = (
models.Index(fields=["id"], name="user_id_idx"),
models.Index(fields=["mobile"], name="user_mobile_idx"),
models.Index(fields=["is_demo", "demo_expires_at"], name="user_demo_expires_idx"),
)

View File

@@ -18,7 +18,7 @@ from apps.users.utils import record_login_attempt
User = get_user_model()
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
@@ -55,15 +55,15 @@ def register_user_with_password(mobile, password):
def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
"""Business logic for verifying OTP and registering a user."""
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")
stored_code = redis_conn.get(f"verification_code:{mobile}")
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:
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(
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):
"""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:
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:
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))
@@ -109,10 +113,10 @@ def login_with_password(mobile, password, request=None):
if not user or not user.check_password(password):
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:
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)
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:
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)
if created:
@@ -133,7 +137,7 @@ def login_with_otp(mobile, code, request=None):
user.save()
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)
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."""
user = User.objects.filter(mobile=mobile).first()
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")
stored_code = redis_conn.get(f"verification_code:{mobile}")
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")
@@ -167,7 +171,7 @@ def reset_password_with_otp(mobile, code, password):
def change_password(user, old_password, new_password):
"""Change password for an already authenticated user."""
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")
@@ -182,9 +186,9 @@ def change_password(user, old_password, new_password):
def logout_user(refresh_token_str):
"""Blacklist the user's refresh token."""
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:
token = RefreshToken(refresh_token_str)
token.blacklist()
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": "توکن نامعتبر است یا قبلا منقضی شده است."})

View File

@@ -3,7 +3,8 @@ from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.exceptions import PermissionDenied
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 rest_framework.viewsets import ModelViewSet
from rest_framework.permissions import IsAuthenticated
@@ -15,6 +16,8 @@ from apps.notifications.services import (
notify_workspace_membership_removed,
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 (
CanWorkspaceManageMembers,
IsWorkspaceAdmin,
@@ -78,6 +81,8 @@ class WorkspaceViewSet(ModelViewSet):
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [IsAuthenticated(), IsWorkspaceMember()]
if self.action == "my_rates":
return [IsAuthenticated()]
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
@@ -86,8 +91,94 @@ class WorkspaceViewSet(ModelViewSet):
return [IsAuthenticated()]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
def perform_create(self, serializer):
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):
@@ -163,7 +254,12 @@ class WorkspaceMembershipViewSet(ModelViewSet):
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()
if not permission.has_object_permission(request, self, workspace):

View 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),
]

View File

@@ -173,3 +173,53 @@ class WorkspaceUserRate(BaseModel):
"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,
},
)

View File

@@ -1,6 +1,34 @@
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"):
@@ -11,6 +39,7 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
is_deleted=False,
).first()
effective_from = timezone.now()
if rate:
update_fields = []
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:
update_fields.append("updated_at")
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 WorkspaceUserRate.objects.create(
rate = WorkspaceUserRate.objects.create(
workspace=workspace,
user_id=user_id,
hourly_rate=hourly_rate,
currency=currency,
effective_from=timezone.now(),
effective_from=effective_from,
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):
@@ -50,5 +94,12 @@ def update_workspace_user_rate(rate_instance, **kwargs):
if update_fields:
update_fields.append("updated_at")
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

View File

@@ -4,6 +4,7 @@ from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APITestCase
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.users.models import User
from apps.workspaces.api.permissions import (
CanWorkspaceManageMembers,
@@ -11,7 +12,7 @@ from apps.workspaces.api.permissions import (
IsWorkspaceMember,
IsWorkspaceOwner,
)
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
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))
self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST)
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")

View File

@@ -4,7 +4,7 @@ from django.core.cache import cache
from django.test import TestCase
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.users.models import User
from apps.workspaces.models import (
@@ -78,6 +78,53 @@ class WorkspaceRateTests(APITestCase):
self.assertIsNone(hourly_rate)
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):
self.client.force_authenticate(user=self.admin)

View File

@@ -2,6 +2,7 @@ import os
from datetime import timedelta
from pathlib import Path
from celery.schedules import crontab
from dotenv import load_dotenv
load_dotenv()
@@ -48,6 +49,8 @@ LOCAL_APPS = [
"apps.notifications",
"apps.reports",
"apps.logs",
"apps.demos",
"apps.contacts",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@@ -138,6 +141,8 @@ REST_FRAMEWORK = {
"otp_send_sustained": "10/day",
"login_password": "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",
}
@@ -243,7 +248,21 @@ NOTIFICATION_TOAST_LEVELS = tuple(
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 = {

View File

@@ -24,6 +24,8 @@ urlpatterns = [
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
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: