Compare commits

...

13 Commits

Author SHA1 Message Date
027afb7e23 feat(contacts): store contact submissions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 14:09:38 +03:30
170ec90ec1 fix(demo): block external account actions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 00:50:42 +03:30
30a324c6f4 feat(demo): add isolated demo environments 2026-06-07 00:49:58 +03:30
da40720a0f fix(reports): freeze first excel column
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 17:22:34 +03:30
948a8e1b75 fix(reports): improve excel summary table spacing 2026-05-26 17:20:18 +03:30
b5ddcb76aa fix(timezone): fix timer clock-skew
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:59:49 +03:30
20874b9968 feat(reports): improve summary rates and export formatting
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:15:44 +03:30
af9facce7e feat(rates): record hourly rate history 2026-05-26 12:15:27 +03:30
e42e0612aa feat(media): add client and project thumbnails 2026-05-26 12:15:09 +03:30
f99e883f12 feat(reports): sort exported breakdown tables
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-25 00:10:28 +03:30
d18fdb1454 refactor(reports): replace escaped persian export labels
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-24 11:16:59 +03:30
5500badc6a refactor(users): replace escaped persian auth messages 2026-05-24 11:01:50 +03:30
2a0fa22be6 feat(projects): support implicit-access roles in rates modal 2026-05-24 10:18:31 +03:30
62 changed files with 1935 additions and 316 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

@@ -24,9 +24,10 @@ from apps.projects.services.access import (
build_project_access_items, build_project_access_items,
ensure_workspace_project_access, ensure_workspace_project_access,
filter_projects_for_user, filter_projects_for_user,
get_access_managed_membership, get_project_access_target_membership,
grant_project_accesses, grant_project_accesses,
revoke_project_accesses, revoke_project_accesses,
user_has_project_access,
) )
from apps.projects.services.rates import get_current_project_user_rate, remove_project_user_rate, upsert_project_user_rate from apps.projects.services.rates import get_current_project_user_rate, remove_project_user_rate, upsert_project_user_rate
from apps.projects.services.projects import ( from apps.projects.services.projects import (
@@ -88,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):
@@ -119,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):
@@ -143,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):
@@ -168,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")
@@ -182,7 +187,7 @@ class ProjectViewSet(ModelViewSet):
is_deleted=False, is_deleted=False,
) )
ensure_workspace_project_access(request.user, workspace) ensure_workspace_project_access(request.user, workspace)
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
return Response( return Response(
{ {
@@ -207,7 +212,7 @@ class ProjectViewSet(ModelViewSet):
id=serializer.validated_data["workspace"], id=serializer.validated_data["workspace"],
is_deleted=False, is_deleted=False,
) )
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
changed = grant_project_accesses( changed = grant_project_accesses(
actor=request.user, actor=request.user,
workspace=workspace, workspace=workspace,
@@ -226,7 +231,7 @@ class ProjectViewSet(ModelViewSet):
id=serializer.validated_data["workspace"], id=serializer.validated_data["workspace"],
is_deleted=False, is_deleted=False,
) )
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
changed = revoke_project_accesses( changed = revoke_project_accesses(
actor=request.user, actor=request.user,
workspace=workspace, workspace=workspace,
@@ -246,7 +251,7 @@ class ProjectViewSet(ModelViewSet):
is_deleted=False, is_deleted=False,
) )
ensure_workspace_project_access(request.user, workspace) ensure_workspace_project_access(request.user, workspace)
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"]))
project = get_object_or_404( project = get_object_or_404(
Project, Project,
id=serializer.validated_data["project"], id=serializer.validated_data["project"],
@@ -254,7 +259,7 @@ class ProjectViewSet(ModelViewSet):
is_deleted=False, is_deleted=False,
) )
has_access = membership.user.project_accesses.filter(project=project).exists() has_access = user_has_project_access(membership.user, project)
if not has_access: if not has_access:
return Response( return Response(
{"detail": "Grant project access before setting a project-specific rate."}, {"detail": "Grant project access before setting a project-specific rate."},

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

@@ -66,7 +66,7 @@ def ensure_workspace_project_access(user, workspace: Workspace) -> None:
raise PermissionDenied("You do not have permission to manage project access in this workspace.") raise PermissionDenied("You do not have permission to manage project access in this workspace.")
def get_access_managed_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership: def get_project_access_target_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership:
membership = WorkspaceMembership.objects.filter( membership = WorkspaceMembership.objects.filter(
workspace=workspace, workspace=workspace,
user_id=user_id, user_id=user_id,
@@ -75,8 +75,6 @@ def get_access_managed_membership(workspace: Workspace, user_id: str) -> Workspa
).select_related("user").first() ).select_related("user").first()
if not membership: if not membership:
raise ValidationError({"user": "Selected user is not an active member of this workspace."}) raise ValidationError({"user": "Selected user is not an active member of this workspace."})
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
raise ValidationError({"user": "Owners and admins have implicit access to all projects."})
return membership return membership
@@ -146,7 +144,7 @@ def build_project_access_items(*, workspace: Workspace, target_user) -> list[dic
return [ return [
build_project_access_item( build_project_access_item(
project=project, project=project,
has_access=str(project.id) in explicit_access_ids, has_access=user_has_project_access(target_user, project) if user_has_implicit_project_access(target_user, workspace) else str(project.id) in explicit_access_ids,
workspace_rate=workspace_rate, workspace_rate=workspace_rate,
project_rate=project_rates.get(str(project.id)), project_rate=project_rates.get(str(project.id)),
) )
@@ -156,7 +154,9 @@ def build_project_access_items(*, workspace: Workspace, target_user) -> list[dic
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int: def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
ensure_workspace_project_access(actor, workspace) ensure_workspace_project_access(actor, workspace)
get_access_managed_membership(workspace, str(target_user.id)) membership = get_project_access_target_membership(workspace, str(target_user.id))
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
raise ValidationError({"user": "Owners and admins already have access to all projects."})
projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False)) projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False))
if len(projects) != len(set(project_ids)): if len(projects) != len(set(project_ids)):
@@ -175,7 +175,9 @@ def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_
def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int: def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
ensure_workspace_project_access(actor, workspace) ensure_workspace_project_access(actor, workspace)
get_access_managed_membership(workspace, str(target_user.id)) membership = get_project_access_target_membership(workspace, str(target_user.id))
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
raise ValidationError({"user": "Owners and admins always keep project access."})
accesses = list( accesses = list(
ProjectAccess.objects.filter( ProjectAccess.objects.filter(

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

@@ -191,3 +191,29 @@ class ProjectViewTests(APITestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn("Grant project access", response.data["detail"]) self.assertIn("Grant project access", response.data["detail"])
def test_owner_access_state_marks_all_projects_as_accessible_and_allows_project_rate_override(self):
self.client.force_authenticate(user=self.owner)
access_response = self.client.get(
"/api/projects/access/",
{"workspace": str(self.workspace.id), "user": str(self.owner.id)},
)
self.assertEqual(access_response.status_code, 200)
self.assertTrue(all(item["has_access"] for item in access_response.data["items"]))
save_response = self.client.post(
"/api/projects/access/rate/",
{
"workspace": str(self.workspace.id),
"user": str(self.owner.id),
"project": str(self.first_project.id),
"hourly_rate": "60.00",
"currency": "USD",
},
format="json",
)
self.assertEqual(save_response.status_code, 200)
self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "60.00")
self.assertTrue(ProjectUserRate.objects.filter(project=self.first_project, user=self.owner, is_deleted=False).exists())

View File

@@ -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()
@@ -53,9 +52,9 @@ UNCATEGORIZED_LABELS = {
"tags": "No tag", "tags": "No tag",
}, },
"fa": { "fa": {
"clients": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc", "clients": "بدون مشتری",
"projects": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647", "projects": "بدون پروژه",
"tags": "\u0628\u062f\u0648\u0646 \u062a\u06af", "tags": "بدون تگ",
}, },
} }
@@ -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

@@ -10,7 +10,7 @@ import jdatetime
from arabic_reshaper import reshape from arabic_reshaper import reshape
from bidi.algorithm import get_display from bidi.algorithm import get_display
PERSIAN_DIGITS = str.maketrans("0123456789", "\u06f0\u06f1\u06f2\u06f3\u06f4\u06f5\u06f6\u06f7\u06f8\u06f9") PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹")
ARABIC_RANGES = ( ARABIC_RANGES = (
(0x0600, 0x06FF), (0x0600, 0x06FF),
(0x0750, 0x077F), (0x0750, 0x077F),
@@ -70,52 +70,52 @@ TRANSLATIONS = {
"uncategorized_tag": "No tag", "uncategorized_tag": "No tag",
}, },
"fa": { "fa": {
"report_title": "\u06af\u0632\u0627\u0631\u0634 \u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc", "report_title": "گزارش فضای کاری",
"overall_sheet": "\u06af\u0632\u0627\u0631\u0634 \u06a9\u0644\u06cc", "overall_sheet": "گزارش کلی",
"users_summary_sheet": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", "users_summary_sheet": "خلاصه کاربران",
"workspace": "\u0641\u0636\u0627\u06cc \u06a9\u0627\u0631\u06cc", "workspace": "فضای کاری",
"period": "\u0628\u0627\u0632\u0647", "period": "بازه",
"from_date": "\u0627\u0632 \u062a\u0627\u0631\u06cc\u062e", "from_date": "از تاریخ",
"to_date": "\u062a\u0627 \u062a\u0627\u0631\u06cc\u062e", "to_date": "تا تاریخ",
"user": "\u06a9\u0627\u0631\u0628\u0631", "user": "کاربر",
"mobile": "\u0645\u0648\u0628\u0627\u06cc\u0644", "mobile": "موبایل",
"all_users": "\u0647\u0645\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", "all_users": "همه کاربران",
"generated_at": "\u062a\u0627\u0631\u06cc\u062e \u062a\u0648\u0644\u06cc\u062f", "generated_at": "تاریخ تولید",
"summary": "\u062e\u0644\u0627\u0635\u0647", "summary": "خلاصه",
"total_hours": "\u06a9\u0644 \u0633\u0627\u0639\u0627\u062a", "total_hours": "کل ساعات",
"billable_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc", "billable_hours": "ساعات کاری",
"non_billable_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631 \u06a9\u0627\u0631\u06cc", "non_billable_hours": "ساعات غیر کاری",
"hourly_rate": "\u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", "hourly_rate": "نرخ ساعتی",
"income": "\u06a9\u0627\u0631\u06a9\u0631\u062f", "income": "کارکرد",
"working_hours": "\u0633\u0627\u0639\u0627\u062a \u06a9\u0627\u0631\u06cc", "working_hours": "ساعات کاری",
"non_working_hours": "\u0633\u0627\u0639\u0627\u062a \u063a\u06cc\u0631\u06a9\u0627\u0631\u06cc", "non_working_hours": "ساعات غیرکاری",
"hourly_rates": "\u0646\u0631\u062e\u200c\u0647\u0627\u06cc \u0633\u0627\u0639\u062a\u06cc", "hourly_rates": "نرخ‌های ساعتی",
"project_percentages": "\u062f\u0631\u0635\u062f \u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627", "project_percentages": "درصد پروژه‌ها",
"client_percentages": "\u062f\u0631\u0635\u062f \u0645\u0634\u062a\u0631\u06cc\u200c\u0647\u0627", "client_percentages": "درصد مشتری‌ها",
"tag_percentages": "\u062f\u0631\u0635\u062f \u062a\u06af\u200c\u0647\u0627", "tag_percentages": "درصد تگ‌ها",
"summary_by_user": "\u062e\u0644\u0627\u0635\u0647 \u06a9\u0627\u0631\u0628\u0631\u0627\u0646", "summary_by_user": "خلاصه کاربران",
"rate_history": "\u062a\u0627\u0631\u06cc\u062e\u0686\u0647 \u0646\u0631\u062e \u0633\u0627\u0639\u062a\u06cc", "rate_history": "تاریخچه نرخ ساعتی",
"from": "\u0627\u0632", "from": "از",
"to": "\u062a\u0627", "to": "تا",
"now": "\u062d\u0627\u0644", "now": "حال",
"project": "\u067e\u0631\u0648\u0698\u0647", "project": "پروژه",
"percentage": "\u062f\u0631\u0635\u062f", "percentage": "درصد",
"hour_percentage": "\u062f\u0631\u0635\u062f \u0633\u0627\u0639\u062a", "hour_percentage": "درصد ساعت",
"income_percentage": "\u062f\u0631\u0635\u062f \u06a9\u0627\u0631\u06a9\u0631\u062f", "income_percentage": "درصد کارکرد",
"multiple_rates": "\u0686\u0646\u062f \u0646\u0631\u062e - \u062c\u0632\u0626\u06cc\u0627\u062a \u062f\u0631 \u06af\u0632\u0627\u0631\u0634 \u06a9\u0627\u0631\u0628\u0631", "multiple_rates": "چند نرخ - جزئیات در گزارش کاربر",
"variable_rate": "\u0646\u0631\u062e \u0645\u062a\u063a\u06cc\u0631", "variable_rate": "نرخ متغیر",
"none": "\u0628\u062f\u0648\u0646 \u0645\u0648\u0631\u062f", "none": "بدون مورد",
"daily_summary": "\u062e\u0644\u0627\u0635\u0647 \u0631\u0648\u0632\u0627\u0646\u0647", "daily_summary": "خلاصه روزانه",
"clients": "\u0645\u0634\u062a\u0631\u06cc\u0627\u0646", "clients": "مشتریان",
"projects": "\u067e\u0631\u0648\u0698\u0647\u200c\u0647\u0627", "projects": "پروژه‌ها",
"tags": "\u062a\u06af\u200c\u0647\u0627", "tags": "تگ‌ها",
"date": "\u062a\u0627\u0631\u06cc\u062e", "date": "تاریخ",
"name": "\u0646\u0627\u0645", "name": "نام",
"total": "\u062c\u0645\u0639", "total": "جمع",
"no_data": "\u0628\u062f\u0648\u0646 \u062f\u0627\u062f\u0647", "no_data": "بدون داده",
"uncategorized_client": "\u0628\u062f\u0648\u0646 \u0645\u0634\u062a\u0631\u06cc", "uncategorized_client": "بدون مشتری",
"uncategorized_project": "\u0628\u062f\u0648\u0646 \u067e\u0631\u0648\u0698\u0647", "uncategorized_project": "بدون پروژه",
"uncategorized_tag": "\u0628\u062f\u0648\u0646 \u062a\u06af", "uncategorized_tag": "بدون تگ",
}, },
} }
@@ -129,23 +129,23 @@ PERIOD_LABELS = {
"period": "Custom period", "period": "Custom period",
}, },
"fa": { "fa": {
"this_week": "\u0627\u06cc\u0646 \u0647\u0641\u062a\u0647", "this_week": "این هفته",
"this_month": "\u0627\u06cc\u0646 \u0645\u0627\u0647", "this_month": "این ماه",
"this_year": "\u0627\u0645\u0633\u0627\u0644", "this_year": "امسال",
"half_year_first": "\u0646\u06cc\u0645\u0647 \u0627\u0648\u0644 \u0633\u0627\u0644", "half_year_first": "نیمه اول سال",
"half_year_second": "\u0646\u06cc\u0645\u0647 \u062f\u0648\u0645 \u0633\u0627\u0644", "half_year_second": "نیمه دوم سال",
"period": "\u0628\u0627\u0632\u0647 \u062f\u0644\u062e\u0648\u0627\u0647", "period": "بازه دلخواه",
}, },
} }
CURRENCY_LABELS = { CURRENCY_LABELS = {
"USD": {"en": "USD", "fa": "\u062f\u0644\u0627\u0631 \u0622\u0645\u0631\u06cc\u06a9\u0627"}, "USD": {"en": "USD", "fa": "دلار آمریکا"},
"EUR": {"en": "EUR", "fa": "\u06cc\u0648\u0631\u0648"}, "EUR": {"en": "EUR", "fa": "یورو"},
"GBP": {"en": "GBP", "fa": "\u067e\u0648\u0646\u062f"}, "GBP": {"en": "GBP", "fa": "پوند"},
"IRR": {"en": "IRR", "fa": "\u0631\u06cc\u0627\u0644"}, "IRR": {"en": "IRR", "fa": "ریال"},
"IRT": {"en": "IRT", "fa": "\u062a\u0648\u0645\u0627\u0646"}, "IRT": {"en": "IRT", "fa": "تومان"},
"AED": {"en": "AED", "fa": "\u062f\u0631\u0647\u0645"}, "AED": {"en": "AED", "fa": "درهم"},
"TRY": {"en": "TRY", "fa": "\u0644\u06cc\u0631"}, "TRY": {"en": "TRY", "fa": "لیر"},
} }
DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"} DECIMAL_TRIM_CURRENCIES = {"IRR", "IRT"}
@@ -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,18 +72,22 @@ def _autosize_columns(worksheet) -> None:
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30) worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
def _freeze_first_column(worksheet) -> None:
worksheet.freeze_panes = "B1"
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str: def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
return locale.format_money_label(income_totals) return locale.format_money_label(income_totals)
def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str: def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str:
value = locale.format_money_label(income_totals, ascii_digits=True) value = locale.format_money_label(income_totals, ascii_digits=True)
return f"\u202B{value}\u202C" if locale.is_rtl else value return f"\u202B{value}\u202C" if locale.is_rtl else value # Unicode bidi control characters
def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str: def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str:
if not rates: if not rates:
return "-" 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,18 +114,18 @@ 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'])}"
) )
return f"\u202B{value}\u202C" if locale.is_rtl else value return f"\u202B{value}\u202C" if locale.is_rtl else value # Unicode bidi control characters
def _pdf_summary_rate_label(locale: ExportLocale, rates: list[dict]) -> str: def _pdf_summary_rate_label(locale: ExportLocale, rates: list[dict]) -> str:
@@ -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,12 +240,47 @@ def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict,
return value return value
if row_name and row["name"] == row_name: if row_name and row["name"] == row_name:
return value return value
return "-" return default
def _percentage_number(rows: list[dict] | None, row_data: dict) -> float:
row_id = str(row_data.get("id")) if row_data.get("id") is not None else None
row_name = row_data.get("name")
for row in rows or []:
if row_id is not None and str(row.get("id")) == row_id:
try:
return float(row.get("percentage") or 0)
except (TypeError, ValueError):
return 0.0
if row_name and row.get("name") == row_name:
try:
return float(row.get("percentage") or 0)
except (TypeError, ValueError):
return 0.0
return 0.0
def _percentage_sort_value(row: dict) -> float:
try:
return float(row.get("percentage") or 0)
except (TypeError, ValueError):
return 0.0
def _sort_breakdown_rows(rows: list[dict], hour_percentages: list[dict] | None) -> list[dict]:
return sorted(
rows,
key=lambda row: (
-_percentage_number(hour_percentages, row),
-(row.get("billable_seconds") or 0),
row.get("name") or "",
),
)
def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str: def _percentage_value(locale: ExportLocale, percentage: str, *, ascii_digits: bool = False) -> str:
value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%" value = f"{locale.format_amount(percentage, ascii_digits=ascii_digits)}%"
return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value # Unicode bidi control characters
def _summary_breakdown_rows( def _summary_breakdown_rows(
@@ -242,9 +295,9 @@ def _summary_breakdown_rows(
[ [
row["name"], row["name"],
_percentage_value(locale, row["percentage"], ascii_digits=True), _percentage_value(locale, row["percentage"], ascii_digits=True),
_percentage_display(locale, income_rows, row, ascii_digits=True), _percentage_display(locale, income_rows, row, ascii_digits=True, default="-"),
] ]
for row in hour_rows for row in sorted(hour_rows, key=lambda row: (-_percentage_sort_value(row), row.get("name") or ""))
] ]
@@ -269,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]:
@@ -292,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 "-",
], ],
) )
) )
@@ -508,7 +562,7 @@ def _append_breakdown_table(
_apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl) _apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl)
return return
for row in rows: for row in _sort_breakdown_rows(rows, hour_percentages):
values = [ values = [
row["name"], row["name"],
locale.format_duration(row["billable_duration"], ascii_digits=True), locale.format_duration(row["billable_duration"], ascii_digits=True),
@@ -527,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 []
), ),
@@ -576,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
@@ -585,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,
*, *,
@@ -634,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]),
], ],
) )
@@ -660,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"]
@@ -694,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"),
@@ -709,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"),
@@ -739,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 []
@@ -749,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 (
( (
@@ -787,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,
) )
@@ -808,8 +882,9 @@ def _render_all_users_overall_excel_sheet(
fill=HEADER_FILL, fill=HEADER_FILL,
) )
current_row += 1 current_row += 1
if rows: sorted_rows = _sort_breakdown_rows(rows, hour_percentages)
for row in rows: if sorted_rows:
for row in sorted_rows:
_write_table_row( _write_table_row(
worksheet, worksheet,
row=current_row, row=current_row,
@@ -819,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,
) )
@@ -865,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([])
@@ -1085,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,
@@ -1093,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))
@@ -1172,7 +1246,8 @@ def _append_pdf_report_sections(
hour_percentage_rows = user_summary[f"{prefix}_percentages"] hour_percentage_rows = user_summary[f"{prefix}_percentages"]
income_percentage_rows = user_summary[f"{prefix}_income_percentages"] income_percentage_rows = user_summary[f"{prefix}_income_percentages"]
header = _rtl_row(locale, header_values) header = _rtl_row(locale, header_values)
body_rows = _report_table_rows(locale, rows, is_daily=is_daily) sorted_rows = rows if is_daily else _sort_breakdown_rows(rows, hour_percentage_rows)
body_rows = _report_table_rows(locale, sorted_rows, is_daily=is_daily)
if hour_percentage_rows is not None: if hour_percentage_rows is not None:
body_rows = [ body_rows = [
_rtl_row( _rtl_row(
@@ -1190,10 +1265,10 @@ 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 rows for row in sorted_rows
] or [ ] or [
_rtl_row( _rtl_row(
locale, locale,

View File

@@ -7,6 +7,7 @@ from apps.reports.services.export_i18n import build_export_locale
from apps.reports.services.exporters import ( from apps.reports.services.exporters import (
_pdf_summary_rate_label, _pdf_summary_rate_label,
_rate_label, _rate_label,
_sort_breakdown_rows,
build_excel_report, build_excel_report,
build_pdf_report, build_pdf_report,
) )
@@ -133,6 +134,23 @@ class ReportExporterTests(TestCase):
"Variable rate", "Variable rate",
) )
def test_breakdown_rows_are_sorted_by_hour_percentage(self):
rows = [
{"id": "low", "name": "Low", "billable_seconds": 7200},
{"id": "high", "name": "High", "billable_seconds": 3600},
{"id": "tie", "name": "Tie", "billable_seconds": 10800},
]
percentages = [
{"id": "low", "name": "Low", "percentage": "20"},
{"id": "high", "name": "High", "percentage": "70"},
{"id": "tie", "name": "Tie", "percentage": "20"},
]
self.assertEqual(
[row["name"] for row in _sort_breakdown_rows(rows, percentages)],
["High", "Tie", "Low"],
)
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self): def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
locale = build_export_locale("en") locale = build_export_locale("en")
report_data = make_report_data( report_data = make_report_data(
@@ -178,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",
@@ -191,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 %",
@@ -205,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

@@ -8,11 +8,11 @@ from apps.users.email_identity import normalize_email_identity
User = get_user_model() User = get_user_model()
INVALID_MOBILE_FORMAT_MESSAGE = "\u0641\u0631\u0645\u062a \u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 \u0646\u0627\u062f\u0631\u0633\u062a \u0627\u0633\u062a." INVALID_MOBILE_FORMAT_MESSAGE = "فرمت شماره موبایل نادرست است."
INVALID_MOBILE_NUMBER_MESSAGE = "\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 \u0645\u0639\u062a\u0628\u0631 \u0646\u06cc\u0633\u062a." INVALID_MOBILE_NUMBER_MESSAGE = "شماره موبایل معتبر نیست."
PASSWORD_MISMATCH_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0645\u0637\u0627\u0628\u0642\u062a \u0646\u062f\u0627\u0631\u062f." PASSWORD_MISMATCH_MESSAGE = "رمز عبور مطابقت ندارد."
NEW_PASSWORD_MISMATCH_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0648 \u062a\u06a9\u0631\u0627\u0631 \u0622\u0646 \u0645\u0637\u0627\u0628\u0642\u062a \u0646\u062f\u0627\u0631\u0646\u062f." NEW_PASSWORD_MISMATCH_MESSAGE = "رمز عبور جدید و تکرار آن مطابقت ندارند."
PASSWORD_REUSE_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0646\u0628\u0627\u06cc\u062f \u0628\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0642\u0628\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0628\u0627\u0634\u062f." PASSWORD_REUSE_MESSAGE = "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد."
def _raise_password_validation_error(password: str, *, user, field_name: str) -> None: def _raise_password_validation_error(password: str, *, user, field_name: str) -> None:
@@ -212,10 +212,12 @@ class UserProfileSerializer(BaseModelSerializer):
"profile_picture", "profile_picture",
"birth_date", "birth_date",
"is_verified", "is_verified",
"is_demo",
"demo_expires_at",
"full_name", "full_name",
"age", "age",
) )
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified") read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified", "is_demo", "demo_expires_at")
class UserSearchSerializer(serializers.ModelSerializer): class UserSearchSerializer(serializers.ModelSerializer):

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

@@ -18,7 +18,7 @@ from apps.users.utils import record_login_attempt
User = get_user_model() User = get_user_model()
USER_ALREADY_EXISTS_MESSAGE = "User already exists." USER_ALREADY_EXISTS_MESSAGE = "User already exists."
PASSWORD_REUSE_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0646\u0628\u0627\u06cc\u062f \u0628\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0642\u0628\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0628\u0627\u0634\u062f." PASSWORD_REUSE_MESSAGE = "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد."
OTP_EXPIRY_SECONDS = 120 OTP_EXPIRY_SECONDS = 120
@@ -55,15 +55,15 @@ def register_user_with_password(mobile, password):
def register_user_with_otp(mobile, code, password, first_name="", last_name=""): def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
"""Business logic for verifying OTP and registering a user.""" """Business logic for verifying OTP and registering a user."""
if User.objects.filter(mobile=mobile).exists(): if User.objects.filter(mobile=mobile).exists():
raise ValidationError({"mobile": "\u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u0642\u0628\u0644\u0627\u064b \u062b\u0628\u062a \u0634\u062f\u0647 \u0627\u0633\u062a."}) raise ValidationError({"mobile": "این شماره قبلاً ثبت شده است."})
redis_conn = get_redis_connection("default") redis_conn = get_redis_connection("default")
stored_code = redis_conn.get(f"verification_code:{mobile}") stored_code = redis_conn.get(f"verification_code:{mobile}")
if not stored_code: if not stored_code:
raise ValidationError({"code": "\u06a9\u062f \u062a\u0623\u06cc\u06cc\u062f \u06cc\u0627\u0641\u062a \u0646\u0634\u062f."}) raise ValidationError({"code": "کد تأیید یافت نشد."})
if stored_code.decode("utf-8") != code: if stored_code.decode("utf-8") != code:
raise ValidationError({"code": "\u06a9\u062f \u062a\u0623\u06cc\u06cc\u062f \u0627\u0634\u062a\u0628\u0627\u0647 \u0627\u0633\u062a."}) raise ValidationError({"code": "کد تأیید اشتباه است."})
user = User.objects.create_user( user = User.objects.create_user(
mobile=mobile, mobile=mobile,
@@ -81,13 +81,17 @@ def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
def generate_and_send_otp(mobile, mode): def generate_and_send_otp(mobile, mode):
"""Business logic for generating OTP, checking existence rules, and sending SMS.""" """Business logic for generating OTP, checking existence rules, and sending SMS."""
user_exists = User.objects.filter(mobile=mobile).exists() user = User.objects.filter(mobile=mobile).only("is_demo").first()
user_exists = user is not None
if user and user.is_demo:
raise ValidationError({"mobile": "Demo accounts cannot use SMS verification."})
if mode == "register" and user_exists: if mode == "register" and user_exists:
raise ValidationError({"mobile": "\u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u0642\u0628\u0644\u0627\u064b \u062b\u0628\u062a\u200c\u0646\u0627\u0645 \u0634\u062f\u0647 \u0627\u0633\u062a."}) raise ValidationError({"mobile": "این شماره قبلاً ثبت‌نام شده است."})
if mode in ["login", "forget_password"] and not user_exists: if mode in ["login", "forget_password"] and not user_exists:
raise ValidationError({"mobile": "\u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u06cc\u0627\u0641\u062a \u0646\u0634\u062f."}) raise ValidationError({"mobile": "این شماره یافت نشد."})
verification_code = "".join(random.choices(string.digits, k=5)) verification_code = "".join(random.choices(string.digits, k=5))
@@ -109,10 +113,10 @@ def login_with_password(mobile, password, request=None):
if not user or not user.check_password(password): if not user or not user.check_password(password):
record_login_attempt(request, user, LoginAttempt.StatusType.FAILED) record_login_attempt(request, user, LoginAttempt.StatusType.FAILED)
raise ValidationError({"detail": "\u0634\u0645\u0627\u0631\u0647 \u0645\u0648\u0628\u0627\u06cc\u0644 \u06cc\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0627\u0634\u062a\u0628\u0627\u0647 \u0627\u0633\u062a."}) raise ValidationError({"detail": "شماره موبایل یا رمز عبور اشتباه است."})
if not user.is_active: if not user.is_active:
raise ValidationError({"detail": "\u062d\u0633\u0627\u0628 \u06a9\u0627\u0631\u0628\u0631\u06cc \u0634\u0645\u0627 \u063a\u06cc\u0631\u0641\u0639\u0627\u0644 \u0634\u062f\u0647 \u0627\u0633\u062a."}) raise ValidationError({"detail": "حساب کاربری شما غیرفعال شده است."})
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS) record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
return get_tokens_for_user(user) return get_tokens_for_user(user)
@@ -125,7 +129,7 @@ def login_with_otp(mobile, code, request=None):
if not stored_code or stored_code.decode("utf-8") != code: if not stored_code or stored_code.decode("utf-8") != code:
record_login_attempt(request, None, LoginAttempt.StatusType.FAILED) record_login_attempt(request, None, LoginAttempt.StatusType.FAILED)
raise ValidationError({"code": "\u06a9\u062f \u062a\u0627\u06cc\u06cc\u062f \u0646\u0627\u0645\u0639\u062a\u0628\u0631 \u0627\u0633\u062a \u06cc\u0627 \u0645\u0646\u0642\u0636\u06cc \u0634\u062f\u0647 \u0627\u0633\u062a."}) raise ValidationError({"code": "کد تایید نامعتبر است یا منقضی شده است."})
user, created = User.objects.get_or_create(mobile=mobile) user, created = User.objects.get_or_create(mobile=mobile)
if created: if created:
@@ -133,7 +137,7 @@ def login_with_otp(mobile, code, request=None):
user.save() user.save()
if not user.is_active: if not user.is_active:
raise ValidationError({"detail": "\u062d\u0633\u0627\u0628 \u06a9\u0627\u0631\u0628\u0631\u06cc \u0634\u0645\u0627 \u063a\u06cc\u0631\u0641\u0639\u0627\u0644 \u0634\u062f\u0647 \u0627\u0633\u062a."}) raise ValidationError({"detail": "حساب کاربری شما غیرفعال شده است."})
record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS) record_login_attempt(request, user, LoginAttempt.StatusType.SUCCESS)
redis_conn.delete(f"verification_code:{mobile}") redis_conn.delete(f"verification_code:{mobile}")
@@ -145,13 +149,13 @@ def reset_password_with_otp(mobile, code, password):
"""Verify OTP and change forgotten password.""" """Verify OTP and change forgotten password."""
user = User.objects.filter(mobile=mobile).first() user = User.objects.filter(mobile=mobile).first()
if not user: if not user:
raise ValidationError({"mobile": "\u06a9\u0627\u0631\u0628\u0631\u06cc \u0628\u0627 \u0627\u06cc\u0646 \u0634\u0645\u0627\u0631\u0647 \u06cc\u0627\u0641\u062a \u0646\u0634\u062f."}) raise ValidationError({"mobile": "کاربری با این شماره یافت نشد."})
redis_conn = get_redis_connection("default") redis_conn = get_redis_connection("default")
stored_code = redis_conn.get(f"verification_code:{mobile}") stored_code = redis_conn.get(f"verification_code:{mobile}")
if not stored_code or stored_code.decode("utf-8") != code: if not stored_code or stored_code.decode("utf-8") != code:
raise ValidationError({"code": "\u06a9\u062f \u062a\u0627\u06cc\u06cc\u062f \u0646\u0627\u0645\u0639\u062a\u0628\u0631 \u0627\u0633\u062a \u06cc\u0627 \u0645\u0646\u0642\u0636\u06cc \u0634\u062f\u0647 \u0627\u0633\u062a."}) raise ValidationError({"code": "کد تایید نامعتبر است یا منقضی شده است."})
_validate_new_password(password, user=user, field_name="password") _validate_new_password(password, user=user, field_name="password")
@@ -167,7 +171,7 @@ def reset_password_with_otp(mobile, code, password):
def change_password(user, old_password, new_password): def change_password(user, old_password, new_password):
"""Change password for an already authenticated user.""" """Change password for an already authenticated user."""
if not user.check_password(old_password): if not user.check_password(old_password):
raise ValidationError({"old_password": "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0641\u0639\u0644\u06cc \u0627\u0634\u062a\u0628\u0627\u0647 \u0627\u0633\u062a."}) raise ValidationError({"old_password": "رمز عبور فعلی اشتباه است."})
_validate_new_password(new_password, user=user, field_name="new_password") _validate_new_password(new_password, user=user, field_name="new_password")
@@ -182,9 +186,9 @@ def change_password(user, old_password, new_password):
def logout_user(refresh_token_str): def logout_user(refresh_token_str):
"""Blacklist the user's refresh token.""" """Blacklist the user's refresh token."""
if not refresh_token_str: if not refresh_token_str:
raise ValidationError({"refresh": "\u062a\u0648\u06a9\u0646 \u0631\u0641\u0631\u0634 \u0627\u0644\u0632\u0627\u0645\u06cc \u0627\u0633\u062a."}) raise ValidationError({"refresh": "توکن رفرش الزامی است."})
try: try:
token = RefreshToken(refresh_token_str) token = RefreshToken(refresh_token_str)
token.blacklist() token.blacklist()
except TokenError: except TokenError:
raise ValidationError({"detail": "\u062a\u0648\u06a9\u0646 \u0646\u0627\u0645\u0639\u062a\u0628\u0631 \u0627\u0633\u062a \u06cc\u0627 \u0642\u0628\u0644\u0627 \u0645\u0646\u0642\u0636\u06cc \u0634\u062f\u0647 \u0627\u0633\u062a."}) raise ValidationError({"detail": "توکن نامعتبر است یا قبلا منقضی شده است."})

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: