Compare commits
8 Commits
20874b9968
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c7745c935 | |||
| 95f5e85e44 | |||
| 027afb7e23 | |||
| 170ec90ec1 | |||
| 30a324c6f4 | |||
| da40720a0f | |||
| 948a8e1b75 | |||
| b5ddcb76aa |
1
apps/contacts/__init__.py
Normal file
1
apps/contacts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
56
apps/contacts/admin.py
Normal file
56
apps/contacts/admin.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ContactSubmission)
|
||||||
|
class ContactSubmissionAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"full_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"is_deleted",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
readonly_fields = (
|
||||||
|
"id",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"ip_address",
|
||||||
|
"user_agent",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Full name")
|
||||||
|
def full_name(self, obj):
|
||||||
|
return f"{obj.first_name} {obj.last_name}".strip()
|
||||||
1
apps/contacts/api/__init__.py
Normal file
1
apps/contacts/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
43
apps/contacts/api/serializers.py
Normal file
43
apps/contacts/api/serializers.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_mobile(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 8:
|
||||||
|
raise serializers.ValidationError("Enter a valid mobile number.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
def validate_message(self, value):
|
||||||
|
clean_value = value.strip()
|
||||||
|
if len(clean_value) < 10:
|
||||||
|
raise serializers.ValidationError("Message must be at least 10 characters.")
|
||||||
|
return clean_value
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionResponseSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ContactSubmission
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"email",
|
||||||
|
"mobile",
|
||||||
|
"message",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
5
apps/contacts/api/throttles.py
Normal file
5
apps/contacts/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionThrottle(AnonRateThrottle):
|
||||||
|
scope = "contact_submission"
|
||||||
9
apps/contacts/api/urls.py
Normal file
9
apps/contacts/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.contacts.api.views import ContactSubmissionView
|
||||||
|
|
||||||
|
app_name = "contacts"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", ContactSubmissionView.as_view(), name="contact-submit"),
|
||||||
|
]
|
||||||
40
apps/contacts/api/views.py
Normal file
40
apps/contacts/api/views.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.contacts.api.serializers import (
|
||||||
|
ContactSubmissionCreateSerializer,
|
||||||
|
ContactSubmissionResponseSerializer,
|
||||||
|
)
|
||||||
|
from apps.contacts.api.throttles import ContactSubmissionThrottle
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request):
|
||||||
|
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if forwarded_for:
|
||||||
|
return forwarded_for.split(",")[0].strip()
|
||||||
|
return request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = (ContactSubmissionThrottle,)
|
||||||
|
serializer_class = ContactSubmissionCreateSerializer
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=ContactSubmissionCreateSerializer,
|
||||||
|
responses={201: ContactSubmissionResponseSerializer},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
serializer = self.serializer_class(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
submission = serializer.save(
|
||||||
|
ip_address=_get_client_ip(request),
|
||||||
|
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
ContactSubmissionResponseSerializer(submission).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
6
apps/contacts/apps.py
Normal file
6
apps/contacts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ContactsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.contacts"
|
||||||
85
apps/contacts/migrations/0001_initial.py
Normal file
85
apps/contacts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Generated manually for contact submissions.
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ContactSubmission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid7,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("is_deleted", models.BooleanField(default=False)),
|
||||||
|
("is_active", models.BooleanField(default=False)),
|
||||||
|
("first_name", models.CharField(max_length=120)),
|
||||||
|
("last_name", models.CharField(max_length=120)),
|
||||||
|
("email", models.EmailField(max_length=254)),
|
||||||
|
("mobile", models.CharField(max_length=32)),
|
||||||
|
("message", models.TextField()),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("new", "New"),
|
||||||
|
("contacted", "Contacted"),
|
||||||
|
("closed", "Closed"),
|
||||||
|
("spam", "Spam"),
|
||||||
|
],
|
||||||
|
default="new",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
("user_agent", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="created_%(app_label)s_%(class)s_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="updated_%(app_label)s_%(class)s_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "contact_submission",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
"indexes": [
|
||||||
|
models.Index(fields=["id"], name="contactsubmission_id_idx"),
|
||||||
|
models.Index(fields=["created_at"], name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=["status"], name="contact_status_idx"),
|
||||||
|
models.Index(fields=["email"], name="contact_email_idx"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/contacts/migrations/__init__.py
Normal file
1
apps/contacts/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
36
apps/contacts/models.py
Normal file
36
apps/contacts/models.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmission(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
NEW = "new", "New"
|
||||||
|
CONTACTED = "contacted", "Contacted"
|
||||||
|
CLOSED = "closed", "Closed"
|
||||||
|
SPAM = "spam", "Spam"
|
||||||
|
|
||||||
|
first_name = models.CharField(max_length=120)
|
||||||
|
last_name = models.CharField(max_length=120)
|
||||||
|
email = models.EmailField()
|
||||||
|
mobile = models.CharField(max_length=32)
|
||||||
|
message = models.TextField()
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.NEW,
|
||||||
|
)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "contact_submission"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = (
|
||||||
|
models.Index(fields=("created_at",), name="contact_created_at_idx"),
|
||||||
|
models.Index(fields=("status",), name="contact_status_idx"),
|
||||||
|
models.Index(fields=("email",), name="contact_email_idx"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.first_name} {self.last_name} - {self.email}"
|
||||||
1
apps/contacts/tests/__init__.py
Normal file
1
apps/contacts/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
44
apps/contacts/tests/test_api_views.py
Normal file
44
apps/contacts/tests/test_api_views.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.contacts.models import ContactSubmission
|
||||||
|
|
||||||
|
|
||||||
|
class ContactSubmissionApiTests(APITestCase):
|
||||||
|
def test_public_user_can_submit_contact_form(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "I need help with Qlockify reports.",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
HTTP_X_FORWARDED_FOR="203.0.113.10",
|
||||||
|
HTTP_USER_AGENT="test-agent",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
submission = ContactSubmission.objects.get()
|
||||||
|
self.assertEqual(submission.email, "amin@example.com")
|
||||||
|
self.assertEqual(submission.ip_address, "203.0.113.10")
|
||||||
|
self.assertEqual(submission.user_agent, "test-agent")
|
||||||
|
|
||||||
|
def test_rejects_short_message(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("contacts:contact-submit"),
|
||||||
|
{
|
||||||
|
"first_name": "Amin",
|
||||||
|
"last_name": "Test",
|
||||||
|
"email": "amin@example.com",
|
||||||
|
"mobile": "09938228438",
|
||||||
|
"message": "Hi",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertFalse(ContactSubmission.objects.exists())
|
||||||
1
apps/demos/__init__.py
Normal file
1
apps/demos/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
apps/demos/api/__init__.py
Normal file
1
apps/demos/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
apps/demos/api/throttles.py
Normal file
5
apps/demos/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
|
|
||||||
|
|
||||||
|
class DemoStartThrottle(AnonRateThrottle):
|
||||||
|
scope = "demo_start"
|
||||||
9
apps/demos/api/urls.py
Normal file
9
apps/demos/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.demos.api.views import DemoStartView
|
||||||
|
|
||||||
|
app_name = "demos"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("start/", DemoStartView.as_view(), name="demo-start"),
|
||||||
|
]
|
||||||
29
apps/demos/api/views.py
Normal file
29
apps/demos/api/views.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.demos.api.throttles import DemoStartThrottle
|
||||||
|
from apps.demos.services import create_demo_environment
|
||||||
|
|
||||||
|
|
||||||
|
class DemoStartView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
throttle_classes = (DemoStartThrottle,)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request=None,
|
||||||
|
responses=inline_serializer(
|
||||||
|
name="DemoStartResponse",
|
||||||
|
fields={
|
||||||
|
"access": serializers.CharField(),
|
||||||
|
"refresh": serializers.CharField(),
|
||||||
|
"workspace_id": serializers.CharField(),
|
||||||
|
"expires_at": serializers.DateTimeField(),
|
||||||
|
"demo_environment_id": serializers.CharField(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
return Response(create_demo_environment(), status=status.HTTP_201_CREATED)
|
||||||
6
apps/demos/apps.py
Normal file
6
apps/demos/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DemosConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.demos"
|
||||||
1
apps/demos/management/__init__.py
Normal file
1
apps/demos/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
apps/demos/management/commands/__init__.py
Normal file
1
apps/demos/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Clean up expired isolated demo environments."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--expired", action="store_true", help="Clean expired demo environments.")
|
||||||
|
parser.add_argument("--batch-size", type=int, default=None, help="Maximum number of environments to clean.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not options["expired"]:
|
||||||
|
self.stderr.write("Only --expired cleanup is supported.")
|
||||||
|
return
|
||||||
|
cleaned = cleanup_expired_demo_environments(batch_size=options["batch_size"])
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Cleaned {cleaned} expired demo environment(s)."))
|
||||||
97
apps/demos/migrations/0001_initial.py
Normal file
97
apps/demos/migrations/0001_initial.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("workspaces", "0008_hourlyratehistory"),
|
||||||
|
("users", "0004_user_demo_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="DemoEnvironment",
|
||||||
|
fields=[
|
||||||
|
("id", models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("is_deleted", models.BooleanField(default=False)),
|
||||||
|
("is_active", models.BooleanField(default=False)),
|
||||||
|
("expires_at", models.DateTimeField()),
|
||||||
|
(
|
||||||
|
"status",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("active", "Active"), ("expired", "Expired"), ("cleaned", "Cleaned")],
|
||||||
|
default="active",
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("seed_version", models.CharField(default="v1", max_length=32)),
|
||||||
|
("cleaned_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("cleanup_error", models.TextField(blank=True)),
|
||||||
|
(
|
||||||
|
"created_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="created_demos_demoenvironment_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"owner_user",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"updated_by",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="updated_demos_demoenvironment_set",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workspace",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
to="workspaces.workspace",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"db_table": "demo_environment",
|
||||||
|
"ordering": ("-created_at",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["id"], name="demoenvironment_id_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="demoenvironment",
|
||||||
|
index=models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-06-06 21:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('demos', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveIndex(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='demoenvironment_id_idx',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='created_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='demoenvironment',
|
||||||
|
name='updated_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
apps/demos/migrations/__init__.py
Normal file
1
apps/demos/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
39
apps/demos/models.py
Normal file
39
apps/demos/models.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DemoEnvironment(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
EXPIRED = "expired", "Expired"
|
||||||
|
CLEANED = "cleaned", "Cleaned"
|
||||||
|
|
||||||
|
owner_user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
)
|
||||||
|
workspace = models.OneToOneField(
|
||||||
|
"workspaces.Workspace",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="demo_environment",
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
|
||||||
|
seed_version = models.CharField(max_length=32, default="v1")
|
||||||
|
cleaned_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
cleanup_error = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "demo_environment"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||||
|
models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||||
|
models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Demo {self.workspace_id} for {self.owner_user_id}"
|
||||||
273
apps/demos/services.py
Normal file
273
apps/demos/services.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.demos.models import DemoEnvironment
|
||||||
|
from apps.notifications.services import RedisNotificationStore
|
||||||
|
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||||
|
from apps.reports.models import ReportExportJob
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.users.services.auth import get_tokens_for_user
|
||||||
|
from apps.workspaces.models import HourlyRateHistory, PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
DEMO_SEED_VERSION = "v1"
|
||||||
|
DEMO_RATE_CURRENCY = "IRT"
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_mobile(prefix: str) -> str:
|
||||||
|
for _ in range(50):
|
||||||
|
mobile = f"09{prefix}{''.join(random.choices(string.digits, k=7))}"
|
||||||
|
if not User.all_objects.filter(mobile=mobile).exists():
|
||||||
|
return mobile
|
||||||
|
raise ValidationError({"detail": "Could not allocate a unique demo mobile number."})
|
||||||
|
|
||||||
|
|
||||||
|
def _create_demo_user(*, prefix: str, first_name: str, last_name: str, expires_at):
|
||||||
|
mobile = _unique_mobile(prefix)
|
||||||
|
user = User.objects.create_user(
|
||||||
|
mobile=mobile,
|
||||||
|
password=None,
|
||||||
|
email=f"demo-{mobile}@demo.qlockify.local",
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
is_active=True,
|
||||||
|
is_verified=True,
|
||||||
|
is_demo=True,
|
||||||
|
demo_expires_at=expires_at,
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_price_units() -> None:
|
||||||
|
PriceUnit.get_or_restore(
|
||||||
|
code="IRT",
|
||||||
|
defaults={"name": "Iranian Toman", "local_name": "تومان", "symbol": "تومان", "is_active": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_workspace_rate(*, workspace, user, amount: str, effective_from):
|
||||||
|
rate = WorkspaceUserRate.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=Decimal(amount),
|
||||||
|
currency=DEMO_RATE_CURRENCY,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
HourlyRateHistory.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _create_project_rate(*, project, user, amount: str, effective_from):
|
||||||
|
rate = ProjectUserRate.objects.create(
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
hourly_rate=Decimal(amount),
|
||||||
|
currency=DEMO_RATE_CURRENCY,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
HourlyRateHistory.objects.create(
|
||||||
|
workspace=project.workspace,
|
||||||
|
project=project,
|
||||||
|
user=user,
|
||||||
|
scope=HourlyRateHistory.Scope.PROJECT,
|
||||||
|
hourly_rate=rate.hourly_rate,
|
||||||
|
currency=rate.currency,
|
||||||
|
effective_from=effective_from,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return rate
|
||||||
|
|
||||||
|
|
||||||
|
def _create_entry(*, workspace, user, project, tags, days_ago: int, hour: int, duration_hours: float, description: str, billable: bool):
|
||||||
|
start_time = timezone.now().replace(hour=hour, minute=0, second=0, microsecond=0) - timedelta(days=days_ago)
|
||||||
|
end_time = start_time + timedelta(hours=duration_hours)
|
||||||
|
rate = None
|
||||||
|
currency = DEMO_RATE_CURRENCY
|
||||||
|
if billable and project:
|
||||||
|
rate = (
|
||||||
|
ProjectUserRate.objects.filter(project=project, user=user, is_deleted=False, is_active=True)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not rate:
|
||||||
|
rate = (
|
||||||
|
WorkspaceUserRate.objects.filter(workspace=workspace, user=user, is_deleted=False)
|
||||||
|
.order_by("-effective_from", "-updated_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if rate:
|
||||||
|
currency = rate.currency
|
||||||
|
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user=user,
|
||||||
|
project=project,
|
||||||
|
description=description,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
duration=end_time - start_time,
|
||||||
|
is_billable=billable,
|
||||||
|
hourly_rate=rate.hourly_rate if rate else None,
|
||||||
|
currency=currency,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
entry.tags.set(tags)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_demo_environment():
|
||||||
|
if not getattr(settings, "DEMO_ENABLED", True):
|
||||||
|
raise ValidationError({"detail": "Demo environments are currently disabled."})
|
||||||
|
|
||||||
|
_ensure_price_units()
|
||||||
|
expires_at = timezone.now() + timedelta(hours=settings.DEMO_ENVIRONMENT_TTL_HOURS)
|
||||||
|
owner = _create_demo_user(prefix="70", first_name="Demo", last_name="Owner", expires_at=expires_at)
|
||||||
|
admin = _create_demo_user(prefix="71", first_name="Nika", last_name="Admin", expires_at=expires_at)
|
||||||
|
member = _create_demo_user(prefix="72", first_name="Arman", last_name="Member", expires_at=expires_at)
|
||||||
|
guest = _create_demo_user(prefix="73", first_name="Sara", last_name="Guest", expires_at=expires_at)
|
||||||
|
|
||||||
|
workspace = Workspace.objects.create(
|
||||||
|
name="Qlockify Demo Workspace",
|
||||||
|
description="A temporary sandbox workspace with seeded data for exploring Qlockify.",
|
||||||
|
owner=owner,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
WorkspaceMembership.objects.bulk_create(
|
||||||
|
[
|
||||||
|
WorkspaceMembership(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True),
|
||||||
|
WorkspaceMembership(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True),
|
||||||
|
WorkspaceMembership(workspace=workspace, user=guest, role=WorkspaceMembership.Role.GUEST, is_active=True),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
for user, amount in ((owner, "750000"), (admin, "650000"), (member, "520000"), (guest, "350000")):
|
||||||
|
_create_workspace_rate(workspace=workspace, user=user, amount=amount, effective_from=now - timedelta(days=60))
|
||||||
|
|
||||||
|
college = Client.objects.create(workspace=workspace, name="Kanoon College", notes="Education client", is_active=True)
|
||||||
|
studio = Client.objects.create(workspace=workspace, name="Nova Studio", notes="Design and product client", is_active=True)
|
||||||
|
internal = Client.objects.create(workspace=workspace, name="Internal Ops", notes="Non-client internal work", is_active=True)
|
||||||
|
|
||||||
|
projects = {
|
||||||
|
"portal": Project.objects.create(workspace=workspace, client=college, name="Student Portal", color="#0891b2", is_active=True),
|
||||||
|
"bootcamp": Project.objects.create(workspace=workspace, client=college, name="Bootcamp Analytics", color="#14b8a6", is_active=True),
|
||||||
|
"brand": Project.objects.create(workspace=workspace, client=studio, name="Brand Refresh", color="#f97316", is_active=True),
|
||||||
|
"ops": Project.objects.create(workspace=workspace, client=internal, name="Operations Automation", color="#6366f1", is_active=True),
|
||||||
|
"archive": Project.objects.create(workspace=workspace, client=studio, name="Archived Campaign", color="#94a3b8", is_archived=True, is_active=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
"design": Tag.objects.create(workspace=workspace, name="Design", color="#f97316", is_active=True),
|
||||||
|
"backend": Tag.objects.create(workspace=workspace, name="Backend", color="#0ea5e9", is_active=True),
|
||||||
|
"meeting": Tag.objects.create(workspace=workspace, name="Meeting", color="#8b5cf6", is_active=True),
|
||||||
|
"qa": Tag.objects.create(workspace=workspace, name="QA", color="#22c55e", is_active=True),
|
||||||
|
}
|
||||||
|
|
||||||
|
for user in (member, guest):
|
||||||
|
for project in (projects["portal"], projects["bootcamp"], projects["brand"]):
|
||||||
|
ProjectAccess.objects.create(project=project, user=user, is_active=True)
|
||||||
|
|
||||||
|
_create_project_rate(project=projects["brand"], user=owner, amount="950000", effective_from=now - timedelta(days=30))
|
||||||
|
_create_project_rate(project=projects["portal"], user=member, amount="610000", effective_from=now - timedelta(days=20))
|
||||||
|
_create_project_rate(project=projects["bootcamp"], user=guest, amount="420000", effective_from=now - timedelta(days=15))
|
||||||
|
|
||||||
|
entry_templates = [
|
||||||
|
(owner, projects["brand"], [tags["design"]], 1, 9, 2.5, "Review landing page motion", True),
|
||||||
|
(owner, projects["ops"], [tags["backend"], tags["qa"]], 2, 10, 3.0, "Improve export pipeline", True),
|
||||||
|
(owner, None, [tags["meeting"]], 3, 13, 1.0, "Weekly planning", False),
|
||||||
|
(admin, projects["portal"], [tags["backend"]], 1, 8, 4.0, "API access checks", True),
|
||||||
|
(admin, projects["bootcamp"], [tags["qa"]], 4, 11, 2.0, "Report QA pass", True),
|
||||||
|
(member, projects["portal"], [tags["backend"], tags["qa"]], 2, 9, 5.0, "Timesheet improvements", True),
|
||||||
|
(member, projects["brand"], [tags["design"]], 6, 14, 2.5, "Design polish", True),
|
||||||
|
(guest, projects["bootcamp"], [tags["meeting"]], 3, 10, 1.5, "Client sync", True),
|
||||||
|
(guest, None, [], 5, 15, 1.0, "Uncategorized admin work", False),
|
||||||
|
]
|
||||||
|
for entry in entry_templates:
|
||||||
|
_create_entry(workspace=workspace, user=entry[0], project=entry[1], tags=entry[2], days_ago=entry[3], hour=entry[4], duration_hours=entry[5], description=entry[6], billable=entry[7])
|
||||||
|
|
||||||
|
environment = DemoEnvironment.objects.create(
|
||||||
|
owner_user=owner,
|
||||||
|
workspace=workspace,
|
||||||
|
expires_at=expires_at,
|
||||||
|
seed_version=DEMO_SEED_VERSION,
|
||||||
|
status=DemoEnvironment.Status.ACTIVE,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
tokens = get_tokens_for_user(owner)
|
||||||
|
return {
|
||||||
|
**tokens,
|
||||||
|
"workspace_id": str(workspace.id),
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
"demo_environment_id": str(environment.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_demo_environment(environment: DemoEnvironment) -> bool:
|
||||||
|
workspace = environment.workspace
|
||||||
|
users = list(
|
||||||
|
User.all_objects.filter(
|
||||||
|
is_demo=True,
|
||||||
|
workspace_memberships__workspace=workspace,
|
||||||
|
).distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
for job in ReportExportJob.all_objects.filter(workspace=workspace):
|
||||||
|
if job.file:
|
||||||
|
job.file.delete(save=False)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
RedisNotificationStore.clear_user(str(user.id))
|
||||||
|
|
||||||
|
workspace.hard_delete()
|
||||||
|
for user in users:
|
||||||
|
user.hard_delete()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_demo_environments(*, batch_size: int | None = None) -> int:
|
||||||
|
batch_size = batch_size or settings.DEMO_CLEANUP_BATCH_SIZE
|
||||||
|
expired = list(
|
||||||
|
DemoEnvironment.objects.filter(
|
||||||
|
status=DemoEnvironment.Status.ACTIVE,
|
||||||
|
expires_at__lte=timezone.now(),
|
||||||
|
)
|
||||||
|
.select_related("workspace", "owner_user")
|
||||||
|
.order_by("expires_at")[:batch_size]
|
||||||
|
)
|
||||||
|
cleaned = 0
|
||||||
|
for environment in expired:
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
cleanup_demo_environment(environment)
|
||||||
|
cleaned += 1
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
DemoEnvironment.all_objects.filter(id=environment.id).update(
|
||||||
|
status=DemoEnvironment.Status.EXPIRED,
|
||||||
|
cleanup_error=str(exc)[:2000],
|
||||||
|
updated_at=timezone.now(),
|
||||||
|
)
|
||||||
|
return cleaned
|
||||||
8
apps/demos/tasks.py
Normal file
8
apps/demos/tasks.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="demos.cleanup_expired_environments")
|
||||||
|
def cleanup_expired_demo_environments_task():
|
||||||
|
return cleanup_expired_demo_environments()
|
||||||
1
apps/demos/tests/__init__.py
Normal file
1
apps/demos/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
77
apps/demos/tests/test_demo_api.py
Normal file
77
apps/demos/tests/test_demo_api.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from apps.clients.models import Client
|
||||||
|
from apps.demos.models import DemoEnvironment
|
||||||
|
from apps.demos.services import cleanup_expired_demo_environments
|
||||||
|
from apps.projects.models import Project, ProjectAccess
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.workspaces.models import WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
DEMO_START_URL = "/api/demo/start/"
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(DEMO_ENABLED=True, DEMO_ENVIRONMENT_TTL_HOURS=24, DEMO_CLEANUP_BATCH_SIZE=100)
|
||||||
|
class DemoStartApiTests(APITestCase):
|
||||||
|
def test_demo_start_creates_isolated_seeded_environment(self):
|
||||||
|
response = self.client.post(DEMO_START_URL)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
self.assertIn("refresh", response.data)
|
||||||
|
self.assertEqual(DemoEnvironment.objects.count(), 1)
|
||||||
|
|
||||||
|
environment = DemoEnvironment.objects.select_related("owner_user", "workspace").get()
|
||||||
|
self.assertTrue(environment.owner_user.is_demo)
|
||||||
|
self.assertEqual(environment.owner_user.demo_expires_at, environment.expires_at)
|
||||||
|
self.assertGreaterEqual(WorkspaceMembership.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(Client.objects.filter(workspace=environment.workspace).count(), 3)
|
||||||
|
self.assertGreaterEqual(Project.objects.filter(workspace=environment.workspace).count(), 5)
|
||||||
|
self.assertGreaterEqual(Tag.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(TimeEntry.objects.filter(workspace=environment.workspace).count(), 8)
|
||||||
|
self.assertGreaterEqual(WorkspaceUserRate.objects.filter(workspace=environment.workspace).count(), 4)
|
||||||
|
self.assertGreaterEqual(ProjectAccess.objects.filter(project__workspace=environment.workspace).count(), 1)
|
||||||
|
|
||||||
|
def test_two_demo_starts_do_not_share_workspace_data(self):
|
||||||
|
first = self.client.post(DEMO_START_URL)
|
||||||
|
second = self.client.post(DEMO_START_URL)
|
||||||
|
|
||||||
|
self.assertEqual(first.status_code, 201)
|
||||||
|
self.assertEqual(second.status_code, 201)
|
||||||
|
environments = list(DemoEnvironment.objects.order_by("created_at"))
|
||||||
|
self.assertEqual(len(environments), 2)
|
||||||
|
self.assertNotEqual(environments[0].workspace_id, environments[1].workspace_id)
|
||||||
|
self.assertNotEqual(environments[0].owner_user_id, environments[1].owner_user_id)
|
||||||
|
|
||||||
|
def test_demo_user_cannot_search_external_users_or_send_otp(self):
|
||||||
|
self.client.post(DEMO_START_URL)
|
||||||
|
environment = DemoEnvironment.objects.select_related("owner_user").get()
|
||||||
|
real_user = User.objects.create_user(mobile="09111111111", password="Testpass123!")
|
||||||
|
self.client.force_authenticate(environment.owner_user)
|
||||||
|
|
||||||
|
search_response = self.client.get(f"/api/users/search/?mobile={real_user.mobile}")
|
||||||
|
self.assertEqual(search_response.status_code, 403)
|
||||||
|
|
||||||
|
otp_response = self.client.post(
|
||||||
|
"/api/users/otp/send/",
|
||||||
|
{"mobile": environment.owner_user.mobile, "mode": "login"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(otp_response.status_code, 400)
|
||||||
|
|
||||||
|
def test_cleanup_deletes_expired_demo_and_keeps_real_users(self):
|
||||||
|
self.client.post(DEMO_START_URL)
|
||||||
|
environment = DemoEnvironment.objects.select_related("workspace").get()
|
||||||
|
real_user = User.objects.create_user(mobile="09122222222", password="Testpass123!")
|
||||||
|
DemoEnvironment.objects.filter(id=environment.id).update(expires_at=timezone.now() - timezone.timedelta(minutes=1))
|
||||||
|
|
||||||
|
cleaned = cleanup_expired_demo_environments()
|
||||||
|
|
||||||
|
self.assertEqual(cleaned, 1)
|
||||||
|
self.assertFalse(DemoEnvironment.all_objects.filter(id=environment.id).exists())
|
||||||
|
self.assertFalse(TimeEntry.all_objects.filter(workspace_id=environment.workspace_id).exists())
|
||||||
|
self.assertTrue(User.objects.filter(id=real_user.id).exists())
|
||||||
@@ -205,6 +205,18 @@ class RedisNotificationStore:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear_user(cls, user_id: str) -> int:
|
||||||
|
ids_key = cls._ids_key(user_id)
|
||||||
|
data_key = cls._data_key(user_id)
|
||||||
|
count = redis_client.zcard(ids_key)
|
||||||
|
pipe = redis_client.pipeline()
|
||||||
|
pipe.delete(ids_key)
|
||||||
|
pipe.delete(data_key)
|
||||||
|
pipe.srem(cls.USERS_KEY, user_id)
|
||||||
|
pipe.execute()
|
||||||
|
return int(count or 0)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
|
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
|
||||||
data = cls.get(user_id, notif_id)
|
data = cls.get(user_id, notif_id)
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ BORDER = Border(
|
|||||||
top=Side(style="thin", color="D0D7DE"),
|
top=Side(style="thin", color="D0D7DE"),
|
||||||
bottom=Side(style="thin", color="D0D7DE"),
|
bottom=Side(style="thin", color="D0D7DE"),
|
||||||
)
|
)
|
||||||
|
USER_SUMMARY_SEPARATOR_BORDER = Border(
|
||||||
|
left=Side(style="thin", color="D0D7DE"),
|
||||||
|
right=Side(style="thin", color="D0D7DE"),
|
||||||
|
top=Side(style="medium", color="94A3B8"),
|
||||||
|
bottom=Side(style="thin", color="D0D7DE"),
|
||||||
|
)
|
||||||
|
EMPTY_BORDER = Border()
|
||||||
|
|
||||||
|
|
||||||
class BookmarkDocTemplate(SimpleDocTemplate):
|
class BookmarkDocTemplate(SimpleDocTemplate):
|
||||||
@@ -65,6 +72,10 @@ def _autosize_columns(worksheet) -> None:
|
|||||||
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
|
worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30)
|
||||||
|
|
||||||
|
|
||||||
|
def _freeze_first_column(worksheet) -> None:
|
||||||
|
worksheet.freeze_panes = "B1"
|
||||||
|
|
||||||
|
|
||||||
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
|
def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str:
|
||||||
return locale.format_money_label(income_totals)
|
return locale.format_money_label(income_totals)
|
||||||
|
|
||||||
@@ -619,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
|
||||||
@@ -628,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,
|
||||||
*,
|
*,
|
||||||
@@ -706,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"]
|
||||||
@@ -740,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=18,
|
|
||||||
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"),
|
||||||
@@ -788,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 []
|
||||||
@@ -811,12 +832,9 @@ def _render_all_users_overall_excel_sheet(
|
|||||||
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True)
|
_merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=18, value_present=True)
|
||||||
current_row += span
|
current_row += span
|
||||||
|
|
||||||
for row_index in range(16, current_row):
|
for row_index in range(15, current_row):
|
||||||
for column_index in (7, 11, 15):
|
for column_index in (7, 11, 15):
|
||||||
cell = worksheet.cell(row=row_index, column=column_index)
|
_clear_excel_cell_style(worksheet.cell(row=row_index, column=column_index))
|
||||||
cell.value = None
|
|
||||||
cell.fill = PatternFill(fill_type=None)
|
|
||||||
cell.border = Border()
|
|
||||||
|
|
||||||
current_row += 2
|
current_row += 2
|
||||||
for title_key, rows, hour_percentages, income_percentages in (
|
for title_key, rows, hour_percentages, income_percentages in (
|
||||||
@@ -843,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,
|
||||||
)
|
)
|
||||||
@@ -922,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([])
|
||||||
|
|||||||
@@ -196,10 +196,20 @@ 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:R15", {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][:18],
|
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:18],
|
||||||
(
|
(
|
||||||
@@ -226,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,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from core.serializers.base import BaseModelSerializer
|
from core.serializers.base import BaseModelSerializer
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
@@ -31,6 +32,27 @@ class TimeEntrySerializer(BaseModelSerializer):
|
|||||||
tag_details = serializers.SerializerMethodField()
|
tag_details = serializers.SerializerMethodField()
|
||||||
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
start_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S")
|
||||||
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
end_time = serializers.DateTimeField(format="%Y-%m-%d %H:%M:%S", allow_null=True)
|
||||||
|
start_time_ms = serializers.SerializerMethodField()
|
||||||
|
end_time_ms = serializers.SerializerMethodField()
|
||||||
|
server_now_ms = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _epoch_ms(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if timezone.is_naive(value):
|
||||||
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
return int(value.timestamp() * 1000)
|
||||||
|
|
||||||
|
def get_start_time_ms(self, obj):
|
||||||
|
return self._epoch_ms(obj.start_time)
|
||||||
|
|
||||||
|
def get_end_time_ms(self, obj):
|
||||||
|
return self._epoch_ms(obj.end_time)
|
||||||
|
|
||||||
|
def get_server_now_ms(self, obj):
|
||||||
|
server_now = self.context.get("server_now") or timezone.now()
|
||||||
|
return self._epoch_ms(server_now)
|
||||||
|
|
||||||
def get_tags(self, obj):
|
def get_tags(self, obj):
|
||||||
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
|
return [str(tag.id) for tag in Tag.all_objects.filter(time_entries=obj).order_by("-updated_at", "-created_at")]
|
||||||
@@ -76,7 +98,10 @@ class TimeEntrySerializer(BaseModelSerializer):
|
|||||||
"project_details",
|
"project_details",
|
||||||
"description",
|
"description",
|
||||||
"start_time",
|
"start_time",
|
||||||
|
"start_time_ms",
|
||||||
"end_time",
|
"end_time",
|
||||||
|
"end_time_ms",
|
||||||
|
"server_now_ms",
|
||||||
"duration",
|
"duration",
|
||||||
"tags",
|
"tags",
|
||||||
"tag_details",
|
"tag_details",
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
filterset_class = TimeEntryFilter
|
filterset_class = TimeEntryFilter
|
||||||
search_fields = ["description", "project__name", "project__client__name", "tags__name"]
|
search_fields = ["description", "project__name", "project__client__name", "tags__name"]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _epoch_ms(value):
|
||||||
|
if timezone.is_naive(value):
|
||||||
|
value = timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
return int(value.timestamp() * 1000)
|
||||||
|
|
||||||
|
def _serializer_context(self, *, server_now=None):
|
||||||
|
context = self.get_serializer_context()
|
||||||
|
context["server_now"] = server_now or timezone.now()
|
||||||
|
return context
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_duration_ms(entry):
|
def _serialize_duration_ms(entry):
|
||||||
if entry.duration is not None:
|
if entry.duration is not None:
|
||||||
@@ -51,8 +62,12 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
days_since_sunday = (local_dt.weekday() + 1) % 7
|
days_since_sunday = (local_dt.weekday() + 1) % 7
|
||||||
return (local_dt - timedelta(days=days_since_sunday)).date()
|
return (local_dt - timedelta(days=days_since_sunday)).date()
|
||||||
|
|
||||||
def _build_grouped_entries(self, entries):
|
def _build_grouped_entries(self, entries, *, server_now):
|
||||||
serialized_entries = TimeEntrySerializer(entries, many=True, context=self.get_serializer_context()).data
|
serialized_entries = TimeEntrySerializer(
|
||||||
|
entries,
|
||||||
|
many=True,
|
||||||
|
context=self._serializer_context(server_now=server_now),
|
||||||
|
).data
|
||||||
serialized_by_id = {item["id"]: item for item in serialized_entries}
|
serialized_by_id = {item["id"]: item for item in serialized_entries}
|
||||||
weeks = []
|
weeks = []
|
||||||
weeks_by_key = {}
|
weeks_by_key = {}
|
||||||
@@ -114,6 +129,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
paginator = self.pagination_class()
|
paginator = self.pagination_class()
|
||||||
page = paginator.paginate_queryset(queryset, request, view=self)
|
page = paginator.paginate_queryset(queryset, request, view=self)
|
||||||
|
server_now = timezone.now()
|
||||||
current_items_count = len(page)
|
current_items_count = len(page)
|
||||||
has_more = (paginator.offset + current_items_count) < paginator.count
|
has_more = (paginator.offset + current_items_count) < paginator.count
|
||||||
|
|
||||||
@@ -125,7 +141,19 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
"offset": paginator.offset,
|
"offset": paginator.offset,
|
||||||
"next_offset": paginator.offset + current_items_count if has_more else None,
|
"next_offset": paginator.offset + current_items_count if has_more else None,
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
"groups": self._build_grouped_entries(page),
|
"server_now_ms": self._epoch_ms(server_now),
|
||||||
|
"server_now": server_now.isoformat(),
|
||||||
|
"groups": self._build_grouped_entries(page, server_now=server_now),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"], url_path="debug-time")
|
||||||
|
def debug_time(self, request):
|
||||||
|
server_now = timezone.now()
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"server_now_ms": self._epoch_ms(server_now),
|
||||||
|
"server_now": server_now.isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,7 +176,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(entry, context=self._serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def update(self, request, *args, **kwargs):
|
def update(self, request, *args, **kwargs):
|
||||||
@@ -168,7 +196,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
**serializer.validated_data
|
**serializer.validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(updated_entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(updated_entry, context=self._serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
@@ -189,7 +217,7 @@ class TimeEntryViewSet(ModelViewSet):
|
|||||||
end_time = serializer.validated_data.get("end_time")
|
end_time = serializer.validated_data.get("end_time")
|
||||||
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
||||||
|
|
||||||
output_serializer = TimeEntrySerializer(stopped_entry, context=self.get_serializer_context())
|
output_serializer = TimeEntrySerializer(stopped_entry, context=self._serializer_context())
|
||||||
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
def destroy(self, request, *args, **kwargs):
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -1,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")
|
||||||
|
|||||||
@@ -212,10 +212,12 @@ class UserProfileSerializer(BaseModelSerializer):
|
|||||||
"profile_picture",
|
"profile_picture",
|
||||||
"birth_date",
|
"birth_date",
|
||||||
"is_verified",
|
"is_verified",
|
||||||
|
"is_demo",
|
||||||
|
"demo_expires_at",
|
||||||
"full_name",
|
"full_name",
|
||||||
"age",
|
"age",
|
||||||
)
|
)
|
||||||
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified")
|
read_only_fields = BaseModelSerializer.Meta.fields + ("mobile", "is_verified", "is_demo", "demo_expires_at")
|
||||||
|
|
||||||
|
|
||||||
class UserSearchSerializer(serializers.ModelSerializer):
|
class UserSearchSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -293,6 +293,11 @@ class ChangePasswordView(APIView):
|
|||||||
|
|
||||||
@extend_schema(request=ChangePasswordSerializer)
|
@extend_schema(request=ChangePasswordSerializer)
|
||||||
def patch(self, request, *args, **kwargs):
|
def patch(self, request, *args, **kwargs):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot change passwords."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
|
serializer = ChangePasswordSerializer(data=request.data, context={"request": request})
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@@ -327,6 +332,11 @@ class SetPasswordView(UpdateAPIView):
|
|||||||
|
|
||||||
@extend_schema(request=ChangePasswordSerializer, responses=None)
|
@extend_schema(request=ChangePasswordSerializer, responses=None)
|
||||||
def patch(self, request, *args, **kwargs):
|
def patch(self, request, *args, **kwargs):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot change passwords."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
return super().patch(request, *args, **kwargs)
|
return super().patch(request, *args, **kwargs)
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
@@ -347,6 +357,11 @@ class ProfilePictureView(APIView):
|
|||||||
operation_id="users_profile_picture_self_create",
|
operation_id="users_profile_picture_self_create",
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot upload profile pictures."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
serializer = UserProfilePictureSerializer(
|
serializer = UserProfilePictureSerializer(
|
||||||
instance=request.user,
|
instance=request.user,
|
||||||
data=request.data,
|
data=request.data,
|
||||||
@@ -362,6 +377,11 @@ class ProfilePictureView(APIView):
|
|||||||
operation_id="users_profile_picture_self_delete",
|
operation_id="users_profile_picture_self_delete",
|
||||||
)
|
)
|
||||||
def delete(self, request):
|
def delete(self, request):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot remove profile pictures."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
request.user.profile_picture.delete(save=False)
|
request.user.profile_picture.delete(save=False)
|
||||||
request.user.profile_picture = None
|
request.user.profile_picture = None
|
||||||
request.user.save(update_fields=["profile_picture", "updated_at"])
|
request.user.save(update_fields=["profile_picture", "updated_at"])
|
||||||
@@ -401,6 +421,11 @@ class UserSearchAPIView(APIView):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot search external users."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
mobile = request.query_params.get('mobile')
|
mobile = request.query_params.get('mobile')
|
||||||
if not mobile:
|
if not mobile:
|
||||||
return Response(
|
return Response(
|
||||||
|
|||||||
25
apps/users/migrations/0004_user_demo_fields.py
Normal file
25
apps/users/migrations/0004_user_demo_fields.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0003_normalize_user_email_identity"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="demo_expires_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="is_demo",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(fields=["is_demo", "demo_expires_at"], name="user_demo_expires_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -22,6 +22,8 @@ class User(AbstractUser, BaseModel):
|
|||||||
|
|
||||||
password_updated_at = models.DateTimeField(blank=True, null=True)
|
password_updated_at = models.DateTimeField(blank=True, null=True)
|
||||||
is_verified = models.BooleanField(default=False)
|
is_verified = models.BooleanField(default=False)
|
||||||
|
is_demo = models.BooleanField(default=False)
|
||||||
|
demo_expires_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
USERNAME_FIELD = "mobile"
|
USERNAME_FIELD = "mobile"
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
@@ -63,6 +65,7 @@ class User(AbstractUser, BaseModel):
|
|||||||
indexes = (
|
indexes = (
|
||||||
models.Index(fields=["id"], name="user_id_idx"),
|
models.Index(fields=["id"], name="user_id_idx"),
|
||||||
models.Index(fields=["mobile"], name="user_mobile_idx"),
|
models.Index(fields=["mobile"], name="user_mobile_idx"),
|
||||||
|
models.Index(fields=["is_demo", "demo_expires_at"], name="user_demo_expires_idx"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ def register_user_with_otp(mobile, code, password, first_name="", last_name=""):
|
|||||||
|
|
||||||
def generate_and_send_otp(mobile, mode):
|
def generate_and_send_otp(mobile, mode):
|
||||||
"""Business logic for generating OTP, checking existence rules, and sending SMS."""
|
"""Business logic for generating OTP, checking existence rules, and sending SMS."""
|
||||||
user_exists = User.objects.filter(mobile=mobile).exists()
|
user = User.objects.filter(mobile=mobile).only("is_demo").first()
|
||||||
|
user_exists = user is not None
|
||||||
|
|
||||||
|
if user and user.is_demo:
|
||||||
|
raise ValidationError({"mobile": "Demo accounts cannot use SMS verification."})
|
||||||
|
|
||||||
if mode == "register" and user_exists:
|
if mode == "register" and user_exists:
|
||||||
raise ValidationError({"mobile": "این شماره قبلاً ثبتنام شده است."})
|
raise ValidationError({"mobile": "این شماره قبلاً ثبتنام شده است."})
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from django.core import signing
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@@ -18,6 +23,7 @@ from apps.notifications.services import (
|
|||||||
)
|
)
|
||||||
from apps.projects.models import ProjectUserRate
|
from apps.projects.models import ProjectUserRate
|
||||||
from apps.projects.services.access import filter_projects_for_user
|
from apps.projects.services.access import filter_projects_for_user
|
||||||
|
from apps.users.models import User
|
||||||
from apps.workspaces.api.permissions import (
|
from apps.workspaces.api.permissions import (
|
||||||
CanWorkspaceManageMembers,
|
CanWorkspaceManageMembers,
|
||||||
IsWorkspaceAdmin,
|
IsWorkspaceAdmin,
|
||||||
@@ -56,6 +62,20 @@ from core.services.cache import (
|
|||||||
|
|
||||||
REFERENCE_CACHE_TTL_SECONDS = 60 * 5
|
REFERENCE_CACHE_TTL_SECONDS = 60 * 5
|
||||||
PRICE_UNITS_CACHE_TTL_SECONDS = 60 * 60
|
PRICE_UNITS_CACHE_TTL_SECONDS = 60 * 60
|
||||||
|
MEMBER_IMPORT_CACHE_PREFIX = "workspace-member-import"
|
||||||
|
MEMBER_IMPORT_TTL_SECONDS = 60 * 15
|
||||||
|
MEMBER_IMPORT_MAX_ROWS = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_digits(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
translation = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
||||||
|
return str(value).translate(translation).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _import_cache_key(token):
|
||||||
|
return f"{MEMBER_IMPORT_CACHE_PREFIX}:{token}"
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceViewSet(ModelViewSet):
|
class WorkspaceViewSet(ModelViewSet):
|
||||||
@@ -94,6 +114,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()
|
||||||
@@ -208,6 +236,288 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
|
|
||||||
return [IsAuthenticated()]
|
return [IsAuthenticated()]
|
||||||
|
|
||||||
|
def _ensure_import_permission(self, request, workspace):
|
||||||
|
if getattr(request.user, "is_demo", False):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Demo accounts cannot import workspace members."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
permission = IsWorkspaceAdmin()
|
||||||
|
if not permission.has_object_permission(request, self, workspace):
|
||||||
|
return Response(
|
||||||
|
{"detail": "You must be a Workspace Admin or Owner to import members."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _validate_import_rows(self, request, workspace, rows):
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
result_rows = []
|
||||||
|
seen_mobiles = set()
|
||||||
|
valid_count = 0
|
||||||
|
invalid_count = 0
|
||||||
|
allowed_roles = {
|
||||||
|
WorkspaceMembership.Role.ADMIN,
|
||||||
|
WorkspaceMembership.Role.MEMBER,
|
||||||
|
WorkspaceMembership.Role.GUEST,
|
||||||
|
}
|
||||||
|
existing_memberships = {
|
||||||
|
str(membership.user_id): membership
|
||||||
|
for membership in WorkspaceMembership.all_objects.filter(
|
||||||
|
workspace=workspace,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) > MEMBER_IMPORT_MAX_ROWS:
|
||||||
|
return {
|
||||||
|
"can_commit": False,
|
||||||
|
"summary": {
|
||||||
|
"total": len(rows),
|
||||||
|
"valid": 0,
|
||||||
|
"invalid": len(rows),
|
||||||
|
},
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"line": None,
|
||||||
|
"mobile": "",
|
||||||
|
"role": WorkspaceMembership.Role.MEMBER,
|
||||||
|
"hourly_rate": "",
|
||||||
|
"currency": "",
|
||||||
|
"status": "invalid",
|
||||||
|
"action": "none",
|
||||||
|
"user": None,
|
||||||
|
"messages": ["too_many_rows"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, raw_row in enumerate(rows, start=1):
|
||||||
|
raw_row = raw_row if isinstance(raw_row, dict) else {}
|
||||||
|
line = raw_row.get("line") or index + 1
|
||||||
|
mobile = _normalize_digits(raw_row.get("mobile"))
|
||||||
|
role = (str(raw_row.get("role") or WorkspaceMembership.Role.MEMBER).strip().lower())
|
||||||
|
hourly_rate_raw = _normalize_digits(raw_row.get("hourly_rate"))
|
||||||
|
currency = str(raw_row.get("currency") or "").strip().upper()
|
||||||
|
messages = []
|
||||||
|
user = None
|
||||||
|
normalized_rate = ""
|
||||||
|
|
||||||
|
if not mobile:
|
||||||
|
messages.append("mobile_required")
|
||||||
|
elif mobile in seen_mobiles:
|
||||||
|
messages.append("duplicate_mobile")
|
||||||
|
else:
|
||||||
|
seen_mobiles.add(mobile)
|
||||||
|
user = User.objects.filter(mobile=mobile).first()
|
||||||
|
if not user:
|
||||||
|
messages.append("user_not_found")
|
||||||
|
elif str(user.id) in existing_memberships:
|
||||||
|
messages.append("already_member")
|
||||||
|
|
||||||
|
if role == WorkspaceMembership.Role.OWNER:
|
||||||
|
messages.append("owner_role_not_allowed")
|
||||||
|
elif role not in allowed_roles:
|
||||||
|
messages.append("invalid_role")
|
||||||
|
elif not can_assign_workspace_role(request.user, workspace, role):
|
||||||
|
messages.append("role_permission_denied")
|
||||||
|
|
||||||
|
has_rate = bool(hourly_rate_raw)
|
||||||
|
has_currency = bool(currency)
|
||||||
|
if has_rate != has_currency:
|
||||||
|
messages.append("rate_currency_pair_required")
|
||||||
|
elif has_rate and has_currency:
|
||||||
|
try:
|
||||||
|
parsed_rate = Decimal(hourly_rate_raw.replace(",", ""))
|
||||||
|
if parsed_rate <= Decimal("0"):
|
||||||
|
messages.append("hourly_rate_positive")
|
||||||
|
else:
|
||||||
|
normalized_rate = f"{parsed_rate:.2f}"
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
messages.append("hourly_rate_invalid")
|
||||||
|
|
||||||
|
if not PriceUnit.objects.filter(code=currency, is_deleted=False).exists():
|
||||||
|
messages.append("currency_invalid")
|
||||||
|
|
||||||
|
row_status = "invalid" if messages else "valid"
|
||||||
|
if messages:
|
||||||
|
invalid_count += 1
|
||||||
|
else:
|
||||||
|
valid_count += 1
|
||||||
|
|
||||||
|
result_rows.append(
|
||||||
|
{
|
||||||
|
"line": line,
|
||||||
|
"mobile": mobile,
|
||||||
|
"role": role,
|
||||||
|
"hourly_rate": normalized_rate,
|
||||||
|
"currency": currency,
|
||||||
|
"status": row_status,
|
||||||
|
"action": "add_member" if row_status == "valid" else "none",
|
||||||
|
"user": (
|
||||||
|
{
|
||||||
|
"id": str(user.id),
|
||||||
|
"full_name": user.full_name or user.mobile,
|
||||||
|
"mobile": user.mobile,
|
||||||
|
}
|
||||||
|
if user
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"can_commit": bool(rows) and invalid_count == 0,
|
||||||
|
"summary": {
|
||||||
|
"total": len(rows),
|
||||||
|
"valid": valid_count,
|
||||||
|
"invalid": invalid_count,
|
||||||
|
},
|
||||||
|
"rows": result_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _build_import_response(self, request, workspace, rows, *, include_token):
|
||||||
|
payload = self._validate_import_rows(request, workspace, rows)
|
||||||
|
if include_token and payload["can_commit"]:
|
||||||
|
token = signing.dumps(
|
||||||
|
{
|
||||||
|
"workspace": str(workspace.id),
|
||||||
|
"user": str(request.user.id),
|
||||||
|
},
|
||||||
|
salt=MEMBER_IMPORT_CACHE_PREFIX,
|
||||||
|
)
|
||||||
|
cache.set(
|
||||||
|
_import_cache_key(token),
|
||||||
|
{
|
||||||
|
"workspace": str(workspace.id),
|
||||||
|
"user": str(request.user.id),
|
||||||
|
"rows": rows,
|
||||||
|
},
|
||||||
|
timeout=MEMBER_IMPORT_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
payload["import_token"] = token
|
||||||
|
else:
|
||||||
|
payload["import_token"] = None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="import/validate")
|
||||||
|
def import_validate(self, request):
|
||||||
|
workspace_id = request.data.get("workspace")
|
||||||
|
if not workspace_id:
|
||||||
|
return Response(
|
||||||
|
{"workspace": ["This field is required."]},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||||
|
permission_response = self._ensure_import_permission(request, workspace)
|
||||||
|
if permission_response is not None:
|
||||||
|
return permission_response
|
||||||
|
|
||||||
|
payload = self._build_import_response(
|
||||||
|
request,
|
||||||
|
workspace,
|
||||||
|
request.data.get("rows") or [],
|
||||||
|
include_token=True,
|
||||||
|
)
|
||||||
|
return Response(payload, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], url_path="import/commit")
|
||||||
|
def import_commit(self, request):
|
||||||
|
workspace_id = request.data.get("workspace")
|
||||||
|
import_token = request.data.get("import_token")
|
||||||
|
if not workspace_id:
|
||||||
|
return Response(
|
||||||
|
{"workspace": ["This field is required."]},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if not import_token:
|
||||||
|
return Response(
|
||||||
|
{"import_token": ["This field is required."]},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
|
||||||
|
permission_response = self._ensure_import_permission(request, workspace)
|
||||||
|
if permission_response is not None:
|
||||||
|
return permission_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
signed_payload = signing.loads(
|
||||||
|
import_token,
|
||||||
|
salt=MEMBER_IMPORT_CACHE_PREFIX,
|
||||||
|
max_age=MEMBER_IMPORT_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
except signing.BadSignature:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Import validation has expired. Please validate the file again."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
cached_payload = cache.get(_import_cache_key(import_token))
|
||||||
|
if (
|
||||||
|
not cached_payload
|
||||||
|
or signed_payload.get("workspace") != str(workspace.id)
|
||||||
|
or signed_payload.get("user") != str(request.user.id)
|
||||||
|
or cached_payload.get("workspace") != str(workspace.id)
|
||||||
|
or cached_payload.get("user") != str(request.user.id)
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Import validation has expired. Please validate the file again."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = cached_payload.get("rows") or []
|
||||||
|
validation_payload = self._validate_import_rows(request, workspace, rows)
|
||||||
|
if not validation_payload["can_commit"]:
|
||||||
|
validation_payload["import_token"] = None
|
||||||
|
return Response(validation_payload, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
memberships = []
|
||||||
|
rate_count = 0
|
||||||
|
with transaction.atomic():
|
||||||
|
for row in validation_payload["rows"]:
|
||||||
|
user_id = row["user"]["id"]
|
||||||
|
membership = WorkspaceMembership.objects.create(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
role=row["role"],
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
memberships.append(membership)
|
||||||
|
notify_workspace_membership_added(
|
||||||
|
actor=request.user,
|
||||||
|
recipient=membership.user,
|
||||||
|
workspace=workspace,
|
||||||
|
role=membership.role,
|
||||||
|
)
|
||||||
|
if row["hourly_rate"] and row["currency"]:
|
||||||
|
upsert_workspace_user_rate(
|
||||||
|
workspace=workspace,
|
||||||
|
user_id=user_id,
|
||||||
|
hourly_rate=Decimal(row["hourly_rate"]),
|
||||||
|
currency=row["currency"],
|
||||||
|
)
|
||||||
|
rate_count += 1
|
||||||
|
|
||||||
|
cache.delete(_import_cache_key(import_token))
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"created_memberships": len(memberships),
|
||||||
|
"created_or_updated_rates": rate_count,
|
||||||
|
"memberships": WorkspaceMembershipSerializer(
|
||||||
|
memberships,
|
||||||
|
many=True,
|
||||||
|
context=self.get_serializer_context(),
|
||||||
|
).data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
workspace_id = request.query_params.get("workspace")
|
workspace_id = request.query_params.get("workspace")
|
||||||
if not workspace_id:
|
if not workspace_id:
|
||||||
@@ -247,6 +557,11 @@ class WorkspaceMembershipViewSet(ModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
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):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -7,7 +8,13 @@ from apps.clients.models import Client
|
|||||||
from apps.projects.models import Project
|
from apps.projects.models import Project
|
||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import (
|
||||||
|
HourlyRateHistory,
|
||||||
|
PriceUnit,
|
||||||
|
Workspace,
|
||||||
|
WorkspaceMembership,
|
||||||
|
WorkspaceUserRate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceCapabilityTests(APITestCase):
|
class WorkspaceCapabilityTests(APITestCase):
|
||||||
@@ -298,6 +305,139 @@ class WorkspaceCapabilityTests(APITestCase):
|
|||||||
self.assertEqual(update_response.status_code, 403)
|
self.assertEqual(update_response.status_code, 403)
|
||||||
self.assertEqual(delete_response.status_code, 403)
|
self.assertEqual(delete_response.status_code, 403)
|
||||||
|
|
||||||
|
def test_owner_can_validate_and_commit_member_import_with_rate(self):
|
||||||
|
PriceUnit.objects.create(code="IRT", name="Toman", local_name="Toman", symbol="Toman")
|
||||||
|
target = self._user(21)
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
validate_response = self.client.post(
|
||||||
|
"/api/workspace-memberships/import/validate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"line": 2,
|
||||||
|
"mobile": target.mobile,
|
||||||
|
"role": "member",
|
||||||
|
"hourly_rate": "150000",
|
||||||
|
"currency": "IRT",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(validate_response.status_code, 200)
|
||||||
|
self.assertTrue(validate_response.data["can_commit"])
|
||||||
|
self.assertEqual(validate_response.data["summary"]["valid"], 1)
|
||||||
|
self.assertTrue(validate_response.data["import_token"])
|
||||||
|
|
||||||
|
commit_response = self.client.post(
|
||||||
|
"/api/workspace-memberships/import/commit/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"import_token": validate_response.data["import_token"],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(commit_response.status_code, 201)
|
||||||
|
self.assertEqual(commit_response.data["created_memberships"], 1)
|
||||||
|
self.assertEqual(commit_response.data["created_or_updated_rates"], 1)
|
||||||
|
self.assertTrue(
|
||||||
|
WorkspaceMembership.objects.filter(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=target,
|
||||||
|
role=WorkspaceMembership.Role.MEMBER,
|
||||||
|
is_active=True,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
rate = WorkspaceUserRate.objects.get(workspace=self.workspace, user=target)
|
||||||
|
self.assertEqual(rate.hourly_rate, Decimal("150000.00"))
|
||||||
|
self.assertEqual(rate.currency, "IRT")
|
||||||
|
self.assertTrue(
|
||||||
|
HourlyRateHistory.objects.filter(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=target,
|
||||||
|
hourly_rate=Decimal("150000.00"),
|
||||||
|
currency="IRT",
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_member_import_rejects_invalid_rows(self):
|
||||||
|
PriceUnit.objects.create(code="USD", name="Dollar", local_name="Dollar", symbol="$")
|
||||||
|
target = self._user(22)
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/workspace-memberships/import/validate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"rows": [
|
||||||
|
{"line": 2, "mobile": "", "role": "member"},
|
||||||
|
{"line": 3, "mobile": "09120000000", "role": "member"},
|
||||||
|
{"line": 4, "mobile": target.mobile, "role": "owner"},
|
||||||
|
{"line": 5, "mobile": target.mobile, "role": "member"},
|
||||||
|
{"line": 6, "mobile": self.member.mobile, "role": "member"},
|
||||||
|
{"line": 7, "mobile": self.guest.mobile, "role": "guest", "hourly_rate": "10"},
|
||||||
|
{"line": 8, "mobile": self.admin.mobile, "role": "admin", "hourly_rate": "0", "currency": "USD"},
|
||||||
|
{"line": 9, "mobile": self.extra_owner.mobile, "role": "guest", "hourly_rate": "10", "currency": "XXX"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFalse(response.data["can_commit"])
|
||||||
|
self.assertIsNone(response.data["import_token"])
|
||||||
|
self.assertEqual(response.data["summary"]["invalid"], 8)
|
||||||
|
|
||||||
|
def test_admin_import_follows_role_assignment_rules(self):
|
||||||
|
target = self._user(23)
|
||||||
|
self.client.force_authenticate(user=self.admin)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/workspace-memberships/import/validate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"rows": [{"line": 2, "mobile": target.mobile, "role": "admin"}],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFalse(response.data["can_commit"])
|
||||||
|
self.assertIn("role_permission_denied", response.data["rows"][0]["messages"])
|
||||||
|
|
||||||
|
def test_member_cannot_import_workspace_members(self):
|
||||||
|
target = self._user(24)
|
||||||
|
self.client.force_authenticate(user=self.member)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/workspace-memberships/import/validate/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"rows": [{"line": 2, "mobile": target.mobile, "role": "member"}],
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_import_commit_rejects_expired_token(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/workspace-memberships/import/commit/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"import_token": "invalid-token",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
def test_admin_can_delete_only_owned_clients_tags_and_projects(self):
|
def test_admin_can_delete_only_owned_clients_tags_and_projects(self):
|
||||||
self.client.force_authenticate(user=self.owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
owner_client_response = self.client.post(
|
owner_client_response = self.client.post(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import os
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from celery.schedules import crontab
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -48,6 +49,8 @@ LOCAL_APPS = [
|
|||||||
"apps.notifications",
|
"apps.notifications",
|
||||||
"apps.reports",
|
"apps.reports",
|
||||||
"apps.logs",
|
"apps.logs",
|
||||||
|
"apps.demos",
|
||||||
|
"apps.contacts",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
@@ -138,6 +141,8 @@ REST_FRAMEWORK = {
|
|||||||
"otp_send_sustained": "10/day",
|
"otp_send_sustained": "10/day",
|
||||||
"login_password": "5/10m",
|
"login_password": "5/10m",
|
||||||
"login_otp": "5/10m",
|
"login_otp": "5/10m",
|
||||||
|
"demo_start": os.getenv("DEMO_START_RATE_LIMIT", "10/hour"),
|
||||||
|
"contact_submission": os.getenv("CONTACT_SUBMISSION_RATE_LIMIT", "5/hour"),
|
||||||
},
|
},
|
||||||
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
|
"EXCEPTION_HANDLER": "core.exceptions.handlers.exception_handler",
|
||||||
}
|
}
|
||||||
@@ -243,7 +248,21 @@ NOTIFICATION_TOAST_LEVELS = tuple(
|
|||||||
|
|
||||||
REPORT_EXPORT_RETENTION_DAYS = int(os.getenv("REPORT_EXPORT_RETENTION_DAYS", "7"))
|
REPORT_EXPORT_RETENTION_DAYS = int(os.getenv("REPORT_EXPORT_RETENTION_DAYS", "7"))
|
||||||
|
|
||||||
CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks", "apps.reports.tasks")
|
DEMO_ENABLED = os.getenv("DEMO_ENABLED", "True") == "True"
|
||||||
|
DEMO_ENVIRONMENT_TTL_HOURS = int(os.getenv("DEMO_ENVIRONMENT_TTL_HOURS", "24"))
|
||||||
|
DEMO_CLEANUP_BATCH_SIZE = int(os.getenv("DEMO_CLEANUP_BATCH_SIZE", "100"))
|
||||||
|
|
||||||
|
CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks", "apps.reports.tasks", "apps.demos.tasks")
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"reports-cleanup-expired-exports": {
|
||||||
|
"task": "reports.cleanup_expired_exports",
|
||||||
|
"schedule": crontab(minute=0, hour="*/6"),
|
||||||
|
},
|
||||||
|
"demos-cleanup-expired-environments": {
|
||||||
|
"task": "demos.cleanup_expired_environments",
|
||||||
|
"schedule": crontab(minute=0, hour="*"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ urlpatterns = [
|
|||||||
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
|
path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"),
|
||||||
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
path("api/reports/", include("apps.reports.api.urls"), name="reports"),
|
||||||
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
|
path("api/logs/", include("apps.logs.api.urls"), name="logs"),
|
||||||
|
path("api/demo/", include("apps.demos.api.urls"), name="demos"),
|
||||||
|
path("api/contact/", include("apps.contacts.api.urls"), name="contacts"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user