From 30a324c6f4b0ca81cbf52598493ff84b00b46cf4 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 7 Jun 2026 00:49:58 +0330 Subject: [PATCH] feat(demo): add isolated demo environments --- apps/demos/__init__.py | 1 + apps/demos/api/__init__.py | 1 + apps/demos/api/throttles.py | 5 + apps/demos/api/urls.py | 9 + apps/demos/api/views.py | 29 ++ apps/demos/apps.py | 6 + apps/demos/management/__init__.py | 1 + apps/demos/management/commands/__init__.py | 1 + .../commands/cleanup_demo_environments.py | 18 ++ apps/demos/migrations/0001_initial.py | 97 +++++++ ...ronment_demoenvironment_id_idx_and_more.py | 30 ++ apps/demos/migrations/__init__.py | 1 + apps/demos/models.py | 39 +++ apps/demos/services.py | 273 ++++++++++++++++++ apps/demos/tasks.py | 8 + apps/demos/tests/__init__.py | 1 + apps/demos/tests/test_demo_api.py | 77 +++++ apps/notifications/services/store.py | 12 + .../users/migrations/0004_user_demo_fields.py | 25 ++ apps/users/models.py | 3 + config/settings/base.py | 19 +- config/urls.py | 1 + 22 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 apps/demos/__init__.py create mode 100644 apps/demos/api/__init__.py create mode 100644 apps/demos/api/throttles.py create mode 100644 apps/demos/api/urls.py create mode 100644 apps/demos/api/views.py create mode 100644 apps/demos/apps.py create mode 100644 apps/demos/management/__init__.py create mode 100644 apps/demos/management/commands/__init__.py create mode 100644 apps/demos/management/commands/cleanup_demo_environments.py create mode 100644 apps/demos/migrations/0001_initial.py create mode 100644 apps/demos/migrations/0002_remove_demoenvironment_demoenvironment_id_idx_and_more.py create mode 100644 apps/demos/migrations/__init__.py create mode 100644 apps/demos/models.py create mode 100644 apps/demos/services.py create mode 100644 apps/demos/tasks.py create mode 100644 apps/demos/tests/__init__.py create mode 100644 apps/demos/tests/test_demo_api.py create mode 100644 apps/users/migrations/0004_user_demo_fields.py diff --git a/apps/demos/__init__.py b/apps/demos/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/demos/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/demos/api/__init__.py b/apps/demos/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/demos/api/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/demos/api/throttles.py b/apps/demos/api/throttles.py new file mode 100644 index 0000000..be4037e --- /dev/null +++ b/apps/demos/api/throttles.py @@ -0,0 +1,5 @@ +from rest_framework.throttling import AnonRateThrottle + + +class DemoStartThrottle(AnonRateThrottle): + scope = "demo_start" diff --git a/apps/demos/api/urls.py b/apps/demos/api/urls.py new file mode 100644 index 0000000..ac2f8f8 --- /dev/null +++ b/apps/demos/api/urls.py @@ -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"), +] diff --git a/apps/demos/api/views.py b/apps/demos/api/views.py new file mode 100644 index 0000000..de3b83e --- /dev/null +++ b/apps/demos/api/views.py @@ -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) diff --git a/apps/demos/apps.py b/apps/demos/apps.py new file mode 100644 index 0000000..fd02aab --- /dev/null +++ b/apps/demos/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DemosConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.demos" diff --git a/apps/demos/management/__init__.py b/apps/demos/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/demos/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/demos/management/commands/__init__.py b/apps/demos/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/demos/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/demos/management/commands/cleanup_demo_environments.py b/apps/demos/management/commands/cleanup_demo_environments.py new file mode 100644 index 0000000..34321ff --- /dev/null +++ b/apps/demos/management/commands/cleanup_demo_environments.py @@ -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).")) diff --git a/apps/demos/migrations/0001_initial.py b/apps/demos/migrations/0001_initial.py new file mode 100644 index 0000000..72bd48e --- /dev/null +++ b/apps/demos/migrations/0001_initial.py @@ -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"), + ), + ] diff --git a/apps/demos/migrations/0002_remove_demoenvironment_demoenvironment_id_idx_and_more.py b/apps/demos/migrations/0002_remove_demoenvironment_demoenvironment_id_idx_and_more.py new file mode 100644 index 0000000..3cad0fa --- /dev/null +++ b/apps/demos/migrations/0002_remove_demoenvironment_demoenvironment_id_idx_and_more.py @@ -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), + ), + ] diff --git a/apps/demos/migrations/__init__.py b/apps/demos/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/demos/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/demos/models.py b/apps/demos/models.py new file mode 100644 index 0000000..b7fe9e4 --- /dev/null +++ b/apps/demos/models.py @@ -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}" diff --git a/apps/demos/services.py b/apps/demos/services.py new file mode 100644 index 0000000..268e56c --- /dev/null +++ b/apps/demos/services.py @@ -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 diff --git a/apps/demos/tasks.py b/apps/demos/tasks.py new file mode 100644 index 0000000..9a566d6 --- /dev/null +++ b/apps/demos/tasks.py @@ -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() diff --git a/apps/demos/tests/__init__.py b/apps/demos/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/demos/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/demos/tests/test_demo_api.py b/apps/demos/tests/test_demo_api.py new file mode 100644 index 0000000..47ecad0 --- /dev/null +++ b/apps/demos/tests/test_demo_api.py @@ -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()) diff --git a/apps/notifications/services/store.py b/apps/notifications/services/store.py index 8b975bb..c6721b5 100644 --- a/apps/notifications/services/store.py +++ b/apps/notifications/services/store.py @@ -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) diff --git a/apps/users/migrations/0004_user_demo_fields.py b/apps/users/migrations/0004_user_demo_fields.py new file mode 100644 index 0000000..3c1048f --- /dev/null +++ b/apps/users/migrations/0004_user_demo_fields.py @@ -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"), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index e0da956..b5a1ff6 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -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"), ) diff --git a/config/settings/base.py b/config/settings/base.py index 66a0aae..fb737e5 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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 = { diff --git a/config/urls.py b/config/urls.py index 3ee9003..c395570 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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: