blackopsrepl's picture
feat!: add task pinning system and refactor existing systems
e3a1efe
from timefold.solver import SolverStatus
from timefold.solver.domain import *
from timefold.solver.score import HardSoftDecimalScore
from datetime import date, timezone
from typing import Annotated
from dataclasses import dataclass, field
@dataclass
class Employee:
name: Annotated[str, PlanningId]
skills: Annotated[set[str], field(default_factory=set)]
unavailable_dates: Annotated[set[date], field(default_factory=set)] = field(
default_factory=set
)
undesired_dates: Annotated[set[date], field(default_factory=set)] = field(
default_factory=set
)
desired_dates: Annotated[set[date], field(default_factory=set)] = field(
default_factory=set
)
def to_dict(self):
return {
"name": self.name,
"skills": list(self.skills),
"unavailable_dates": [d.isoformat() for d in self.unavailable_dates],
"undesired_dates": [d.isoformat() for d in self.undesired_dates],
"desired_dates": [d.isoformat() for d in self.desired_dates],
}
@staticmethod
def from_dict(d):
return Employee(
name=d["name"],
skills=set(d["skills"]),
unavailable_dates=set(
date.fromisoformat(s) for s in d["unavailable_dates"]
),
undesired_dates=set(date.fromisoformat(s) for s in d["undesired_dates"]),
desired_dates=set(date.fromisoformat(s) for s in d["desired_dates"]),
)
@planning_entity
@dataclass
class Task:
id: Annotated[str, PlanningId]
description: str
duration_slots: int # Number of 30-minute slots required
start_slot: Annotated[
int, PlanningVariable(value_range_provider_refs=["startSlotRange"])
] # Slot index when the task starts
required_skill: str
# Identifier for the project this task belongs to (set by the UI when loading multiple project files)
project_id: str = ""
# Sequence number within the project to maintain original task order
sequence_number: int = 0
# Whether this task is pinned to its current assignment (for calendar events)
pinned: Annotated[bool, PlanningPin] = False
employee: Annotated[
Employee | None, PlanningVariable(value_range_provider_refs=["employeeRange"])
] = None
def to_dict(self):
return {
"id": self.id,
"description": self.description,
"duration_slots": self.duration_slots,
"start_slot": self.start_slot,
"required_skill": self.required_skill,
"project_id": self.project_id,
"sequence_number": self.sequence_number,
"pinned": self.pinned,
"employee": self.employee.to_dict() if self.employee else None,
}
@staticmethod
def from_dict(d):
return Task(
id=d["id"],
description=d["description"],
duration_slots=d["duration_slots"],
start_slot=d["start_slot"],
required_skill=d["required_skill"],
project_id=d.get("project_id", ""),
sequence_number=d.get("sequence_number", 0),
pinned=d.get("pinned", False),
employee=Employee.from_dict(d["employee"]) if d["employee"] else None,
)
@dataclass
class ScheduleInfo:
total_slots: int # Total number of 30-minute slots in the schedule
base_date: date = None # Base date for slot 0 (optional, defaults to today)
base_timezone: timezone = None # Timezone for datetime conversions (optional)
def to_dict(self):
return {
"total_slots": self.total_slots,
"base_date": self.base_date.isoformat() if self.base_date else None,
"base_timezone": str(self.base_timezone) if self.base_timezone else None,
}
@staticmethod
def from_dict(d):
base_date = None
if d.get("base_date"):
base_date = date.fromisoformat(d["base_date"])
base_timezone = None
if d.get("base_timezone"):
# Simple timezone parsing - extend as needed
tz_str = d["base_timezone"]
if tz_str == "UTC" or "+00:00" in tz_str:
base_timezone = timezone.utc
# Add more timezone parsing as needed
return ScheduleInfo(
total_slots=d["total_slots"],
base_date=base_date,
base_timezone=base_timezone,
)
@planning_solution
@dataclass
class EmployeeSchedule:
employees: Annotated[
list[Employee],
ProblemFactCollectionProperty,
ValueRangeProvider(id="employeeRange"),
]
tasks: Annotated[list[Task], PlanningEntityCollectionProperty]
schedule_info: Annotated[ScheduleInfo, ProblemFactProperty]
score: Annotated[HardSoftDecimalScore | None, PlanningScore] = None
solver_status: SolverStatus | None = None
def get_start_slot_range(
self,
) -> Annotated[list[int], ValueRangeProvider(id="startSlotRange")]:
"""Returns all possible start slots, including slots used by pinned tasks."""
max_slot = self.schedule_info.total_slots
# Ensure all pinned task slots are included in the range
for task in self.tasks:
if getattr(task, "pinned", False):
task_end_slot = task.start_slot + task.duration_slots
if task_end_slot > max_slot:
max_slot = task_end_slot
return list(range(max_slot))
def to_dict(self):
return {
"employees": [e.to_dict() for e in self.employees],
"tasks": [t.to_dict() for t in self.tasks],
"schedule_info": self.schedule_info.to_dict(),
"score": str(self.score) if self.score is not None else None,
"solver_status": str(self.solver_status)
if self.solver_status is not None
else None,
}
@staticmethod
def from_dict(d):
return EmployeeSchedule(
employees=[Employee.from_dict(e) for e in d["employees"]],
tasks=[Task.from_dict(t) for t in d["tasks"]],
schedule_info=ScheduleInfo.from_dict(d["schedule_info"]),
# score and solver_status are not restored (not needed for state passing)
)