"""System-owned progress for the secret skill ``Исполнительность``.""" from __future__ import annotations from datetime import date, timedelta from django.db import transaction from django.db.models import Exists, F, OuterRef from django.utils import timezone from employees.models import Employee, EmployeeSkillProgress, Skill from employees.services.secret_skill_settings import get_secret_skills_start_date from employees.services.secret_skills import ( RANK_CODES, RANK_INDEX, TASK_SECRET_SKILL_DEFINITIONS, ) from tasks.models import Task, TaskExecutionCycle PROTECTION_DAYS = 7 OVERDUE_GRACE_BUSINESS_DAYS = 3 VIOLATION_WINDOW_DAYS = 60 def _local_date(value) -> date: if timezone.is_aware(value): return timezone.localtime(value).date() return value.date() def _add_business_days(value: date, amount: int) -> date: current = value added = 0 while added < amount: current += timedelta(days=1) if current.weekday() < 5: added += 1 return current def _cycle_key(task_id: int, completed_at) -> str: return f"task:{task_id}:completed:{completed_at.isoformat()}" @transaction.atomic def capture_task_execution_cycle( task_id: int, *, since: date | None = None, ) -> TaskExecutionCycle | None: """Create the immutable completion snapshot if the task is currently done.""" task = ( Task.objects.select_related("assignee") .filter(pk=task_id, completed_at__isnull=False) .first() ) if task is None: return None completed_at = task.completed_at protection_until = completed_at + timedelta(days=PROTECTION_DAYS) is_self_assigned = bool(task.assignee_id and task.assignee_id == task.created_by_id) exclusion_reason = "" if task.assignee_id is None: exclusion_reason = "missing_assignee" elif task.due_date is None: exclusion_reason = "missing_due_date" elif is_self_assigned: exclusion_reason = "self_assigned" elif not task.assignee.is_active: exclusion_reason = "inactive_assignee" if exclusion_reason: status = TaskExecutionCycle.Status.EXCLUDED elif _local_date(completed_at) > task.due_date: status = TaskExecutionCycle.Status.LATE elif protection_until <= timezone.now(): status = TaskExecutionCycle.Status.CONFIRMED else: status = TaskExecutionCycle.Status.PENDING cycle, _ = TaskExecutionCycle.objects.get_or_create( cycle_key=_cycle_key(task.id, completed_at), defaults={ "task": task, "task_id_snapshot": task.id, "employee_id": task.assignee_id, "task_title": task.title, "due_date_snapshot": task.due_date, "completed_at_snapshot": completed_at, "protection_until": protection_until, "status": status, "is_self_assigned": is_self_assigned, "exclusion_reason": exclusion_reason, }, ) if cycle.employee_id: recalculate_task_execution_skill(cycle.employee_id, since=since) return cycle @transaction.atomic def reopen_task_execution_cycle( task_id: int, completed_at, ) -> TaskExecutionCycle | None: """Turn the matching positive/pending completion into one violation.""" if completed_at is None: return None cycle = ( TaskExecutionCycle.objects.select_for_update() .filter(cycle_key=_cycle_key(task_id, completed_at)) .first() ) if cycle is None or cycle.status == TaskExecutionCycle.Status.EXCLUDED: return cycle cycle.status = TaskExecutionCycle.Status.REOPENED cycle.reopened_at = timezone.now() cycle.save(update_fields=["status", "reopened_at", "updated_at"]) if cycle.employee_id: recalculate_task_execution_skill(cycle.employee_id) return cycle def _open_overdue_events( employee_id: int, today: date, *, since: date | None = None, ): tasks = ( Task.objects.filter( assignee_id=employee_id, completed_at__isnull=True, due_date__isnull=False, ) .exclude(created_by_id=F("assignee_id")) .prefetch_related("execution_cycles") ) events = [] for task in tasks: violation_date = _add_business_days( task.due_date, OVERDUE_GRACE_BUSINESS_DAYS, ) if violation_date > today: continue if since is not None and violation_date < since: continue latest_cycle = max( task.execution_cycles.all(), key=lambda item: (item.completed_at_snapshot, item.id), default=None, ) if ( latest_cycle and latest_cycle.due_date_snapshot == task.due_date and latest_cycle.status in { TaskExecutionCycle.Status.LATE, TaskExecutionCycle.Status.REOPENED, } ): continue events.append((violation_date, "overdue", task.id)) return events def _cycle_event(cycle: TaskExecutionCycle) -> tuple[date, str, int] | None: if cycle.status == TaskExecutionCycle.Status.CONFIRMED: return _local_date(cycle.completed_at_snapshot), "confirmed", cycle.id if cycle.status == TaskExecutionCycle.Status.LATE: return _local_date(cycle.completed_at_snapshot), "late", cycle.id if cycle.status == TaskExecutionCycle.Status.REOPENED: violation_at = cycle.reopened_at or cycle.completed_at_snapshot return _local_date(violation_at), "reopened", cycle.id return None def calculate_task_execution_metrics( employee_id: int, *, today: date | None = None, since: date | None = None, ): today = today or timezone.localdate() cycles = list( TaskExecutionCycle.objects.filter(employee_id=employee_id) .exclude( status__in=[ TaskExecutionCycle.Status.PENDING, TaskExecutionCycle.Status.EXCLUDED, ] ) .order_by("completed_at_snapshot", "id") ) events = [] late_tasks = 0 reopened_tasks = 0 for cycle in cycles: event = _cycle_event(cycle) if event is None or (since is not None and event[0] < since): continue events.append(event) late_tasks += event[1] == "late" reopened_tasks += event[1] == "reopened" overdue_events = _open_overdue_events(employee_id, today, since=since) events.extend(overdue_events) events.sort(key=lambda item: (item[0], str(item[2]))) confirmed_tasks = 0 current_on_time_streak = 0 best_on_time_streak = 0 for _, event_type, _ in events: if event_type == "confirmed": confirmed_tasks += 1 current_on_time_streak += 1 best_on_time_streak = max( best_on_time_streak, current_on_time_streak, ) else: current_on_time_streak = 0 attempts = len(events) on_time_percent = round(confirmed_tasks / attempts * 100) if attempts else 0 violation_cutoff = today - timedelta(days=VIOLATION_WINDOW_DAYS) if since is not None: violation_cutoff = max(violation_cutoff, since) violations_last_60 = sum( event_type != "confirmed" and event_date >= violation_cutoff for event_date, event_type, _ in events ) base_penalty_levels = violations_last_60 // 3 recovered_levels = current_on_time_streak // 10 penalty_levels = max(base_penalty_levels - recovered_levels, 0) definition = TASK_SECRET_SKILL_DEFINITIONS[0] rank_metrics = { "confirmed_tasks": confirmed_tasks, "on_time_percent": on_time_percent, "best_on_time_streak": best_on_time_streak, } earned_rank = "none" for rank_code in RANK_CODES[1:]: requirements = definition.requirements[rank_code] if all(rank_metrics[key] >= target for key, target in requirements.items()): earned_rank = rank_code else: break effective_index = max(RANK_INDEX[earned_rank] - penalty_levels, 0) effective_rank = RANK_CODES[effective_index] return { **rank_metrics, "attempted_tasks": attempts, "current_on_time_streak": current_on_time_streak, "late_tasks": late_tasks, "reopened_tasks": reopened_tasks, "open_overdue_tasks": len(overdue_events), "pending_tasks": TaskExecutionCycle.objects.filter( employee_id=employee_id, status=TaskExecutionCycle.Status.PENDING, **( {"completed_at_snapshot__date__gte": since} if since is not None else {} ), ).count(), "violations_last_60": violations_last_60, "anti_streak": violations_last_60 % 3, "anti_streak_limit": 3, "recovery_streak": current_on_time_streak % 10 if penalty_levels else 0, "recovery_streak_target": 10, "penalty_levels": penalty_levels, "earned_rank": earned_rank, "effective_rank": effective_rank, "last_evaluated_date": today.isoformat(), } @transaction.atomic def recalculate_task_execution_skill( employee_id: int, *, since: date | None = None, ) -> list[str]: since = since or get_secret_skills_start_date() employee = Employee.objects.select_for_update().filter(pk=employee_id).first() if employee is None: return [] skill = Skill.objects.filter( system_code="task_execution", is_secret=True, ).first() if skill is None: return [] metrics = calculate_task_execution_metrics(employee_id, since=since) calculated_rank = metrics["effective_rank"] progress = EmployeeSkillProgress.objects.filter( employee=employee, skill=skill, ).first() previous_rank = progress.special_rank if progress else "none" previous_active = progress.is_active if progress else False if progress is None and calculated_rank == "none": return [] progress, _ = EmployeeSkillProgress.objects.get_or_create( employee=employee, skill=skill, defaults={"is_active": calculated_rank != "none"}, ) now = timezone.now() rank_history = dict(progress.progress_data.get("rank_achieved_at") or {}) rank_losses = list(progress.progress_data.get("rank_losses") or []) if RANK_INDEX[calculated_rank] > RANK_INDEX[previous_rank]: rank_history[calculated_rank] = now.isoformat() elif RANK_INDEX[calculated_rank] < RANK_INDEX[previous_rank]: rank_losses.append( {"from": previous_rank, "to": calculated_rank, "at": now.isoformat()} ) progress.special_rank = calculated_rank progress.experience = metrics["confirmed_tasks"] progress.is_active = calculated_rank != "none" progress.created_by = None progress.progress_data = { **metrics, "rank_achieved_at": rank_history, "rank_losses": rank_losses[-20:], "recalculated_at": now.isoformat(), } progress.save( update_fields=[ "special_rank", "experience", "is_active", "created_by", "progress_data", "updated_at", ] ) if calculated_rank == "none": employee.skills.remove(skill) else: employee.skills.add(skill) changed = calculated_rank != previous_rank or progress.is_active != previous_active return [skill.name] if changed else [] def refresh_task_execution_skills( # noqa: C901 employee_id: int | None = None, *, since: date | None = None, ) -> dict[str, int]: """Reconcile missed transitions, mature pending cycles and recalculate users.""" since = since or get_secret_skills_start_date() now = timezone.now() existing_cycle = TaskExecutionCycle.objects.filter( task_id=OuterRef("pk"), completed_at_snapshot=OuterRef("completed_at"), ) completed_tasks = Task.objects.filter(completed_at__isnull=False) if since is not None: completed_tasks = completed_tasks.filter(completed_at__date__gte=since) if employee_id is not None: completed_tasks = completed_tasks.filter(assignee_id=employee_id) missing_task_ids = list( completed_tasks.annotate(has_cycle=Exists(existing_cycle)) .filter(has_cycle=False) .values_list("id", flat=True) ) for task_id in missing_task_ids: capture_task_execution_cycle(task_id, since=since) changed_cycles = 0 employee_ids: set[int] = set() cycles = TaskExecutionCycle.objects.filter( status__in=[ TaskExecutionCycle.Status.PENDING, TaskExecutionCycle.Status.CONFIRMED, ] ).select_related("task") if since is not None: cycles = cycles.filter(completed_at_snapshot__date__gte=since) if employee_id is not None: cycles = cycles.filter(employee_id=employee_id) for cycle in cycles: task = cycle.task if task is None: if cycle.status == TaskExecutionCycle.Status.PENDING: cycle.status = TaskExecutionCycle.Status.EXCLUDED cycle.exclusion_reason = "task_deleted_during_protection" else: continue elif task.completed_at != cycle.completed_at_snapshot: cycle.status = TaskExecutionCycle.Status.REOPENED cycle.reopened_at = now elif ( cycle.status == TaskExecutionCycle.Status.PENDING and cycle.protection_until <= now ): cycle.status = TaskExecutionCycle.Status.CONFIRMED else: continue cycle.save( update_fields=[ "status", "exclusion_reason", "reopened_at", "updated_at", ] ) changed_cycles += 1 if cycle.employee_id: employee_ids.add(cycle.employee_id) cycle_employees = TaskExecutionCycle.objects.exclude(employee_id=None) if since is not None: cycle_employees = cycle_employees.filter(completed_at_snapshot__date__gte=since) open_tasks = Task.objects.filter( completed_at__isnull=True, assignee__isnull=False, due_date__isnull=False, ) if employee_id is not None: cycle_employees = cycle_employees.filter(employee_id=employee_id) open_tasks = open_tasks.filter(assignee_id=employee_id) employee_ids.update(cycle_employees.values_list("employee_id", flat=True)) employee_ids.update(open_tasks.values_list("assignee_id", flat=True)) if employee_id is not None: employee_ids.add(employee_id) elif since is not None: employee_ids.update( EmployeeSkillProgress.objects.filter( skill__system_code="task_execution", ).values_list("employee_id", flat=True) ) rank_changes = 0 for employee_id in employee_ids: rank_changes += len(recalculate_task_execution_skill(employee_id, since=since)) return { "captured_cycles": len(missing_task_ids), "changed_cycles": changed_cycles, "processed_employees": len(employee_ids), "rank_changes": rank_changes, }