274 lines
11 KiB
Python
274 lines
11 KiB
Python
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
|