Compare commits

...

2 Commits

Author SHA1 Message Date
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
26 changed files with 703 additions and 4 deletions

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

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

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

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

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

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

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

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

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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

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

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

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

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

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

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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