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