Compare commits

...

9 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
60 changed files with 1747 additions and 212 deletions

View File

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

View File

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

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) notes = models.TextField(blank=True)
thumbnail = models.ImageField(upload_to="profile/clients/", blank=True, null=True)
class Meta: class Meta:
db_table = "client" db_table = "client"
ordering = ("-updated_at", "-created_at") ordering = ("-updated_at", "-created_at")

View File

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

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 True
return False return False
@classmethod
def clear_user(cls, user_id: str) -> int:
ids_key = cls._ids_key(user_id)
data_key = cls._data_key(user_id)
count = redis_client.zcard(ids_key)
pipe = redis_client.pipeline()
pipe.delete(ids_key)
pipe.delete(data_key)
pipe.srem(cls.USERS_KEY, user_id)
pipe.execute()
return int(count or 0)
@classmethod @classmethod
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None: def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
data = cls.get(user_id, notif_id) data = cls.get(user_id, notif_id)

View File

@@ -6,6 +6,19 @@ from apps.projects.models import Project
from apps.workspaces.models import PriceUnit from apps.workspaces.models import PriceUnit
def validate_thumbnail(value):
if value is None:
return value
max_bytes = 2 * 1024 * 1024
if getattr(value, "size", 0) > max_bytes:
raise serializers.ValidationError("Image size must be 2MB or less.")
content_type = (getattr(value, "content_type", "") or "").lower()
allowed_types = {"image/jpeg", "image/png", "image/webp"}
if content_type and content_type not in allowed_types:
raise serializers.ValidationError("Unsupported image type. Use JPG, PNG, or WebP.")
return value
class ProjectSerializer(BaseModelSerializer): class ProjectSerializer(BaseModelSerializer):
class Meta: class Meta:
model = Project model = Project
@@ -14,6 +27,7 @@ class ProjectSerializer(BaseModelSerializer):
"name", "name",
"client", "client",
"description", "description",
"thumbnail",
"is_archived", "is_archived",
"color", "color",
) )
@@ -21,12 +35,25 @@ class ProjectSerializer(BaseModelSerializer):
def to_representation(self, instance): def to_representation(self, instance):
representation = super().to_representation(instance) representation = super().to_representation(instance)
request = self.context.get("request")
if instance.thumbnail:
thumbnail_url = instance.thumbnail.url
representation["thumbnail"] = request.build_absolute_uri(thumbnail_url) if request else thumbnail_url
else:
representation["thumbnail"] = None
if instance.client: if instance.client:
representation['client'] = { representation['client'] = {
'id': instance.client.id, 'id': instance.client.id,
'name': instance.client.name 'name': instance.client.name,
} 'thumbnail': (
return representation request.build_absolute_uri(instance.client.thumbnail.url)
if request and instance.client.thumbnail
else instance.client.thumbnail.url
if instance.client.thumbnail
else None
),
}
return representation
class ProjectCreateSerializer(serializers.Serializer): class ProjectCreateSerializer(serializers.Serializer):
@@ -34,16 +61,25 @@ class ProjectCreateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="") description = serializers.CharField(required=False, allow_blank=True, default="")
thumbnail = serializers.ImageField(required=False, allow_null=True)
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="") color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ProjectUpdateSerializer(serializers.Serializer): class ProjectUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False) name = serializers.CharField(max_length=255, required=False)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True) description = serializers.CharField(required=False, allow_blank=True)
thumbnail = serializers.ImageField(required=False, allow_null=True)
clear_thumbnail = serializers.BooleanField(required=False, default=False)
color = serializers.CharField(max_length=7, required=False, allow_blank=True) color = serializers.CharField(max_length=7, required=False, allow_blank=True)
is_archived = serializers.BooleanField(required=False) is_archived = serializers.BooleanField(required=False)
def validate_thumbnail(self, value):
return validate_thumbnail(value)
class ProjectAccessQuerySerializer(serializers.Serializer): class ProjectAccessQuerySerializer(serializers.Serializer):
workspace = serializers.UUIDField() workspace = serializers.UUIDField()

View File

@@ -89,6 +89,9 @@ class ProjectViewSet(ModelViewSet):
if client_ids: if client_ids:
queryset = queryset.filter(client_id__in=client_ids) queryset = queryset.filter(client_id__in=client_ids)
if "is_archived" not in self.request.query_params:
queryset = queryset.filter(is_archived=False)
return queryset return queryset
def get_serializer_class(self): def get_serializer_class(self):
@@ -120,13 +123,14 @@ class ProjectViewSet(ModelViewSet):
project = create_project( project = create_project(
user=request.user, user=request.user,
workspace=workspace, workspace=workspace,
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
client=client, client=client,
description=serializer.validated_data.get("description", ""), description=serializer.validated_data.get("description", ""),
color=serializer.validated_data.get("color", "") color=serializer.validated_data.get("color", ""),
thumbnail=serializer.validated_data.get("thumbnail"),
) )
output_serializer = ProjectSerializer(project) output_serializer = ProjectSerializer(project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_201_CREATED) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -144,7 +148,7 @@ class ProjectViewSet(ModelViewSet):
**serializer.validated_data **serializer.validated_data
) )
output_serializer = ProjectSerializer(updated_project) output_serializer = ProjectSerializer(updated_project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
@@ -169,7 +173,7 @@ class ProjectViewSet(ModelViewSet):
project = self.get_object() project = self.get_object()
updated_project = toggle_project_archive(project) updated_project = toggle_project_archive(project)
output_serializer = ProjectSerializer(updated_project) output_serializer = ProjectSerializer(updated_project, context={"request": request})
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="access") @action(detail=False, methods=["get"], url_path="access")

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) description = models.TextField(blank=True)
thumbnail = models.ImageField(upload_to="profile/projects/", blank=True, null=True)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
color = models.CharField(max_length=7, blank=True) color = models.CharField(max_length=7, blank=True)

View File

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

View File

@@ -1,6 +1,35 @@
from django.utils import timezone from django.utils import timezone
from apps.projects.models import ProjectUserRate from apps.projects.models import ProjectUserRate
from apps.workspaces.models import HourlyRateHistory
def record_project_rate_history(*, project, user, hourly_rate, currency, effective_from=None):
currency = currency.upper()
effective_from = effective_from or timezone.now()
latest = (
HourlyRateHistory.objects.filter(
workspace=project.workspace,
project=project,
user=user,
scope=HourlyRateHistory.Scope.PROJECT,
is_deleted=False,
)
.order_by("-effective_from", "-created_at")
.first()
)
if latest and latest.hourly_rate == hourly_rate and latest.currency == currency:
return latest
return HourlyRateHistory.objects.create(
workspace=project.workspace,
project=project,
user=user,
scope=HourlyRateHistory.Scope.PROJECT,
hourly_rate=hourly_rate,
currency=currency,
effective_from=effective_from,
is_active=True,
)
def get_current_project_user_rate(*, project, user): def get_current_project_user_rate(*, project, user):
@@ -27,6 +56,7 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
.first() .first()
) )
effective_from = timezone.now()
if rate: if rate:
update_fields = [] update_fields = []
if rate.is_deleted: if rate.is_deleted:
@@ -43,16 +73,31 @@ def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"):
if update_fields: if update_fields:
update_fields.append("updated_at") update_fields.append("updated_at")
rate.save(update_fields=update_fields) rate.save(update_fields=update_fields)
record_project_rate_history(
project=project,
user=user,
hourly_rate=rate.hourly_rate,
currency=rate.currency,
effective_from=effective_from,
)
return rate return rate
return ProjectUserRate.objects.create( rate = ProjectUserRate.objects.create(
project=project, project=project,
user=user, user=user,
hourly_rate=hourly_rate, hourly_rate=hourly_rate,
currency=currency, currency=currency,
effective_from=timezone.now(), effective_from=effective_from,
is_active=True, is_active=True,
) )
record_project_rate_history(
project=project,
user=user,
hourly_rate=rate.hourly_rate,
currency=rate.currency,
effective_from=rate.effective_from,
)
return rate
def remove_project_user_rate(*, project, user): def remove_project_user_rate(*, project, user):

View File

@@ -18,8 +18,7 @@ from apps.projects.models import Project
from apps.projects.services.access import user_has_project_access from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.workspaces.models import Workspace from apps.workspaces.models import HourlyRateHistory, Workspace
from apps.workspaces.models import WorkspaceUserRate
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
User = get_user_model() User = get_user_model()
@@ -121,113 +120,73 @@ def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
] ]
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]: def _serialize_rate_history_rows(*, user, workspace: Workspace, from_date: date, to_date: date) -> list[dict]:
sorted_entries = sorted(entries, key=lambda entry: (entry.start_time, entry.end_time or entry.start_time, entry.id)) current_timezone = timezone.get_current_timezone()
periods: list[dict] = [] period_start = timezone.make_aware(datetime.combine(from_date, time.min), current_timezone)
current: dict | None = None period_end = timezone.make_aware(datetime.combine(to_date + timedelta(days=1), time.min), current_timezone)
rows = list(
HourlyRateHistory.objects.filter(
workspace=workspace,
user=user,
is_deleted=False,
effective_from__lt=period_end,
)
.select_related("project")
.order_by("scope", "project_id", "effective_from", "created_at")
)
for entry in sorted_entries: grouped: dict[tuple[str, str | None], list[HourlyRateHistory]] = defaultdict(list)
if not entry.hourly_rate or not entry.start_time: for row in rows:
continue grouped[(row.scope, str(row.project_id) if row.project_id else None)].append(row)
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}" serialized: list[dict] = []
currency = entry.currency or "USD" for (_scope, _project_id), history_rows in grouped.items():
start_date = _localize_datetime(entry.start_time).date() selected_indexes = {
end_source = entry.end_time or entry.start_time index for index, row in enumerate(history_rows) if row.effective_from >= period_start
end_date = _localize_datetime(end_source).date() }
previous_indexes = [
index for index, row in enumerate(history_rows) if row.effective_from < period_start
]
if previous_indexes:
selected_indexes.add(previous_indexes[-1])
if ( for index in sorted(selected_indexes):
current row = history_rows[index]
and current["amount"] == amount next_row = history_rows[index + 1] if index + 1 < len(history_rows) else None
and current["currency"] == currency if next_row and next_row.effective_from < period_start:
): continue
if end_date > current["to_date"]: from_day = max(_localize_datetime(row.effective_from).date(), from_date)
current["to_date"] = end_date to_day = min(_localize_datetime(next_row.effective_from).date(), to_date) if next_row else None
continue serialized.append(
if current:
periods.append(
{ {
"amount": current["amount"], "amount": f"{Decimal(row.hourly_rate).quantize(Decimal('0.01'))}",
"currency": current["currency"], "currency": row.currency or "USD",
"from_date": current["from_date"].isoformat(), "from_date": from_day.isoformat(),
"to_date": current["to_date"].isoformat(), "to_date": to_day.isoformat() if to_day else None,
"scope": row.scope,
"project_name": row.project.name if row.project else None,
"is_current": next_row is None,
} }
) )
current = {
"amount": amount,
"currency": currency,
"from_date": start_date,
"to_date": end_date,
}
if current:
periods.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
}
)
return periods
def _serialize_current_rate_rows(*, user, workspace: Workspace) -> list[dict]:
workspace_rate = (
WorkspaceUserRate.objects.filter(
workspace=workspace,
user=user,
is_active=True,
is_deleted=False,
)
.order_by("-effective_from", "-updated_at")
.first()
)
if not workspace_rate or not workspace_rate.effective_from:
return []
return [
{
"amount": f"{Decimal(workspace_rate.hourly_rate).quantize(Decimal('0.01'))}",
"currency": workspace_rate.currency or "USD",
"from_date": _localize_datetime(workspace_rate.effective_from).date().isoformat(),
"to_date": None,
"is_current": True,
}
]
def _merge_rate_history_rows(history_rows: list[dict], current_rows: list[dict]) -> list[dict]:
merged = [dict(row) for row in history_rows]
latest_indexes = {
(row["amount"], row["currency"]): index
for index, row in enumerate(merged)
}
for row in current_rows:
key = (row["amount"], row["currency"])
index = latest_indexes.get(key)
if index is not None:
merged[index]["to_date"] = None
continue
merged.append(dict(row))
latest_indexes[key] = len(merged) - 1
return sorted( return sorted(
merged, serialized,
key=lambda item: ( key=lambda item: (
item["from_date"], item["from_date"],
item["currency"], item["scope"],
item.get("project_name") or "",
Decimal(item["amount"]), Decimal(item["amount"]),
item.get("to_date") or "9999-12-31",
), ),
) )
def _uncategorized_label(kind: str, language: str) -> str: def _uncategorized_label(kind: str, language: str) -> str:
if language == "fa":
return {
"clients": "بدون مشتری",
"projects": "بدون پروژه",
"tags": "بدون تگ",
}[kind]
resolved_language = language if language in UNCATEGORIZED_LABELS else "en" resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
return UNCATEGORIZED_LABELS[resolved_language][kind] return UNCATEGORIZED_LABELS[resolved_language][kind]
@@ -422,11 +381,22 @@ def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict])
return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income)) return _complete_percentage_rows(rows, _allocate_percentage_rows(items, total_income))
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict: def _build_user_summary(
user,
entries: list[TimeEntry],
*,
workspace: Workspace,
from_date: date,
to_date: date,
language: str,
) -> dict:
summary = _summary_from_entries(entries) summary = _summary_from_entries(entries)
historical_rate_rows = _serialize_rate_periods(entries) rate_rows = _serialize_rate_history_rows(
current_rate_rows = _serialize_current_rate_rows(user=user, workspace=entries[0].workspace) user=user,
rate_rows = _merge_rate_history_rows(historical_rate_rows, current_rate_rows) workspace=workspace,
from_date=from_date,
to_date=to_date,
)
project_rows = _build_breakdown(entries, "projects", language=language) project_rows = _build_breakdown(entries, "projects", language=language)
client_rows = _build_breakdown(entries, "clients", language=language) client_rows = _build_breakdown(entries, "clients", language=language)
tag_rows = _build_breakdown(entries, "tags", language=language) tag_rows = _build_breakdown(entries, "tags", language=language)
@@ -458,13 +428,20 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic
} }
def _build_user_summaries(entries: list[TimeEntry], *, language: str) -> list[dict]: def _build_user_summaries(entries: list[TimeEntry], *, filters: ReportFilters) -> list[dict]:
grouped: dict[str, list[TimeEntry]] = defaultdict(list) grouped: dict[str, list[TimeEntry]] = defaultdict(list)
for entry in entries: for entry in entries:
grouped[str(entry.user_id)].append(entry) grouped[str(entry.user_id)].append(entry)
summaries = [ summaries = [
_build_user_summary(grouped_entries[0].user, grouped_entries, language=language) _build_user_summary(
grouped_entries[0].user,
grouped_entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
for grouped_entries in grouped.values() for grouped_entries in grouped.values()
if grouped_entries if grouped_entries
] ]
@@ -799,9 +776,6 @@ def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]:
if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}: if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}:
bucket_date = local_dt.date() bucket_date = local_dt.date()
return bucket_date.isoformat(), bucket_date return bucket_date.isoformat(), bucket_date
if filters.language == "fa":
persian_date = jdatetime.date.fromgregorian(date=local_dt.date())
return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.date()
bucket_date = date(local_dt.year, local_dt.month, 1) bucket_date = date(local_dt.year, local_dt.month, 1)
return bucket_date.strftime("%Y-%m"), bucket_date return bucket_date.strftime("%Y-%m"), bucket_date
@@ -1045,11 +1019,18 @@ def build_table_report(actor, raw_filters) -> dict:
payload = _table_report_payload( payload = _table_report_payload(
filters, filters,
entries, entries,
user_summaries=_build_user_summaries(entries, language=filters.language), user_summaries=_build_user_summaries(entries, filters=filters),
) )
return payload return payload
user_summary = ( user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language) _build_user_summary(
entries[0].user,
entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
if entries and filters.user_id if entries and filters.user_id
else None else None
) )
@@ -1063,7 +1044,14 @@ def build_user_summary_report(actor, raw_filters) -> dict:
entries = list(_base_queryset(filters)) entries = list(_base_queryset(filters))
user_summary = ( user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language) _build_user_summary(
entries[0].user,
entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language,
)
if entries if entries
else None else None
) )
@@ -1095,6 +1083,9 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
user_summary=_build_user_summary( user_summary=_build_user_summary(
user_entries[0].user, user_entries[0].user,
user_entries, user_entries,
workspace=filters.workspace,
from_date=filters.from_date,
to_date=filters.to_date,
language=filters.language, language=filters.language,
), ),
) )

View File

@@ -219,7 +219,7 @@ class ExportLocale:
def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str: def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str:
if not income_totals: if not income_totals:
return "-" return self.format_number("0", ascii_digits=ascii_digits)
parts = [] parts = []
for item in income_totals: for item in income_totals:
currency = self.currency_label(item["currency"]) currency = self.currency_label(item["currency"])

View File

@@ -26,6 +26,13 @@ BORDER = Border(
top=Side(style="thin", color="D0D7DE"), top=Side(style="thin", color="D0D7DE"),
bottom=Side(style="thin", color="D0D7DE"), bottom=Side(style="thin", color="D0D7DE"),
) )
USER_SUMMARY_SEPARATOR_BORDER = Border(
left=Side(style="thin", color="D0D7DE"),
right=Side(style="thin", color="D0D7DE"),
top=Side(style="medium", color="94A3B8"),
bottom=Side(style="thin", color="D0D7DE"),
)
EMPTY_BORDER = Border()
class BookmarkDocTemplate(SimpleDocTemplate): class BookmarkDocTemplate(SimpleDocTemplate):
@@ -65,6 +72,10 @@ def _autosize_columns(worksheet) -> None:
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30) worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
def _freeze_first_column(worksheet) -> None:
worksheet.freeze_panes = "B1"
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str: def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
return locale.format_money_label(income_totals) return locale.format_money_label(income_totals)
@@ -76,7 +87,7 @@ def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str: def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
if not rates: if not rates:
return "-" return locale.format_number("0", ascii_digits=ascii_digits)
items = [ items = [
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}" f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}"
for rate in rates for rate in rates
@@ -103,13 +114,13 @@ def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool =
def _rate_label(locale: ExportLocale, rate: dict | None) -> str: def _rate_label(locale: ExportLocale, rate: dict | None) -> str:
if not rate: if not rate:
return "-" return locale.format_number("0")
return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}" return f"{locale.format_amount_for_currency(rate['amount'], rate['currency'])} {locale.currency_label(rate['currency'])}"
def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str: def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str:
if not rate: if not rate:
return "-" return locale.format_number("0", ascii_digits=True)
value = ( value = (
f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} " f"{locale.format_amount_for_currency(rate['amount'], rate['currency'], ascii_digits=True)} "
f"{locale.currency_label(rate['currency'])}" f"{locale.currency_label(rate['currency'])}"
@@ -213,7 +224,14 @@ def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary:
worksheet.append(row) worksheet.append(row)
def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, *, ascii_digits: bool = False) -> str: def _percentage_display(
locale: ExportLocale,
rows: list[dict],
row_data: dict,
*,
ascii_digits: bool = False,
default: str = "0%",
) -> str:
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
row_name = row_data.get("name") row_name = row_data.get("name")
for row in rows: for row in rows:
@@ -222,7 +240,7 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
return value return value
if row_name and row["name"] == row_name: if row_name and row["name"] == row_name:
return value return value
return "-" return default
def _percentage_number(rows: list[dict] | None, row_data: dict) -> float: def _percentage_number(rows: list[dict] | None, row_data: dict) -> float:
@@ -277,7 +295,7 @@ def _summary_breakdown_rows(
[ [
row["name"], row["name"],
_percentage_value(locale, row["percentage"], ascii_digits=True), _percentage_value(locale, row["percentage"], ascii_digits=True),
_percentage_display(locale, income_rows, row, ascii_digits=True), _percentage_display(locale, income_rows, row, ascii_digits=True, default="-"),
] ]
for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or "")) for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or ""))
] ]
@@ -304,11 +322,11 @@ def _rate_to_label(locale: ExportLocale, to_date: str | None, *, ascii_digits: b
def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None: def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None:
worksheet.append([]) worksheet.append([])
_append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3) _append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=4)
header_row = worksheet.max_row + 1 header_row = worksheet.max_row + 1
worksheet.append( worksheet.append(
_excel_table_row( _excel_table_row(
[locale.t("hourly_rate"), locale.t("from"), locale.t("to")], [locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")],
) )
) )
for cell in worksheet[header_row]: for cell in worksheet[header_row]:
@@ -327,6 +345,7 @@ def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_su
_rate_period_label(locale, row, ascii_digits=True), _rate_period_label(locale, row, ascii_digits=True),
locale.format_date(row["from_date"], ascii_digits=True), locale.format_date(row["from_date"], ascii_digits=True),
_rate_to_label(locale, row.get("to_date"), ascii_digits=True), _rate_to_label(locale, row.get("to_date"), ascii_digits=True),
row.get("project_name") or "-",
], ],
) )
) )
@@ -562,7 +581,7 @@ def _append_breakdown_table(
), ),
_money_label_excel(locale, row["income_totals"]), _money_label_excel(locale, row["income_totals"]),
*( *(
[_percentage_display(locale, income_percentages or [], row, ascii_digits=True)] [_percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-")]
if hour_percentages is not None if hour_percentages is not None
else [] else []
), ),
@@ -611,6 +630,8 @@ def _append_user_details_block_excel(
def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value: str, rtl: bool) -> None: def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value: str, rtl: bool) -> None:
if end_col < start_col:
end_col = start_col
worksheet.merge_cells(start_row=row, start_column=start_col, end_row=row, end_column=end_col) worksheet.merge_cells(start_row=row, start_column=start_col, end_row=row, end_column=end_col)
cell = worksheet.cell(row=row, column=start_col) cell = worksheet.cell(row=row, column=start_col)
cell.value = value cell.value = value
@@ -620,12 +641,19 @@ def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value
def _append_merged_heading(worksheet, *, locale: ExportLocale, title: str, span: int) -> int: def _append_merged_heading(worksheet, *, locale: ExportLocale, title: str, span: int) -> int:
worksheet.append([title]) worksheet.append([title])
row = worksheet.max_row row = worksheet.max_row
span = max(int(span), 1)
if span > 1: if span > 1:
worksheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=span) worksheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=span)
_apply_cell_style(worksheet.cell(row=row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) _apply_cell_style(worksheet.cell(row=row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl)
return row return row
def _clear_excel_cell_style(cell) -> None:
cell.value = None
cell.fill = PatternFill(fill_type=None)
cell.border = EMPTY_BORDER
def _write_table_row( def _write_table_row(
worksheet, worksheet,
*, *,
@@ -669,8 +697,11 @@ def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int,
locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None, locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None,
*(rate_rows[index] if index < len(rate_rows) else [None, None]), *(rate_rows[index] if index < len(rate_rows) else [None, None]),
_money_label_excel(locale, summary["income_totals"]) if index == 0 else None, _money_label_excel(locale, summary["income_totals"]) if index == 0 else None,
None,
*(client_rows[index] if index < len(client_rows) else [None, None, None]), *(client_rows[index] if index < len(client_rows) else [None, None, None]),
None,
*(project_rows[index] if index < len(project_rows) else [None, None, None]), *(project_rows[index] if index < len(project_rows) else [None, None, None]),
None,
*(tag_rows[index] if index < len(tag_rows) else [None, None, None]), *(tag_rows[index] if index < len(tag_rows) else [None, None, None]),
], ],
) )
@@ -695,6 +726,7 @@ def _render_all_users_overall_excel_sheet(
) -> None: ) -> None:
if locale.is_rtl: if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True worksheet.sheet_view.rightToLeft = True
_freeze_first_column(worksheet)
scope = report_data["scope"] scope = report_data["scope"]
summary = report_data["summary"] summary = report_data["summary"]
@@ -729,14 +761,10 @@ def _render_all_users_overall_excel_sheet(
for row_index, values in enumerate(summary_rows, start=10): for row_index, values in enumerate(summary_rows, start=10):
_write_table_row(worksheet, row=row_index, start_col=1, values=values, rtl=locale.is_rtl) _write_table_row(worksheet, row=row_index, start_col=1, values=values, rtl=locale.is_rtl)
_merge_and_style( _merge_and_style(worksheet, row=15, start_col=1, end_col=6, value=locale.t("users_summary_sheet"), rtl=locale.is_rtl)
worksheet, _merge_and_style(worksheet, row=15, start_col=8, end_col=10, value=locale.t("clients"), rtl=locale.is_rtl)
row=15, _merge_and_style(worksheet, row=15, start_col=12, end_col=14, value=locale.t("projects"), rtl=locale.is_rtl)
start_col=1, _merge_and_style(worksheet, row=15, start_col=16, end_col=18, value=locale.t("tags"), rtl=locale.is_rtl)
end_col=15,
value=locale.t("users_summary_sheet"),
rtl=locale.is_rtl,
)
summary_headers = [ summary_headers = [
locale.t("name"), locale.t("name"),
locale.t("mobile"), locale.t("mobile"),
@@ -744,12 +772,15 @@ def _render_all_users_overall_excel_sheet(
locale.t("hourly_rate"), locale.t("hourly_rate"),
locale.t("period"), locale.t("period"),
locale.t("income"), locale.t("income"),
"",
locale.t("clients"), locale.t("clients"),
locale.t("hour_percentage"), locale.t("hour_percentage"),
locale.t("income_percentage"), locale.t("income_percentage"),
"",
locale.t("projects"), locale.t("projects"),
locale.t("hour_percentage"), locale.t("hour_percentage"),
locale.t("income_percentage"), locale.t("income_percentage"),
"",
locale.t("tags"), locale.t("tags"),
locale.t("hour_percentage"), locale.t("hour_percentage"),
locale.t("income_percentage"), locale.t("income_percentage"),
@@ -774,6 +805,10 @@ def _render_all_users_overall_excel_sheet(
values=values, values=values,
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
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): for column in (1, 2, 3, 6):
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column)
rate_rows = user_summary.get("rate_periods") or [] rate_rows = user_summary.get("rate_periods") or []
@@ -784,19 +819,23 @@ def _render_all_users_overall_excel_sheet(
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=4, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=4, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=5, value_present=True)
if len(client_rows) == 1: if len(client_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True)
if len(project_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True) if len(project_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True)
if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True) _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=14, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=15, value_present=True) if len(tag_rows) == 1:
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=16, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=17, value_present=True)
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True)
current_row += span current_row += span
for row_index in range(15, current_row):
for column_index in (7, 11, 15):
_clear_excel_cell_style(worksheet.cell(row=row_index, column=column_index))
current_row += 2 current_row += 2
for title_key, rows, hour_percentages, income_percentages in ( for title_key, rows, hour_percentages, income_percentages in (
( (
@@ -822,7 +861,7 @@ def _render_all_users_overall_excel_sheet(
worksheet, worksheet,
row=current_row, row=current_row,
start_col=1, start_col=1,
end_col=7, end_col=5,
value=locale.t(title_key), value=locale.t(title_key),
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
@@ -855,7 +894,7 @@ def _render_all_users_overall_excel_sheet(
locale.format_duration(row["billable_duration"], ascii_digits=True), locale.format_duration(row["billable_duration"], ascii_digits=True),
_percentage_display(locale, hour_percentages or [], row, ascii_digits=True), _percentage_display(locale, hour_percentages or [], row, ascii_digits=True),
_money_label_excel(locale, row["income_totals"]), _money_label_excel(locale, row["income_totals"]),
_percentage_display(locale, income_percentages or [], row, ascii_digits=True), _percentage_display(locale, income_percentages or [], row, ascii_digits=True, default="-"),
], ],
rtl=locale.is_rtl, rtl=locale.is_rtl,
) )
@@ -901,9 +940,7 @@ def _render_excel_sheet(
) -> None: ) -> None:
if locale.is_rtl: if locale.is_rtl:
worksheet.sheet_view.rightToLeft = True worksheet.sheet_view.rightToLeft = True
worksheet.freeze_panes = "E4" _freeze_first_column(worksheet)
else:
worksheet.freeze_panes = "A4"
_append_meta_block(worksheet, locale=locale, report_data=report_data) _append_meta_block(worksheet, locale=locale, report_data=report_data)
if report_data.get("user_summaries"): if report_data.get("user_summaries"):
worksheet.append([]) worksheet.append([])
@@ -1121,7 +1158,7 @@ def _build_pdf_user_summary_table(locale: ExportLocale, summary: dict, doc_width
def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table: def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table:
rows = summary.get("rate_periods") or [] rows = summary.get("rate_periods") or []
data = [ data = [
_rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to")]), _rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to"), locale.t("project")]),
*( *(
_rtl_row( _rtl_row(
locale, locale,
@@ -1129,14 +1166,15 @@ def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width
_rate_period_label(locale, row), _rate_period_label(locale, row),
locale.format_date(row["from_date"]), locale.format_date(row["from_date"]),
_rate_to_label(locale, row.get("to_date")), _rate_to_label(locale, row.get("to_date")),
row.get("project_name") or "-",
], ],
) )
for row in rows for row in rows
), ),
] ]
if not rows: if not rows:
data.append(_rtl_row(locale, [locale.t("no_data"), "", ""])) data.append(_rtl_row(locale, [locale.t("no_data"), "", "", ""]))
fixed_widths = [doc_width * 0.18, doc_width * 0.18] fixed_widths = [doc_width * 0.18, doc_width * 0.18, doc_width * 0.24]
column_widths = [doc_width - sum(fixed_widths), *fixed_widths] column_widths = [doc_width - sum(fixed_widths), *fixed_widths]
if locale.is_rtl: if locale.is_rtl:
column_widths = list(reversed(column_widths)) column_widths = list(reversed(column_widths))
@@ -1227,7 +1265,7 @@ def _append_pdf_report_sections(
] ]
), ),
_money_label(locale, row["income_totals"]), _money_label(locale, row["income_totals"]),
_percentage_display(locale, income_percentage_rows or [], row), _percentage_display(locale, income_percentage_rows or [], row, default="-"),
], ],
) )
for row in sorted_rows for row in sorted_rows

View File

@@ -196,12 +196,22 @@ class ReportExporterTests(TestCase):
summary_sheet = workbook[workbook.sheetnames[0]] summary_sheet = workbook[workbook.sheetnames[0]]
summary_values = list(summary_sheet.iter_rows(values_only=True)) summary_values = list(summary_sheet.iter_rows(values_only=True))
self.assertEqual(summary_sheet.freeze_panes, "B1")
self.assertEqual(summary_sheet["A1"].value, "Workspace Report") self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
self.assertEqual(summary_sheet["B1"].value, "Exports") self.assertEqual(summary_sheet["B1"].value, "Exports")
self.assertEqual(summary_sheet["A15"].value, "Users Summary") self.assertEqual(summary_sheet["A15"].value, "Users Summary")
self.assertIn("A15:O15", {str(item) for item in summary_sheet.merged_cells.ranges}) merged_ranges = {str(item) for item in summary_sheet.merged_cells.ranges}
self.assertIn("A15:F15", merged_ranges)
self.assertIn("H15:J15", merged_ranges)
self.assertIn("L15:N15", merged_ranges)
self.assertIn("P15:R15", merged_ranges)
self.assertNotIn("A15:R15", merged_ranges)
self.assertIsNone(summary_sheet["G15"].fill.fill_type)
self.assertIsNone(summary_sheet["G16"].fill.fill_type)
self.assertIsNone(summary_sheet["K15"].fill.fill_type)
self.assertIsNone(summary_sheet["O15"].fill.fill_type)
self.assertEqual( self.assertEqual(
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:15], tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18],
( (
"Name", "Name",
"Mobile", "Mobile",
@@ -209,12 +219,15 @@ class ReportExporterTests(TestCase):
"Hourly rate", "Hourly rate",
"Period", "Period",
"Income", "Income",
None,
"Clients", "Clients",
"Hour %", "Hour %",
"Income %", "Income %",
None,
"Projects", "Projects",
"Hour %", "Hour %",
"Income %", "Income %",
None,
"Tags", "Tags",
"Hour %", "Hour %",
"Income %", "Income %",
@@ -223,10 +236,14 @@ class ReportExporterTests(TestCase):
self.assertTrue(any(row and "Owner User" in row for row in summary_values)) self.assertTrue(any(row and "Owner User" in row for row in summary_values))
self.assertTrue(any(row and "09129990001" in row for row in summary_values)) self.assertTrue(any(row and "09129990001" in row for row in summary_values))
self.assertTrue(any(row and "Variable rate" in row for row in summary_values)) self.assertTrue(any(row and "Variable rate" in row for row in summary_values))
self.assertEqual(summary_sheet["A17"].border.top.style, "medium")
self.assertEqual(summary_sheet["A18"].border.top.style, "medium")
self.assertIsNone(summary_sheet["G17"].border.top)
user_sheet = workbook[workbook.sheetnames[1]] user_sheet = workbook[workbook.sheetnames[1]]
user_values = list(user_sheet.iter_rows(values_only=True)) user_values = list(user_sheet.iter_rows(values_only=True))
self.assertEqual(user_sheet.freeze_panes, "B1")
daily_header = next(row[:6] for row in user_values if row and "Date" in row) daily_header = next(row[:6] for row in user_values if row and "Date" in row)
self.assertEqual( self.assertEqual(
daily_header, daily_header,

View File

@@ -1,4 +1,5 @@
from rest_framework import serializers from rest_framework import serializers
from django.utils import timezone
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
@@ -31,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer):
tag_details = serializers.SerializerMethodField() tag_details = serializers.SerializerMethodField()
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S") start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True) end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
start_time_ms = serializers.SerializerMethodField()
end_time_ms = serializers.SerializerMethodField()
server_now_ms = serializers.SerializerMethodField()
@staticmethod
def _epoch_ms(value):
if value is None:
return None
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return int(value.timestamp() * 1000)
def get_start_time_ms(self, obj):
return self._epoch_ms(obj.start_time)
def get_end_time_ms(self, obj):
return self._epoch_ms(obj.end_time)
def get_server_now_ms(self, obj):
server_now = self.context.get("server_now") or timezone.now()
return self._epoch_ms(server_now)
def get_tags(self, obj): def get_tags(self, obj):
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")] return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
@@ -76,7 +98,10 @@ class TimeEntrySerializer(BaseModelSerializer):
"project_details", "project_details",
"description", "description",
"start_time", "start_time",
"start_time_ms",
"end_time", "end_time",
"end_time_ms",
"server_now_ms",
"duration", "duration",
"tags", "tags",
"tag_details", "tag_details",

View File

@@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet):
filterset_class = TimeEntryFilter filterset_class = TimeEntryFilter
search_fields = ["description", "project__name", "project__client__name", "tags__name"] search_fields = ["description", "project__name", "project__client__name", "tags__name"]
@staticmethod
def _epoch_ms(value):
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return int(value.timestamp() * 1000)
def _serializer_context(self, *, server_now=None):
context = self.get_serializer_context()
context["server_now"] = server_now or timezone.now()
return context
@staticmethod @staticmethod
def _serialize_duration_ms(entry): def _serialize_duration_ms(entry):
if entry.duration is not None: if entry.duration is not None:
@@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet):
days_since_sunday = (local_dt.weekday() + 1) % 7 days_since_sunday = (local_dt.weekday() + 1) % 7
return (local_dt - timedelta(days=days_since_sunday)).date() return (local_dt - timedelta(days=days_since_sunday)).date()
def _build_grouped_entries(self, entries): def _build_grouped_entries(self, entries, *, server_now):
serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data serialized_entries = TimeEntrySerializer(
entries,
many=True,
context=self._serializer_context(server_now=server_now),
).data
serialized_by_id = {item["id"]: item for item in serialized_entries} serialized_by_id = {item["id"]: item for item in serialized_entries}
weeks = [] weeks = []
weeks_by_key = {} weeks_by_key = {}
@@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet):
queryset = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
paginator = self.pagination_class() paginator = self.pagination_class()
page = paginator.paginate_queryset(queryset, request, view=self) page = paginator.paginate_queryset(queryset, request, view=self)
server_now = timezone.now()
current_items_count = len(page) current_items_count = len(page)
has_more = (paginator.offset + current_items_count) < paginator.count has_more = (paginator.offset + current_items_count) < paginator.count
@@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet):
"offset": paginator.offset, "offset": paginator.offset,
"next_offset": paginator.offset + current_items_count if has_more else None, "next_offset": paginator.offset + current_items_count if has_more else None,
"has_more": has_more, "has_more": has_more,
"groups": self._build_grouped_entries(page), "server_now_ms": self._epoch_ms(server_now),
"server_now": server_now.isoformat(),
"groups": self._build_grouped_entries(page, server_now=server_now),
}
)
@action(detail=False, methods=["get"], url_path="debug-time")
def debug_time(self, request):
server_now = timezone.now()
return Response(
{
"server_now_ms": self._epoch_ms(server_now),
"server_now": server_now.isoformat(),
} }
) )
@@ -148,7 +176,7 @@ class TimeEntryViewSet(ModelViewSet):
**serializer.validated_data **serializer.validated_data
) )
output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context()) output_serializer = TimeEntrySerializer(entry, context=self._serializer_context())
return Response(output_serializer.data, status=status.HTTP_201_CREATED) return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
@@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet):
**serializer.validated_data **serializer.validated_data
) )
output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context()) output_serializer = TimeEntrySerializer(updated_entry, context=self._serializer_context())
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
@@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet):
end_time = serializer.validated_data.get("end_time") end_time = serializer.validated_data.get("end_time")
stopped_entry = stop_time_entry(entry, end_time=end_time) stopped_entry = stop_time_entry(entry, end_time=end_time)
output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context()) output_serializer = TimeEntrySerializer(stopped_entry, context=self._serializer_context())
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import datetime, timedelta
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
@@ -40,6 +40,9 @@ class TimeEntryViewTests(APITestCase):
self.assertIsNone(entry.end_time) self.assertIsNone(entry.end_time)
self.assertGreaterEqual(entry.start_time, before) self.assertGreaterEqual(entry.start_time, before)
self.assertLessEqual(entry.start_time, after) self.assertLessEqual(entry.start_time, after)
self.assertIsInstance(response.data["start_time_ms"], int)
self.assertIsNone(response.data["end_time_ms"])
self.assertIsInstance(response.data["server_now_ms"], int)
def test_time_entry_list_returns_grouped_payload_for_ended_entries(self): def test_time_entry_list_returns_grouped_payload_for_ended_entries(self):
user = User.objects.create_user(mobile="09126666666", password="secret123") user = User.objects.create_user(mobile="09126666666", password="secret123")
@@ -72,6 +75,8 @@ class TimeEntryViewTests(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["current_page_items_count"], 1) self.assertEqual(response.data["current_page_items_count"], 1)
self.assertIsInstance(response.data["server_now_ms"], int)
self.assertIn("server_now", response.data)
self.assertFalse(response.data["has_more"]) self.assertFalse(response.data["has_more"])
self.assertEqual(len(response.data["groups"]), 1) self.assertEqual(len(response.data["groups"]), 1)
self.assertEqual(len(response.data["groups"][0]["days"]), 1) self.assertEqual(len(response.data["groups"][0]["days"]), 1)
@@ -79,6 +84,41 @@ class TimeEntryViewTests(APITestCase):
response.data["groups"][0]["days"][0]["entries"][0]["id"], response.data["groups"][0]["days"][0]["entries"][0]["id"],
str(first_entry.id), str(first_entry.id),
) )
entry_payload = response.data["groups"][0]["days"][0]["entries"][0]
self.assertIsInstance(entry_payload["start_time_ms"], int)
self.assertIsInstance(entry_payload["end_time_ms"], int)
self.assertIsInstance(entry_payload["server_now_ms"], int)
def test_debug_time_returns_server_clock_payload(self):
user = User.objects.create_user(mobile="09126666667", password="secret123")
self.client.force_authenticate(user=user)
response = self.client.get("/api/time-entries/debug-time/")
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.data["server_now_ms"], int)
self.assertIn("server_now", response.data)
def test_stop_running_time_entry_returns_server_epoch_fields(self):
user = User.objects.create_user(mobile="09126666668", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=user)
entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
description="Running work",
start_time=timezone.now() - timedelta(seconds=5),
)
self.client.force_authenticate(user=user)
response = self.client.post(f"/api/time-entries/{entry.id}/stop/", {}, format="json")
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response.data["start_time_ms"], int)
self.assertIsInstance(response.data["end_time_ms"], int)
self.assertIsInstance(response.data["server_now_ms"], int)
entry.refresh_from_db()
self.assertIsNotNone(entry.duration)
self.assertGreaterEqual(entry.duration.total_seconds(), 5)
def test_time_entry_update_preserves_current_deleted_tags(self): def test_time_entry_update_preserves_current_deleted_tags(self):
user = User.objects.create_user(mobile="09127777777", password="secret123") user = User.objects.create_user(mobile="09127777777", password="secret123")

View File

@@ -212,10 +212,12 @@ class UserProfileSerializer(BaseModelSerializer):
"profile_picture", "profile_picture",
"birth_date", "birth_date",
"is_verified", "is_verified",
"is_demo",
"demo_expires_at",
"full_name", "full_name",
"age", "age",
) )
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified") read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified", "is_demo", "demo_expires_at")
class UserSearchSerializer(serializers.ModelSerializer): class UserSearchSerializer(serializers.ModelSerializer):

View File

@@ -293,6 +293,11 @@ class ChangePasswordView(APIView):
@extend_schema(request=ChangePasswordSerializer) @extend_schema(request=ChangePasswordSerializer)
def patch(self, request, *args, **kwargs): def patch(self, request, *args, **kwargs):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot change passwords."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = ChangePasswordSerializer(data=request.data, context={"request": request}) serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -327,6 +332,11 @@ class SetPasswordView(UpdateAPIView):
@extend_schema(request=ChangePasswordSerializer, responses=None) @extend_schema(request=ChangePasswordSerializer, responses=None)
def patch(self, request, *args, **kwargs): def patch(self, request, *args, **kwargs):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot change passwords."},
status=status.HTTP_403_FORBIDDEN,
)
return super().patch(request, *args, **kwargs) return super().patch(request, *args, **kwargs)
def get_object(self): def get_object(self):
@@ -347,6 +357,11 @@ class ProfilePictureView(APIView):
operation_id="users_profile_picture_self_create", operation_id="users_profile_picture_self_create",
) )
def post(self, request): def post(self, request):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot upload profile pictures."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = UserProfilePictureSerializer( serializer = UserProfilePictureSerializer(
instance=request.user, instance=request.user,
data=request.data, data=request.data,
@@ -362,6 +377,11 @@ class ProfilePictureView(APIView):
operation_id="users_profile_picture_self_delete", operation_id="users_profile_picture_self_delete",
) )
def delete(self, request): def delete(self, request):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot remove profile pictures."},
status=status.HTTP_403_FORBIDDEN,
)
request.user.profile_picture.delete(save=False) request.user.profile_picture.delete(save=False)
request.user.profile_picture = None request.user.profile_picture = None
request.user.save(update_fields=["profile_picture", "updated_at"]) request.user.save(update_fields=["profile_picture", "updated_at"])
@@ -401,6 +421,11 @@ class UserSearchAPIView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot search external users."},
status=status.HTTP_403_FORBIDDEN,
)
mobile = request.query_params.get('mobile') mobile = request.query_params.get('mobile')
if not mobile: if not mobile:
return Response( return Response(

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

View File

@@ -81,7 +81,11 @@ def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
def generate_and_send_otp(mobile, mode): def generate_and_send_otp(mobile, mode):
"""Business logic for generating OTP, checking existence rules, and sending SMS.""" """Business logic for generating OTP, checking existence rules, and sending SMS."""
user_exists = User.objects.filter(mobile=mobile).exists() user = User.objects.filter(mobile=mobile).only("is_demo").first()
user_exists = user is not None
if user and user.is_demo:
raise ValidationError({"mobile": "Demo accounts cannot use SMS verification."})
if mode == "register" and user_exists: if mode == "register" and user_exists:
raise ValidationError({"mobile": "این شماره قبلاً ثبت‌نام شده است."}) raise ValidationError({"mobile": "این شماره قبلاً ثبت‌نام شده است."})

View File

@@ -94,6 +94,14 @@ class WorkspaceViewSet(ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(owner=self.request.user) serializer.save(owner=self.request.user)
def create(self, request, *args, **kwargs):
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot create additional workspaces."},
status=status.HTTP_403_FORBIDDEN,
)
return super().create(request, *args, **kwargs)
@action(detail=True, methods=["get"], url_path="my-rates") @action(detail=True, methods=["get"], url_path="my-rates")
def my_rates(self, request, pk=None): def my_rates(self, request, pk=None):
workspace = self.get_object() workspace = self.get_object()
@@ -246,7 +254,12 @@ class WorkspaceMembershipViewSet(ModelViewSet):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
workspace = get_object_or_404(Workspace, id=workspace_id) workspace = get_object_or_404(Workspace, id=workspace_id)
if getattr(request.user, "is_demo", False):
return Response(
{"detail": "Demo accounts cannot add workspace members."},
status=status.HTTP_403_FORBIDDEN,
)
permission = IsWorkspaceAdmin() permission = IsWorkspaceAdmin()
if not permission.has_object_permission(request, self, workspace): if not permission.has_object_permission(request, self, workspace):

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, "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 django.utils import timezone
from apps.workspaces.models import WorkspaceUserRate from apps.workspaces.models import HourlyRateHistory, WorkspaceUserRate
def record_workspace_rate_history(*, workspace, user_id, hourly_rate, currency, effective_from=None):
currency = currency.upper()
effective_from = effective_from or timezone.now()
latest = (
HourlyRateHistory.objects.filter(
workspace=workspace,
user_id=user_id,
scope=HourlyRateHistory.Scope.WORKSPACE,
project__isnull=True,
is_deleted=False,
)
.order_by("-effective_from", "-created_at")
.first()
)
if latest and latest.hourly_rate == hourly_rate and latest.currency == currency:
return latest
return HourlyRateHistory.objects.create(
workspace=workspace,
user_id=user_id,
project=None,
scope=HourlyRateHistory.Scope.WORKSPACE,
hourly_rate=hourly_rate,
currency=currency,
effective_from=effective_from,
is_active=True,
)
def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"): def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
@@ -11,6 +39,7 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
is_deleted=False, is_deleted=False,
).first() ).first()
effective_from = timezone.now()
if rate: if rate:
update_fields = [] update_fields = []
if rate.hourly_rate != hourly_rate: if rate.hourly_rate != hourly_rate:
@@ -25,16 +54,31 @@ def upsert_workspace_user_rate(workspace, user_id, hourly_rate, currency="USD"):
if update_fields: if update_fields:
update_fields.append("updated_at") update_fields.append("updated_at")
rate.save(update_fields=update_fields) rate.save(update_fields=update_fields)
record_workspace_rate_history(
workspace=workspace,
user_id=user_id,
hourly_rate=rate.hourly_rate,
currency=rate.currency,
effective_from=effective_from,
)
return rate return rate
return WorkspaceUserRate.objects.create( rate = WorkspaceUserRate.objects.create(
workspace=workspace, workspace=workspace,
user_id=user_id, user_id=user_id,
hourly_rate=hourly_rate, hourly_rate=hourly_rate,
currency=currency, currency=currency,
effective_from=timezone.now(), effective_from=effective_from,
is_active=True, is_active=True,
) )
record_workspace_rate_history(
workspace=workspace,
user_id=user_id,
hourly_rate=rate.hourly_rate,
currency=rate.currency,
effective_from=rate.effective_from,
)
return rate
def update_workspace_user_rate(rate_instance, **kwargs): def update_workspace_user_rate(rate_instance, **kwargs):
@@ -50,5 +94,12 @@ def update_workspace_user_rate(rate_instance, **kwargs):
if update_fields: if update_fields:
update_fields.append("updated_at") update_fields.append("updated_at")
rate_instance.save(update_fields=update_fields) rate_instance.save(update_fields=update_fields)
if {"hourly_rate", "currency", "is_active"} & set(update_fields):
record_workspace_rate_history(
workspace=rate_instance.workspace,
user_id=rate_instance.user_id,
hourly_rate=rate_instance.hourly_rate,
currency=rate_instance.currency,
)
return rate_instance return rate_instance

View File

@@ -2,6 +2,7 @@ import os
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from celery.schedules import crontab
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@@ -48,6 +49,8 @@ LOCAL_APPS = [
"apps.notifications", "apps.notifications",
"apps.reports", "apps.reports",
"apps.logs", "apps.logs",
"apps.demos",
"apps.contacts",
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@@ -138,6 +141,8 @@ REST_FRAMEWORK = {
"otp_send_sustained": "10/day", "otp_send_sustained": "10/day",
"login_password": "5/10m", "login_password": "5/10m",
"login_otp": "5/10m", "login_otp": "5/10m",
"demo_start": os.getenv("DEMO_START_RATE_LIMIT", "10/hour"),
"contact_submission": os.getenv("CONTACT_SUBMISSION_RATE_LIMIT", "5/hour"),
}, },
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler", "EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
} }
@@ -243,7 +248,21 @@ NOTIFICATION_TOAST_LEVELS = tuple(
REPORT_EXPORT_RETENTION_DAYS = int(os.getenv("REPORT_EXPORT_RETENTION_DAYS", "7")) REPORT_EXPORT_RETENTION_DAYS = int(os.getenv("REPORT_EXPORT_RETENTION_DAYS", "7"))
CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks", "apps.reports.tasks") DEMO_ENABLED = os.getenv("DEMO_ENABLED", "True") == "True"
DEMO_ENVIRONMENT_TTL_HOURS = int(os.getenv("DEMO_ENVIRONMENT_TTL_HOURS", "24"))
DEMO_CLEANUP_BATCH_SIZE = int(os.getenv("DEMO_CLEANUP_BATCH_SIZE", "100"))
CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks", "apps.reports.tasks", "apps.demos.tasks")
CELERY_BEAT_SCHEDULE = {
"reports-cleanup-expired-exports": {
"task": "reports.cleanup_expired_exports",
"schedule": crontab(minute=0, hour="*/6"),
},
"demos-cleanup-expired-environments": {
"task": "demos.cleanup_expired_environments",
"schedule": crontab(minute=0, hour="*"),
},
}
STORAGES = { STORAGES = {

View File

@@ -24,6 +24,8 @@ urlpatterns = [
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"), path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
path("api/reports/", include("apps.reports.api.urls"), name="reports"), path("api/reports/", include("apps.reports.api.urls"), name="reports"),
path("api/logs/", include("apps.logs.api.urls"), name="logs"), path("api/logs/", include("apps.logs.api.urls"), name="logs"),
path("api/demo/", include("apps.demos.api.urls"), name="demos"),
path("api/contact/", include("apps.contacts.api.urls"), name="contacts"),
] ]
if settings.DEBUG: if settings.DEBUG: