Compare commits

..

7 Commits

Author SHA1 Message Date
95f5e85e44 feat(workspaces): add bulk member import endpoints
Some checks are pending
Backend CI/CD / test (push) Waiting to run
Backend CI/CD / deploy (push) Blocked by required conditions
2026-06-18 22:53:34 +03:30
027afb7e23 feat(contacts): store contact submissions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 14:09:38 +03:30
170ec90ec1 fix(demo): block external account actions
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-06-07 00:50:42 +03:30
30a324c6f4 feat(demo): add isolated demo environments 2026-06-07 00:49:58 +03:30
da40720a0f fix(reports): freeze first excel column
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 17:22:34 +03:30
948a8e1b75 fix(reports): improve excel summary table spacing 2026-05-26 17:20:18 +03:30
b5ddcb76aa fix(timezone): fix timer clock-skew
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-26 12:59:49 +03:30
45 changed files with 1627 additions and 32 deletions

View File

@@ -0,0 +1 @@

56
apps/contacts/admin.py Normal file
View File

@@ -0,0 +1,56 @@
from django.contrib import admin
from apps.contacts.models import ContactSubmission
from core.admins.base import BaseAdmin, SoftDeleteListFilter
@admin.register(ContactSubmission)
class ContactSubmissionAdmin(BaseAdmin):
list_display = (
"id",
"full_name",
"email",
"mobile",
"status",
"created_at",
"is_deleted",
)
list_filter = (
SoftDeleteListFilter,
"status",
"created_at",
)
search_fields = (
"first_name",
"last_name",
"email",
"mobile",
"message",
)
readonly_fields = (
"id",
"ip_address",
"user_agent",
"created_at",
"updated_at",
"created_by",
"updated_by",
)
fields = (
"first_name",
"last_name",
"email",
"mobile",
"message",
"status",
"ip_address",
"user_agent",
"created_at",
"updated_at",
"created_by",
"updated_by",
)
@admin.display(description="Full name")
def full_name(self, obj):
return f"{obj.first_name} {obj.last_name}".strip()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,43 @@
from rest_framework import serializers
from apps.contacts.models import ContactSubmission
class ContactSubmissionCreateSerializer(serializers.ModelSerializer):
class Meta:
model = ContactSubmission
fields = (
"first_name",
"last_name",
"email",
"mobile",
"message",
)
def validate_mobile(self, value):
clean_value = value.strip()
if len(clean_value) < 8:
raise serializers.ValidationError("Enter a valid mobile number.")
return clean_value
def validate_message(self, value):
clean_value = value.strip()
if len(clean_value) < 10:
raise serializers.ValidationError("Message must be at least 10 characters.")
return clean_value
class ContactSubmissionResponseSerializer(serializers.ModelSerializer):
class Meta:
model = ContactSubmission
fields = (
"id",
"first_name",
"last_name",
"email",
"mobile",
"message",
"status",
"created_at",
)
read_only_fields = fields

View File

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

View File

@@ -0,0 +1,9 @@
from django.urls import path
from apps.contacts.api.views import ContactSubmissionView
app_name = "contacts"
urlpatterns = [
path("", ContactSubmissionView.as_view(), name="contact-submit"),
]

View File

@@ -0,0 +1,40 @@
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.contacts.api.serializers import (
ContactSubmissionCreateSerializer,
ContactSubmissionResponseSerializer,
)
from apps.contacts.api.throttles import ContactSubmissionThrottle
def _get_client_ip(request):
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR")
class ContactSubmissionView(APIView):
permission_classes = (AllowAny,)
throttle_classes = (ContactSubmissionThrottle,)
serializer_class = ContactSubmissionCreateSerializer
@extend_schema(
request=ContactSubmissionCreateSerializer,
responses={201: ContactSubmissionResponseSerializer},
)
def post(self, request):
serializer = self.serializer_class(data=request.data)
serializer.is_valid(raise_exception=True)
submission = serializer.save(
ip_address=_get_client_ip(request),
user_agent=request.META.get("HTTP_USER_AGENT", ""),
)
return Response(
ContactSubmissionResponseSerializer(submission).data,
status=status.HTTP_201_CREATED,
)

6
apps/contacts/apps.py Normal file
View File

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

View File

@@ -0,0 +1,85 @@
# Generated manually for contact submissions.
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="ContactSubmission",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid7,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
("is_deleted", models.BooleanField(default=False)),
("is_active", models.BooleanField(default=False)),
("first_name", models.CharField(max_length=120)),
("last_name", models.CharField(max_length=120)),
("email", models.EmailField(max_length=254)),
("mobile", models.CharField(max_length=32)),
("message", models.TextField()),
(
"status",
models.CharField(
choices=[
("new", "New"),
("contacted", "Contacted"),
("closed", "Closed"),
("spam", "Spam"),
],
default="new",
max_length=20,
),
),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("user_agent", models.TextField(blank=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(app_label)s_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="updated_%(app_label)s_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"db_table": "contact_submission",
"ordering": ("-created_at",),
"indexes": [
models.Index(fields=["id"], name="contactsubmission_id_idx"),
models.Index(fields=["created_at"], name="contact_created_at_idx"),
models.Index(fields=["status"], name="contact_status_idx"),
models.Index(fields=["email"], name="contact_email_idx"),
],
},
),
]

View File

@@ -0,0 +1 @@

36
apps/contacts/models.py Normal file
View File

@@ -0,0 +1,36 @@
from django.db import models
from core.models.base import BaseModel
class ContactSubmission(BaseModel):
class Status(models.TextChoices):
NEW = "new", "New"
CONTACTED = "contacted", "Contacted"
CLOSED = "closed", "Closed"
SPAM = "spam", "Spam"
first_name = models.CharField(max_length=120)
last_name = models.CharField(max_length=120)
email = models.EmailField()
mobile = models.CharField(max_length=32)
message = models.TextField()
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.NEW,
)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent = models.TextField(blank=True)
class Meta:
db_table = "contact_submission"
ordering = ("-created_at",)
indexes = (
models.Index(fields=("created_at",), name="contact_created_at_idx"),
models.Index(fields=("status",), name="contact_status_idx"),
models.Index(fields=("email",), name="contact_email_idx"),
)
def __str__(self):
return f"{self.first_name} {self.last_name} - {self.email}"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,44 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from apps.contacts.models import ContactSubmission
class ContactSubmissionApiTests(APITestCase):
def test_public_user_can_submit_contact_form(self):
response = self.client.post(
reverse("contacts:contact-submit"),
{
"first_name": "Amin",
"last_name": "Test",
"email": "amin@example.com",
"mobile": "09938228438",
"message": "I need help with Qlockify reports.",
},
format="json",
HTTP_X_FORWARDED_FOR="203.0.113.10",
HTTP_USER_AGENT="test-agent",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
submission = ContactSubmission.objects.get()
self.assertEqual(submission.email, "amin@example.com")
self.assertEqual(submission.ip_address, "203.0.113.10")
self.assertEqual(submission.user_agent, "test-agent")
def test_rejects_short_message(self):
response = self.client.post(
reverse("contacts:contact-submit"),
{
"first_name": "Amin",
"last_name": "Test",
"email": "amin@example.com",
"mobile": "09938228438",
"message": "Hi",
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(ContactSubmission.objects.exists())

1
apps/demos/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

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

9
apps/demos/api/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from apps.demos.api.views import DemoStartView
app_name = "demos"
urlpatterns = [
path("start/", DemoStartView.as_view(), name="demo-start"),
]

29
apps/demos/api/views.py Normal file
View File

@@ -0,0 +1,29 @@
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import serializers, status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.demos.api.throttles import DemoStartThrottle
from apps.demos.services import create_demo_environment
class DemoStartView(APIView):
permission_classes = (AllowAny,)
throttle_classes = (DemoStartThrottle,)
@extend_schema(
request=None,
responses=inline_serializer(
name="DemoStartResponse",
fields={
"access": serializers.CharField(),
"refresh": serializers.CharField(),
"workspace_id": serializers.CharField(),
"expires_at": serializers.DateTimeField(),
"demo_environment_id": serializers.CharField(),
},
),
)
def post(self, request):
return Response(create_demo_environment(), status=status.HTTP_201_CREATED)

6
apps/demos/apps.py Normal file
View File

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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,18 @@
from django.core.management.base import BaseCommand
from apps.demos.services import cleanup_expired_demo_environments
class Command(BaseCommand):
help = "Clean up expired isolated demo environments."
def add_arguments(self, parser):
parser.add_argument("--expired", action="store_true", help="Clean expired demo environments.")
parser.add_argument("--batch-size", type=int, default=None, help="Maximum number of environments to clean.")
def handle(self, *args, **options):
if not options["expired"]:
self.stderr.write("Only --expired cleanup is supported.")
return
cleaned = cleanup_expired_demo_environments(batch_size=options["batch_size"])
self.stdout.write(self.style.SUCCESS(f"Cleaned {cleaned} expired demo environment(s)."))

View File

@@ -0,0 +1,97 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("workspaces", "0008_hourlyratehistory"),
("users", "0004_user_demo_fields"),
]
operations = [
migrations.CreateModel(
name="DemoEnvironment",
fields=[
("id", models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("deleted_at", models.DateTimeField(blank=True, null=True)),
("is_deleted", models.BooleanField(default=False)),
("is_active", models.BooleanField(default=False)),
("expires_at", models.DateTimeField()),
(
"status",
models.CharField(
choices=[("active", "Active"), ("expired", "Expired"), ("cleaned", "Cleaned")],
default="active",
max_length=16,
),
),
("seed_version", models.CharField(default="v1", max_length=32)),
("cleaned_at", models.DateTimeField(blank=True, null=True)),
("cleanup_error", models.TextField(blank=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_demos_demoenvironment_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner_user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="demo_environment",
to=settings.AUTH_USER_MODEL,
),
),
(
"updated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="updated_demos_demoenvironment_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"workspace",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="demo_environment",
to="workspaces.workspace",
),
),
],
options={
"db_table": "demo_environment",
"ordering": ("-created_at",),
},
),
migrations.AddIndex(
model_name="demoenvironment",
index=models.Index(fields=["id"], name="demoenvironment_id_idx"),
),
migrations.AddIndex(
model_name="demoenvironment",
index=models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
),
migrations.AddIndex(
model_name="demoenvironment",
index=models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
),
migrations.AddIndex(
model_name="demoenvironment",
index=models.Index(fields=["workspace"], name="demo_workspace_idx"),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.12 on 2026-06-06 21:07
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('demos', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveIndex(
model_name='demoenvironment',
name='demoenvironment_id_idx',
),
migrations.AlterField(
model_name='demoenvironment',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='demoenvironment',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1 @@

39
apps/demos/models.py Normal file
View File

@@ -0,0 +1,39 @@
from django.conf import settings
from django.db import models
from core.models.base import BaseModel
class DemoEnvironment(BaseModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
EXPIRED = "expired", "Expired"
CLEANED = "cleaned", "Cleaned"
owner_user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="demo_environment",
)
workspace = models.OneToOneField(
"workspaces.Workspace",
on_delete=models.CASCADE,
related_name="demo_environment",
)
expires_at = models.DateTimeField()
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
seed_version = models.CharField(max_length=32, default="v1")
cleaned_at = models.DateTimeField(blank=True, null=True)
cleanup_error = models.TextField(blank=True)
class Meta:
db_table = "demo_environment"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
models.Index(fields=["workspace"], name="demo_workspace_idx"),
]
def __str__(self):
return f"Demo {self.workspace_id} for {self.owner_user_id}"

273
apps/demos/services.py Normal file
View File

@@ -0,0 +1,273 @@
from __future__ import annotations
import random
import string
from datetime import timedelta
from decimal import Decimal
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from apps.clients.models import Client
from apps.demos.models import DemoEnvironment
from apps.notifications.services import RedisNotificationStore
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
from apps.reports.models import ReportExportJob
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.users.services.auth import get_tokens_for_user
from apps.workspaces.models import HourlyRateHistory, PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
User = get_user_model()
DEMO_SEED_VERSION = "v1"
DEMO_RATE_CURRENCY = "IRT"
def _unique_mobile(prefix: str) -> str:
for _ in range(50):
mobile = f"09{prefix}{''.join(random.choices(string.digits, k=7))}"
if not User.all_objects.filter(mobile=mobile).exists():
return mobile
raise ValidationError({"detail": "Could not allocate a unique demo mobile number."})
def _create_demo_user(*, prefix: str, first_name: str, last_name: str, expires_at):
mobile = _unique_mobile(prefix)
user = User.objects.create_user(
mobile=mobile,
password=None,
email=f"demo-{mobile}@demo.qlockify.local",
first_name=first_name,
last_name=last_name,
is_active=True,
is_verified=True,
is_demo=True,
demo_expires_at=expires_at,
)
return user
def _ensure_price_units() -> None:
PriceUnit.get_or_restore(
code="IRT",
defaults={"name": "Iranian Toman", "local_name": "تومان", "symbol": "تومان", "is_active": True},
)
def _create_workspace_rate(*, workspace, user, amount: str, effective_from):
rate = WorkspaceUserRate.objects.create(
workspace=workspace,
user=user,
hourly_rate=Decimal(amount),
currency=DEMO_RATE_CURRENCY,
effective_from=effective_from,
is_active=True,
)
HourlyRateHistory.objects.create(
workspace=workspace,
user=user,
scope=HourlyRateHistory.Scope.WORKSPACE,
hourly_rate=rate.hourly_rate,
currency=rate.currency,
effective_from=effective_from,
is_active=True,
)
return rate
def _create_project_rate(*, project, user, amount: str, effective_from):
rate = ProjectUserRate.objects.create(
project=project,
user=user,
hourly_rate=Decimal(amount),
currency=DEMO_RATE_CURRENCY,
effective_from=effective_from,
is_active=True,
)
HourlyRateHistory.objects.create(
workspace=project.workspace,
project=project,
user=user,
scope=HourlyRateHistory.Scope.PROJECT,
hourly_rate=rate.hourly_rate,
currency=rate.currency,
effective_from=effective_from,
is_active=True,
)
return rate
def _create_entry(*, workspace, user, project, tags, days_ago: int, hour: int, duration_hours: float, description: str, billable: bool):
start_time = timezone.now().replace(hour=hour, minute=0, second=0, microsecond=0) - timedelta(days=days_ago)
end_time = start_time + timedelta(hours=duration_hours)
rate = None
currency = DEMO_RATE_CURRENCY
if billable and project:
rate = (
ProjectUserRate.objects.filter(project=project, user=user, is_deleted=False, is_active=True)
.order_by("-effective_from", "-updated_at")
.first()
)
if not rate:
rate = (
WorkspaceUserRate.objects.filter(workspace=workspace, user=user, is_deleted=False)
.order_by("-effective_from", "-updated_at")
.first()
)
if rate:
currency = rate.currency
entry = TimeEntry.objects.create(
workspace=workspace,
user=user,
project=project,
description=description,
start_time=start_time,
end_time=end_time,
duration=end_time - start_time,
is_billable=billable,
hourly_rate=rate.hourly_rate if rate else None,
currency=currency,
is_active=True,
)
entry.tags.set(tags)
return entry
@transaction.atomic
def create_demo_environment():
if not getattr(settings, "DEMO_ENABLED", True):
raise ValidationError({"detail": "Demo environments are currently disabled."})
_ensure_price_units()
expires_at = timezone.now() + timedelta(hours=settings.DEMO_ENVIRONMENT_TTL_HOURS)
owner = _create_demo_user(prefix="70", first_name="Demo", last_name="Owner", expires_at=expires_at)
admin = _create_demo_user(prefix="71", first_name="Nika", last_name="Admin", expires_at=expires_at)
member = _create_demo_user(prefix="72", first_name="Arman", last_name="Member", expires_at=expires_at)
guest = _create_demo_user(prefix="73", first_name="Sara", last_name="Guest", expires_at=expires_at)
workspace = Workspace.objects.create(
name="Qlockify Demo Workspace",
description="A temporary sandbox workspace with seeded data for exploring Qlockify.",
owner=owner,
is_active=True,
)
WorkspaceMembership.objects.bulk_create(
[
WorkspaceMembership(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True),
WorkspaceMembership(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True),
WorkspaceMembership(workspace=workspace, user=guest, role=WorkspaceMembership.Role.GUEST, is_active=True),
]
)
now = timezone.now()
for user, amount in ((owner, "750000"), (admin, "650000"), (member, "520000"), (guest, "350000")):
_create_workspace_rate(workspace=workspace, user=user, amount=amount, effective_from=now - timedelta(days=60))
college = Client.objects.create(workspace=workspace, name="Kanoon College", notes="Education client", is_active=True)
studio = Client.objects.create(workspace=workspace, name="Nova Studio", notes="Design and product client", is_active=True)
internal = Client.objects.create(workspace=workspace, name="Internal Ops", notes="Non-client internal work", is_active=True)
projects = {
"portal": Project.objects.create(workspace=workspace, client=college, name="Student Portal", color="#0891b2", is_active=True),
"bootcamp": Project.objects.create(workspace=workspace, client=college, name="Bootcamp Analytics", color="#14b8a6", is_active=True),
"brand": Project.objects.create(workspace=workspace, client=studio, name="Brand Refresh", color="#f97316", is_active=True),
"ops": Project.objects.create(workspace=workspace, client=internal, name="Operations Automation", color="#6366f1", is_active=True),
"archive": Project.objects.create(workspace=workspace, client=studio, name="Archived Campaign", color="#94a3b8", is_archived=True, is_active=True),
}
tags = {
"design": Tag.objects.create(workspace=workspace, name="Design", color="#f97316", is_active=True),
"backend": Tag.objects.create(workspace=workspace, name="Backend", color="#0ea5e9", is_active=True),
"meeting": Tag.objects.create(workspace=workspace, name="Meeting", color="#8b5cf6", is_active=True),
"qa": Tag.objects.create(workspace=workspace, name="QA", color="#22c55e", is_active=True),
}
for user in (member, guest):
for project in (projects["portal"], projects["bootcamp"], projects["brand"]):
ProjectAccess.objects.create(project=project, user=user, is_active=True)
_create_project_rate(project=projects["brand"], user=owner, amount="950000", effective_from=now - timedelta(days=30))
_create_project_rate(project=projects["portal"], user=member, amount="610000", effective_from=now - timedelta(days=20))
_create_project_rate(project=projects["bootcamp"], user=guest, amount="420000", effective_from=now - timedelta(days=15))
entry_templates = [
(owner, projects["brand"], [tags["design"]], 1, 9, 2.5, "Review landing page motion", True),
(owner, projects["ops"], [tags["backend"], tags["qa"]], 2, 10, 3.0, "Improve export pipeline", True),
(owner, None, [tags["meeting"]], 3, 13, 1.0, "Weekly planning", False),
(admin, projects["portal"], [tags["backend"]], 1, 8, 4.0, "API access checks", True),
(admin, projects["bootcamp"], [tags["qa"]], 4, 11, 2.0, "Report QA pass", True),
(member, projects["portal"], [tags["backend"], tags["qa"]], 2, 9, 5.0, "Timesheet improvements", True),
(member, projects["brand"], [tags["design"]], 6, 14, 2.5, "Design polish", True),
(guest, projects["bootcamp"], [tags["meeting"]], 3, 10, 1.5, "Client sync", True),
(guest, None, [], 5, 15, 1.0, "Uncategorized admin work", False),
]
for entry in entry_templates:
_create_entry(workspace=workspace, user=entry[0], project=entry[1], tags=entry[2], days_ago=entry[3], hour=entry[4], duration_hours=entry[5], description=entry[6], billable=entry[7])
environment = DemoEnvironment.objects.create(
owner_user=owner,
workspace=workspace,
expires_at=expires_at,
seed_version=DEMO_SEED_VERSION,
status=DemoEnvironment.Status.ACTIVE,
is_active=True,
)
tokens = get_tokens_for_user(owner)
return {
**tokens,
"workspace_id": str(workspace.id),
"expires_at": expires_at.isoformat(),
"demo_environment_id": str(environment.id),
}
def cleanup_demo_environment(environment: DemoEnvironment) -> bool:
workspace = environment.workspace
users = list(
User.all_objects.filter(
is_demo=True,
workspace_memberships__workspace=workspace,
).distinct()
)
for job in ReportExportJob.all_objects.filter(workspace=workspace):
if job.file:
job.file.delete(save=False)
for user in users:
RedisNotificationStore.clear_user(str(user.id))
workspace.hard_delete()
for user in users:
user.hard_delete()
return True
def cleanup_expired_demo_environments(*, batch_size: int | None = None) -> int:
batch_size = batch_size or settings.DEMO_CLEANUP_BATCH_SIZE
expired = list(
DemoEnvironment.objects.filter(
status=DemoEnvironment.Status.ACTIVE,
expires_at__lte=timezone.now(),
)
.select_related("workspace", "owner_user")
.order_by("expires_at")[:batch_size]
)
cleaned = 0
for environment in expired:
try:
with transaction.atomic():
cleanup_demo_environment(environment)
cleaned += 1
except Exception as exc: # noqa: BLE001
DemoEnvironment.all_objects.filter(id=environment.id).update(
status=DemoEnvironment.Status.EXPIRED,
cleanup_error=str(exc)[:2000],
updated_at=timezone.now(),
)
return cleaned

8
apps/demos/tasks.py Normal file
View File

@@ -0,0 +1,8 @@
from celery import shared_task
from apps.demos.services import cleanup_expired_demo_environments
@shared_task(name="demos.cleanup_expired_environments")
def cleanup_expired_demo_environments_task():
return cleanup_expired_demo_environments()

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,77 @@
from django.contrib.auth import get_user_model
from django.test import override_settings
from django.utils import timezone
from rest_framework.test import APITestCase
from apps.clients.models import Client
from apps.demos.models import DemoEnvironment
from apps.demos.services import cleanup_expired_demo_environments
from apps.projects.models import Project, ProjectAccess
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.workspaces.models import WorkspaceMembership, WorkspaceUserRate
User = get_user_model()
DEMO_START_URL = "/api/demo/start/"
@override_settings(DEMO_ENABLED=True, DEMO_ENVIRONMENT_TTL_HOURS=24, DEMO_CLEANUP_BATCH_SIZE=100)
class DemoStartApiTests(APITestCase):
def test_demo_start_creates_isolated_seeded_environment(self):
response = self.client.post(DEMO_START_URL)
self.assertEqual(response.status_code, 201)
self.assertIn("access", response.data)
self.assertIn("refresh", response.data)
self.assertEqual(DemoEnvironment.objects.count(), 1)
environment = DemoEnvironment.objects.select_related("owner_user", "workspace").get()
self.assertTrue(environment.owner_user.is_demo)
self.assertEqual(environment.owner_user.demo_expires_at, environment.expires_at)
self.assertGreaterEqual(WorkspaceMembership.objects.filter(workspace=environment.workspace).count(), 4)
self.assertGreaterEqual(Client.objects.filter(workspace=environment.workspace).count(), 3)
self.assertGreaterEqual(Project.objects.filter(workspace=environment.workspace).count(), 5)
self.assertGreaterEqual(Tag.objects.filter(workspace=environment.workspace).count(), 4)
self.assertGreaterEqual(TimeEntry.objects.filter(workspace=environment.workspace).count(), 8)
self.assertGreaterEqual(WorkspaceUserRate.objects.filter(workspace=environment.workspace).count(), 4)
self.assertGreaterEqual(ProjectAccess.objects.filter(project__workspace=environment.workspace).count(), 1)
def test_two_demo_starts_do_not_share_workspace_data(self):
first = self.client.post(DEMO_START_URL)
second = self.client.post(DEMO_START_URL)
self.assertEqual(first.status_code, 201)
self.assertEqual(second.status_code, 201)
environments = list(DemoEnvironment.objects.order_by("created_at"))
self.assertEqual(len(environments), 2)
self.assertNotEqual(environments[0].workspace_id, environments[1].workspace_id)
self.assertNotEqual(environments[0].owner_user_id, environments[1].owner_user_id)
def test_demo_user_cannot_search_external_users_or_send_otp(self):
self.client.post(DEMO_START_URL)
environment = DemoEnvironment.objects.select_related("owner_user").get()
real_user = User.objects.create_user(mobile="09111111111", password="Testpass123!")
self.client.force_authenticate(environment.owner_user)
search_response = self.client.get(f"/api/users/search/?mobile={real_user.mobile}")
self.assertEqual(search_response.status_code, 403)
otp_response = self.client.post(
"/api/users/otp/send/",
{"mobile": environment.owner_user.mobile, "mode": "login"},
format="json",
)
self.assertEqual(otp_response.status_code, 400)
def test_cleanup_deletes_expired_demo_and_keeps_real_users(self):
self.client.post(DEMO_START_URL)
environment = DemoEnvironment.objects.select_related("workspace").get()
real_user = User.objects.create_user(mobile="09122222222", password="Testpass123!")
DemoEnvironment.objects.filter(id=environment.id).update(expires_at=timezone.now() - timezone.timedelta(minutes=1))
cleaned = cleanup_expired_demo_environments()
self.assertEqual(cleaned, 1)
self.assertFalse(DemoEnvironment.all_objects.filter(id=environment.id).exists())
self.assertFalse(TimeEntry.all_objects.filter(workspace_id=environment.workspace_id).exists())
self.assertTrue(User.objects.filter(id=real_user.id).exists())

View File

@@ -205,6 +205,18 @@ class RedisNotificationStore:
return True return True
return False return False
@classmethod
def clear_user(cls, user_id: str) -> int:
ids_key = cls._ids_key(user_id)
data_key = cls._data_key(user_id)
count = redis_client.zcard(ids_key)
pipe = redis_client.pipeline()
pipe.delete(ids_key)
pipe.delete(data_key)
pipe.srem(cls.USERS_KEY, user_id)
pipe.execute()
return int(count or 0)
@classmethod @classmethod
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None: def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
data = cls.get(user_id, notif_id) data = cls.get(user_id, notif_id)

View File

@@ -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([])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0003_normalize_user_email_identity"),
]
operations = [
migrations.AddField(
model_name="user",
name="demo_expires_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="user",
name="is_demo",
field=models.BooleanField(default=False),
),
migrations.AddIndex(
model_name="user",
index=models.Index(fields=["is_demo", "demo_expires_at"], name="user_demo_expires_idx"),
),
]

View File

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

View File

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

View File

@@ -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": [f"Import is limited to {MEMBER_IMPORT_MAX_ROWS} 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 is required.")
elif mobile in seen_mobiles:
messages.append("This mobile appears more than once in the import file.")
else:
seen_mobiles.add(mobile)
user = User.objects.filter(mobile=mobile).first()
if not user:
messages.append("No user exists with this mobile number.")
elif str(user.id) in existing_memberships:
messages.append("This user is already a member of the workspace.")
if role == WorkspaceMembership.Role.OWNER:
messages.append("Owner role cannot be imported.")
elif role not in allowed_roles:
messages.append("Role must be admin, member, or guest.")
elif not can_assign_workspace_role(request.user, workspace, role):
messages.append("You do not have permission to assign this role.")
has_rate = bool(hourly_rate_raw)
has_currency = bool(currency)
if has_rate != has_currency:
messages.append("Hourly rate and currency must be provided together.")
elif has_rate and has_currency:
try:
parsed_rate = Decimal(hourly_rate_raw.replace(",", ""))
if parsed_rate <= Decimal("0"):
messages.append("Hourly rate must be greater than zero.")
else:
normalized_rate = f"{parsed_rate:.2f}"
except (InvalidOperation, ValueError):
messages.append("Hourly rate must be a valid number.")
if not PriceUnit.objects.filter(code=currency, is_deleted=False).exists():
messages.append("Currency is 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):

View File

@@ -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("permission", response.data["rows"][0]["messages"][0].lower())
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(

View File

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

View File

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