feat(demo): add isolated demo environments
This commit is contained in:
1
apps/demos/__init__.py
Normal file
1
apps/demos/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/demos/api/__init__.py
Normal file
1
apps/demos/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
5
apps/demos/api/throttles.py
Normal file
5
apps/demos/api/throttles.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
|
||||
|
||||
class DemoStartThrottle(AnonRateThrottle):
|
||||
scope = "demo_start"
|
||||
9
apps/demos/api/urls.py
Normal file
9
apps/demos/api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from apps.demos.api.views import DemoStartView
|
||||
|
||||
app_name = "demos"
|
||||
|
||||
urlpatterns = [
|
||||
path("start/", DemoStartView.as_view(), name="demo-start"),
|
||||
]
|
||||
29
apps/demos/api/views.py
Normal file
29
apps/demos/api/views.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.demos.api.throttles import DemoStartThrottle
|
||||
from apps.demos.services import create_demo_environment
|
||||
|
||||
|
||||
class DemoStartView(APIView):
|
||||
permission_classes = (AllowAny,)
|
||||
throttle_classes = (DemoStartThrottle,)
|
||||
|
||||
@extend_schema(
|
||||
request=None,
|
||||
responses=inline_serializer(
|
||||
name="DemoStartResponse",
|
||||
fields={
|
||||
"access": serializers.CharField(),
|
||||
"refresh": serializers.CharField(),
|
||||
"workspace_id": serializers.CharField(),
|
||||
"expires_at": serializers.DateTimeField(),
|
||||
"demo_environment_id": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
)
|
||||
def post(self, request):
|
||||
return Response(create_demo_environment(), status=status.HTTP_201_CREATED)
|
||||
6
apps/demos/apps.py
Normal file
6
apps/demos/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DemosConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.demos"
|
||||
1
apps/demos/management/__init__.py
Normal file
1
apps/demos/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/demos/management/commands/__init__.py
Normal file
1
apps/demos/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
18
apps/demos/management/commands/cleanup_demo_environments.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.demos.services import cleanup_expired_demo_environments
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Clean up expired isolated demo environments."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--expired", action="store_true", help="Clean expired demo environments.")
|
||||
parser.add_argument("--batch-size", type=int, default=None, help="Maximum number of environments to clean.")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if not options["expired"]:
|
||||
self.stderr.write("Only --expired cleanup is supported.")
|
||||
return
|
||||
cleaned = cleanup_expired_demo_environments(batch_size=options["batch_size"])
|
||||
self.stdout.write(self.style.SUCCESS(f"Cleaned {cleaned} expired demo environment(s)."))
|
||||
97
apps/demos/migrations/0001_initial.py
Normal file
97
apps/demos/migrations/0001_initial.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("workspaces", "0008_hourlyratehistory"),
|
||||
("users", "0004_user_demo_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DemoEnvironment",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("deleted_at", models.DateTimeField(blank=True, null=True)),
|
||||
("is_deleted", models.BooleanField(default=False)),
|
||||
("is_active", models.BooleanField(default=False)),
|
||||
("expires_at", models.DateTimeField()),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[("active", "Active"), ("expired", "Expired"), ("cleaned", "Cleaned")],
|
||||
default="active",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("seed_version", models.CharField(default="v1", max_length=32)),
|
||||
("cleaned_at", models.DateTimeField(blank=True, null=True)),
|
||||
("cleanup_error", models.TextField(blank=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_demos_demoenvironment_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"owner_user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="demo_environment",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="updated_demos_demoenvironment_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="demo_environment",
|
||||
to="workspaces.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "demo_environment",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="demoenvironment",
|
||||
index=models.Index(fields=["id"], name="demoenvironment_id_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="demoenvironment",
|
||||
index=models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="demoenvironment",
|
||||
index=models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="demoenvironment",
|
||||
index=models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.12 on 2026-06-06 21:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('demos', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='demoenvironment',
|
||||
name='demoenvironment_id_idx',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='demoenvironment',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='demoenvironment',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
1
apps/demos/migrations/__init__.py
Normal file
1
apps/demos/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
39
apps/demos/models.py
Normal file
39
apps/demos/models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from core.models.base import BaseModel
|
||||
|
||||
|
||||
class DemoEnvironment(BaseModel):
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "Active"
|
||||
EXPIRED = "expired", "Expired"
|
||||
CLEANED = "cleaned", "Cleaned"
|
||||
|
||||
owner_user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="demo_environment",
|
||||
)
|
||||
workspace = models.OneToOneField(
|
||||
"workspaces.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="demo_environment",
|
||||
)
|
||||
expires_at = models.DateTimeField()
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.ACTIVE)
|
||||
seed_version = models.CharField(max_length=32, default="v1")
|
||||
cleaned_at = models.DateTimeField(blank=True, null=True)
|
||||
cleanup_error = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "demo_environment"
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["status", "expires_at"], name="demo_status_expires_idx"),
|
||||
models.Index(fields=["owner_user"], name="demo_owner_user_idx"),
|
||||
models.Index(fields=["workspace"], name="demo_workspace_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Demo {self.workspace_id} for {self.owner_user_id}"
|
||||
273
apps/demos/services.py
Normal file
273
apps/demos/services.py
Normal file
@@ -0,0 +1,273 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import string
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from apps.clients.models import Client
|
||||
from apps.demos.models import DemoEnvironment
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.projects.models import Project, ProjectAccess, ProjectUserRate
|
||||
from apps.reports.models import ReportExportJob
|
||||
from apps.tags.models import Tag
|
||||
from apps.time_entries.models import TimeEntry
|
||||
from apps.users.services.auth import get_tokens_for_user
|
||||
from apps.workspaces.models import HourlyRateHistory, PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
DEMO_SEED_VERSION = "v1"
|
||||
DEMO_RATE_CURRENCY = "IRT"
|
||||
|
||||
|
||||
def _unique_mobile(prefix: str) -> str:
|
||||
for _ in range(50):
|
||||
mobile = f"09{prefix}{''.join(random.choices(string.digits, k=7))}"
|
||||
if not User.all_objects.filter(mobile=mobile).exists():
|
||||
return mobile
|
||||
raise ValidationError({"detail": "Could not allocate a unique demo mobile number."})
|
||||
|
||||
|
||||
def _create_demo_user(*, prefix: str, first_name: str, last_name: str, expires_at):
|
||||
mobile = _unique_mobile(prefix)
|
||||
user = User.objects.create_user(
|
||||
mobile=mobile,
|
||||
password=None,
|
||||
email=f"demo-{mobile}@demo.qlockify.local",
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
is_demo=True,
|
||||
demo_expires_at=expires_at,
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def _ensure_price_units() -> None:
|
||||
PriceUnit.get_or_restore(
|
||||
code="IRT",
|
||||
defaults={"name": "Iranian Toman", "local_name": "تومان", "symbol": "تومان", "is_active": True},
|
||||
)
|
||||
|
||||
|
||||
def _create_workspace_rate(*, workspace, user, amount: str, effective_from):
|
||||
rate = WorkspaceUserRate.objects.create(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
hourly_rate=Decimal(amount),
|
||||
currency=DEMO_RATE_CURRENCY,
|
||||
effective_from=effective_from,
|
||||
is_active=True,
|
||||
)
|
||||
HourlyRateHistory.objects.create(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
scope=HourlyRateHistory.Scope.WORKSPACE,
|
||||
hourly_rate=rate.hourly_rate,
|
||||
currency=rate.currency,
|
||||
effective_from=effective_from,
|
||||
is_active=True,
|
||||
)
|
||||
return rate
|
||||
|
||||
|
||||
def _create_project_rate(*, project, user, amount: str, effective_from):
|
||||
rate = ProjectUserRate.objects.create(
|
||||
project=project,
|
||||
user=user,
|
||||
hourly_rate=Decimal(amount),
|
||||
currency=DEMO_RATE_CURRENCY,
|
||||
effective_from=effective_from,
|
||||
is_active=True,
|
||||
)
|
||||
HourlyRateHistory.objects.create(
|
||||
workspace=project.workspace,
|
||||
project=project,
|
||||
user=user,
|
||||
scope=HourlyRateHistory.Scope.PROJECT,
|
||||
hourly_rate=rate.hourly_rate,
|
||||
currency=rate.currency,
|
||||
effective_from=effective_from,
|
||||
is_active=True,
|
||||
)
|
||||
return rate
|
||||
|
||||
|
||||
def _create_entry(*, workspace, user, project, tags, days_ago: int, hour: int, duration_hours: float, description: str, billable: bool):
|
||||
start_time = timezone.now().replace(hour=hour, minute=0, second=0, microsecond=0) - timedelta(days=days_ago)
|
||||
end_time = start_time + timedelta(hours=duration_hours)
|
||||
rate = None
|
||||
currency = DEMO_RATE_CURRENCY
|
||||
if billable and project:
|
||||
rate = (
|
||||
ProjectUserRate.objects.filter(project=project, user=user, is_deleted=False, is_active=True)
|
||||
.order_by("-effective_from", "-updated_at")
|
||||
.first()
|
||||
)
|
||||
if not rate:
|
||||
rate = (
|
||||
WorkspaceUserRate.objects.filter(workspace=workspace, user=user, is_deleted=False)
|
||||
.order_by("-effective_from", "-updated_at")
|
||||
.first()
|
||||
)
|
||||
if rate:
|
||||
currency = rate.currency
|
||||
|
||||
entry = TimeEntry.objects.create(
|
||||
workspace=workspace,
|
||||
user=user,
|
||||
project=project,
|
||||
description=description,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
duration=end_time - start_time,
|
||||
is_billable=billable,
|
||||
hourly_rate=rate.hourly_rate if rate else None,
|
||||
currency=currency,
|
||||
is_active=True,
|
||||
)
|
||||
entry.tags.set(tags)
|
||||
return entry
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_demo_environment():
|
||||
if not getattr(settings, "DEMO_ENABLED", True):
|
||||
raise ValidationError({"detail": "Demo environments are currently disabled."})
|
||||
|
||||
_ensure_price_units()
|
||||
expires_at = timezone.now() + timedelta(hours=settings.DEMO_ENVIRONMENT_TTL_HOURS)
|
||||
owner = _create_demo_user(prefix="70", first_name="Demo", last_name="Owner", expires_at=expires_at)
|
||||
admin = _create_demo_user(prefix="71", first_name="Nika", last_name="Admin", expires_at=expires_at)
|
||||
member = _create_demo_user(prefix="72", first_name="Arman", last_name="Member", expires_at=expires_at)
|
||||
guest = _create_demo_user(prefix="73", first_name="Sara", last_name="Guest", expires_at=expires_at)
|
||||
|
||||
workspace = Workspace.objects.create(
|
||||
name="Qlockify Demo Workspace",
|
||||
description="A temporary sandbox workspace with seeded data for exploring Qlockify.",
|
||||
owner=owner,
|
||||
is_active=True,
|
||||
)
|
||||
WorkspaceMembership.objects.bulk_create(
|
||||
[
|
||||
WorkspaceMembership(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True),
|
||||
WorkspaceMembership(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True),
|
||||
WorkspaceMembership(workspace=workspace, user=guest, role=WorkspaceMembership.Role.GUEST, is_active=True),
|
||||
]
|
||||
)
|
||||
|
||||
now = timezone.now()
|
||||
for user, amount in ((owner, "750000"), (admin, "650000"), (member, "520000"), (guest, "350000")):
|
||||
_create_workspace_rate(workspace=workspace, user=user, amount=amount, effective_from=now - timedelta(days=60))
|
||||
|
||||
college = Client.objects.create(workspace=workspace, name="Kanoon College", notes="Education client", is_active=True)
|
||||
studio = Client.objects.create(workspace=workspace, name="Nova Studio", notes="Design and product client", is_active=True)
|
||||
internal = Client.objects.create(workspace=workspace, name="Internal Ops", notes="Non-client internal work", is_active=True)
|
||||
|
||||
projects = {
|
||||
"portal": Project.objects.create(workspace=workspace, client=college, name="Student Portal", color="#0891b2", is_active=True),
|
||||
"bootcamp": Project.objects.create(workspace=workspace, client=college, name="Bootcamp Analytics", color="#14b8a6", is_active=True),
|
||||
"brand": Project.objects.create(workspace=workspace, client=studio, name="Brand Refresh", color="#f97316", is_active=True),
|
||||
"ops": Project.objects.create(workspace=workspace, client=internal, name="Operations Automation", color="#6366f1", is_active=True),
|
||||
"archive": Project.objects.create(workspace=workspace, client=studio, name="Archived Campaign", color="#94a3b8", is_archived=True, is_active=True),
|
||||
}
|
||||
|
||||
tags = {
|
||||
"design": Tag.objects.create(workspace=workspace, name="Design", color="#f97316", is_active=True),
|
||||
"backend": Tag.objects.create(workspace=workspace, name="Backend", color="#0ea5e9", is_active=True),
|
||||
"meeting": Tag.objects.create(workspace=workspace, name="Meeting", color="#8b5cf6", is_active=True),
|
||||
"qa": Tag.objects.create(workspace=workspace, name="QA", color="#22c55e", is_active=True),
|
||||
}
|
||||
|
||||
for user in (member, guest):
|
||||
for project in (projects["portal"], projects["bootcamp"], projects["brand"]):
|
||||
ProjectAccess.objects.create(project=project, user=user, is_active=True)
|
||||
|
||||
_create_project_rate(project=projects["brand"], user=owner, amount="950000", effective_from=now - timedelta(days=30))
|
||||
_create_project_rate(project=projects["portal"], user=member, amount="610000", effective_from=now - timedelta(days=20))
|
||||
_create_project_rate(project=projects["bootcamp"], user=guest, amount="420000", effective_from=now - timedelta(days=15))
|
||||
|
||||
entry_templates = [
|
||||
(owner, projects["brand"], [tags["design"]], 1, 9, 2.5, "Review landing page motion", True),
|
||||
(owner, projects["ops"], [tags["backend"], tags["qa"]], 2, 10, 3.0, "Improve export pipeline", True),
|
||||
(owner, None, [tags["meeting"]], 3, 13, 1.0, "Weekly planning", False),
|
||||
(admin, projects["portal"], [tags["backend"]], 1, 8, 4.0, "API access checks", True),
|
||||
(admin, projects["bootcamp"], [tags["qa"]], 4, 11, 2.0, "Report QA pass", True),
|
||||
(member, projects["portal"], [tags["backend"], tags["qa"]], 2, 9, 5.0, "Timesheet improvements", True),
|
||||
(member, projects["brand"], [tags["design"]], 6, 14, 2.5, "Design polish", True),
|
||||
(guest, projects["bootcamp"], [tags["meeting"]], 3, 10, 1.5, "Client sync", True),
|
||||
(guest, None, [], 5, 15, 1.0, "Uncategorized admin work", False),
|
||||
]
|
||||
for entry in entry_templates:
|
||||
_create_entry(workspace=workspace, user=entry[0], project=entry[1], tags=entry[2], days_ago=entry[3], hour=entry[4], duration_hours=entry[5], description=entry[6], billable=entry[7])
|
||||
|
||||
environment = DemoEnvironment.objects.create(
|
||||
owner_user=owner,
|
||||
workspace=workspace,
|
||||
expires_at=expires_at,
|
||||
seed_version=DEMO_SEED_VERSION,
|
||||
status=DemoEnvironment.Status.ACTIVE,
|
||||
is_active=True,
|
||||
)
|
||||
tokens = get_tokens_for_user(owner)
|
||||
return {
|
||||
**tokens,
|
||||
"workspace_id": str(workspace.id),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"demo_environment_id": str(environment.id),
|
||||
}
|
||||
|
||||
|
||||
def cleanup_demo_environment(environment: DemoEnvironment) -> bool:
|
||||
workspace = environment.workspace
|
||||
users = list(
|
||||
User.all_objects.filter(
|
||||
is_demo=True,
|
||||
workspace_memberships__workspace=workspace,
|
||||
).distinct()
|
||||
)
|
||||
|
||||
for job in ReportExportJob.all_objects.filter(workspace=workspace):
|
||||
if job.file:
|
||||
job.file.delete(save=False)
|
||||
|
||||
for user in users:
|
||||
RedisNotificationStore.clear_user(str(user.id))
|
||||
|
||||
workspace.hard_delete()
|
||||
for user in users:
|
||||
user.hard_delete()
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_expired_demo_environments(*, batch_size: int | None = None) -> int:
|
||||
batch_size = batch_size or settings.DEMO_CLEANUP_BATCH_SIZE
|
||||
expired = list(
|
||||
DemoEnvironment.objects.filter(
|
||||
status=DemoEnvironment.Status.ACTIVE,
|
||||
expires_at__lte=timezone.now(),
|
||||
)
|
||||
.select_related("workspace", "owner_user")
|
||||
.order_by("expires_at")[:batch_size]
|
||||
)
|
||||
cleaned = 0
|
||||
for environment in expired:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
cleanup_demo_environment(environment)
|
||||
cleaned += 1
|
||||
except Exception as exc: # noqa: BLE001
|
||||
DemoEnvironment.all_objects.filter(id=environment.id).update(
|
||||
status=DemoEnvironment.Status.EXPIRED,
|
||||
cleanup_error=str(exc)[:2000],
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
return cleaned
|
||||
8
apps/demos/tasks.py
Normal file
8
apps/demos/tasks.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from celery import shared_task
|
||||
|
||||
from apps.demos.services import cleanup_expired_demo_environments
|
||||
|
||||
|
||||
@shared_task(name="demos.cleanup_expired_environments")
|
||||
def cleanup_expired_demo_environments_task():
|
||||
return cleanup_expired_demo_environments()
|
||||
1
apps/demos/tests/__init__.py
Normal file
1
apps/demos/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
77
apps/demos/tests/test_demo_api.py
Normal file
77
apps/demos/tests/test_demo_api.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from apps.clients.models import Client
|
||||
from apps.demos.models import DemoEnvironment
|
||||
from apps.demos.services import cleanup_expired_demo_environments
|
||||
from apps.projects.models import Project, ProjectAccess
|
||||
from apps.tags.models import Tag
|
||||
from apps.time_entries.models import TimeEntry
|
||||
from apps.workspaces.models import WorkspaceMembership, WorkspaceUserRate
|
||||
|
||||
User = get_user_model()
|
||||
DEMO_START_URL = "/api/demo/start/"
|
||||
|
||||
|
||||
@override_settings(DEMO_ENABLED=True, DEMO_ENVIRONMENT_TTL_HOURS=24, DEMO_CLEANUP_BATCH_SIZE=100)
|
||||
class DemoStartApiTests(APITestCase):
|
||||
def test_demo_start_creates_isolated_seeded_environment(self):
|
||||
response = self.client.post(DEMO_START_URL)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertIn("access", response.data)
|
||||
self.assertIn("refresh", response.data)
|
||||
self.assertEqual(DemoEnvironment.objects.count(), 1)
|
||||
|
||||
environment = DemoEnvironment.objects.select_related("owner_user", "workspace").get()
|
||||
self.assertTrue(environment.owner_user.is_demo)
|
||||
self.assertEqual(environment.owner_user.demo_expires_at, environment.expires_at)
|
||||
self.assertGreaterEqual(WorkspaceMembership.objects.filter(workspace=environment.workspace).count(), 4)
|
||||
self.assertGreaterEqual(Client.objects.filter(workspace=environment.workspace).count(), 3)
|
||||
self.assertGreaterEqual(Project.objects.filter(workspace=environment.workspace).count(), 5)
|
||||
self.assertGreaterEqual(Tag.objects.filter(workspace=environment.workspace).count(), 4)
|
||||
self.assertGreaterEqual(TimeEntry.objects.filter(workspace=environment.workspace).count(), 8)
|
||||
self.assertGreaterEqual(WorkspaceUserRate.objects.filter(workspace=environment.workspace).count(), 4)
|
||||
self.assertGreaterEqual(ProjectAccess.objects.filter(project__workspace=environment.workspace).count(), 1)
|
||||
|
||||
def test_two_demo_starts_do_not_share_workspace_data(self):
|
||||
first = self.client.post(DEMO_START_URL)
|
||||
second = self.client.post(DEMO_START_URL)
|
||||
|
||||
self.assertEqual(first.status_code, 201)
|
||||
self.assertEqual(second.status_code, 201)
|
||||
environments = list(DemoEnvironment.objects.order_by("created_at"))
|
||||
self.assertEqual(len(environments), 2)
|
||||
self.assertNotEqual(environments[0].workspace_id, environments[1].workspace_id)
|
||||
self.assertNotEqual(environments[0].owner_user_id, environments[1].owner_user_id)
|
||||
|
||||
def test_demo_user_cannot_search_external_users_or_send_otp(self):
|
||||
self.client.post(DEMO_START_URL)
|
||||
environment = DemoEnvironment.objects.select_related("owner_user").get()
|
||||
real_user = User.objects.create_user(mobile="09111111111", password="Testpass123!")
|
||||
self.client.force_authenticate(environment.owner_user)
|
||||
|
||||
search_response = self.client.get(f"/api/users/search/?mobile={real_user.mobile}")
|
||||
self.assertEqual(search_response.status_code, 403)
|
||||
|
||||
otp_response = self.client.post(
|
||||
"/api/users/otp/send/",
|
||||
{"mobile": environment.owner_user.mobile, "mode": "login"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(otp_response.status_code, 400)
|
||||
|
||||
def test_cleanup_deletes_expired_demo_and_keeps_real_users(self):
|
||||
self.client.post(DEMO_START_URL)
|
||||
environment = DemoEnvironment.objects.select_related("workspace").get()
|
||||
real_user = User.objects.create_user(mobile="09122222222", password="Testpass123!")
|
||||
DemoEnvironment.objects.filter(id=environment.id).update(expires_at=timezone.now() - timezone.timedelta(minutes=1))
|
||||
|
||||
cleaned = cleanup_expired_demo_environments()
|
||||
|
||||
self.assertEqual(cleaned, 1)
|
||||
self.assertFalse(DemoEnvironment.all_objects.filter(id=environment.id).exists())
|
||||
self.assertFalse(TimeEntry.all_objects.filter(workspace_id=environment.workspace_id).exists())
|
||||
self.assertTrue(User.objects.filter(id=real_user.id).exists())
|
||||
@@ -205,6 +205,18 @@ class RedisNotificationStore:
|
||||
return True
|
||||
return 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)
|
||||
|
||||
25
apps/users/migrations/0004_user_demo_fields.py
Normal file
25
apps/users/migrations/0004_user_demo_fields.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0003_normalize_user_email_identity"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="demo_expires_at",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_demo",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["is_demo", "demo_expires_at"], name="user_demo_expires_idx"),
|
||||
),
|
||||
]
|
||||
@@ -22,6 +22,8 @@ class User(AbstractUser, BaseModel):
|
||||
|
||||
password_updated_at = models.DateTimeField(blank=True, null=True)
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user