├── .gitignore ├── Makefile ├── NOTES_ABOUT_PYTHON_VERSION.md ├── README.md ├── poetry.lock ├── pyproject.toml ├── smartschedule ├── __init__.py ├── allocation │ ├── __init__.py │ ├── allocated_capability.py │ ├── allocation_facade.py │ ├── allocations.py │ ├── capabilities_allocated.py │ ├── capability_released.py │ ├── capabilityscheduling │ │ ├── __init__.py │ │ ├── allocatable_capabilities_summary.py │ │ ├── allocatable_capability.py │ │ ├── allocatable_capability_id.py │ │ ├── allocatable_capability_repository.py │ │ ├── allocatable_capability_summary.py │ │ ├── allocatable_resource_id.py │ │ ├── capability_finder.py │ │ ├── capability_scheduler.py │ │ └── legacyacl │ │ │ ├── __init__.py │ │ │ ├── employee_created_in_legacy_system_message_handler.py │ │ │ ├── employee_data_from_legacy_esb_message.py │ │ │ └── translate_to_capability_selector.py │ ├── cashflow │ │ ├── __init__.py │ │ ├── cash_flow_facade.py │ │ ├── cashflow.py │ │ ├── cashflow_repository.py │ │ ├── cost.py │ │ ├── earnings.py │ │ ├── earnings_recalculated.py │ │ ├── income.py │ │ └── sqlalchemy_cashflow_repository.py │ ├── demand.py │ ├── demands.py │ ├── not_satisfied_demands.py │ ├── potential_transfers.py │ ├── potential_transfers_service.py │ ├── project_allocation_scheduled.py │ ├── project_allocations.py │ ├── project_allocations_demands_scheduled.py │ ├── project_allocations_id.py │ ├── project_allocations_repository.py │ ├── projects_allocations_summary.py │ ├── publish_missing_demands_service.py │ └── sqlalchemy_project_allocations_repository.py ├── availability │ ├── __init__.py │ ├── availability_facade.py │ ├── blockade.py │ ├── calendar.py │ ├── calendars.py │ ├── owner.py │ ├── resource_availability.py │ ├── resource_availability_id.py │ ├── resource_availability_read_model.py │ ├── resource_availability_repository.py │ ├── resource_grouped_availability.py │ ├── resource_id.py │ ├── resource_taken_over.py │ └── segment │ │ ├── __init__.py │ │ ├── segment_in_minutes.py │ │ ├── segments.py │ │ ├── slot_to_normalized_slot.py │ │ └── slot_to_segments.py ├── container.py ├── optimization │ ├── __init__.py │ ├── capacity_dimension.py │ ├── item.py │ ├── optimization_facade.py │ ├── result.py │ ├── total_capacity.py │ ├── total_weight.py │ └── weight_dimension.py ├── planning │ ├── __init__.py │ ├── capabilities_demanded.py │ ├── chosen_resources.py │ ├── create_project_allocations.py │ ├── critical_stage_planned.py │ ├── demand.py │ ├── demands.py │ ├── demands_per_stage.py │ ├── edit_stage_date_service.py │ ├── needed_resource_chosen.py │ ├── needed_resources_chosen.py │ ├── parallelization │ │ ├── __init__.py │ │ ├── duration_calculator.py │ │ ├── parallel_stages.py │ │ ├── parallel_stages_list.py │ │ ├── sorted_nodes_to_parallelized_stages.py │ │ ├── stage.py │ │ ├── stage_parallelization.py │ │ └── stages_to_nodes.py │ ├── plan_chosen_resources.py │ ├── planning_facade.py │ ├── project.py │ ├── project_card.py │ ├── project_id.py │ ├── project_repository.py │ ├── redis_project_repository.py │ └── schedule │ │ ├── __init__.py │ │ ├── schedule.py │ │ ├── schedule_based_on_chosen_resources_availability_calculator.py │ │ ├── schedule_based_on_reference_stage_calculator.py │ │ └── schedule_based_on_start_day_calculator.py ├── resource │ ├── __init__.py │ ├── device │ │ ├── __init__.py │ │ ├── device.py │ │ ├── device_configuration.py │ │ ├── device_facade.py │ │ ├── device_id.py │ │ ├── device_repository.py │ │ ├── device_summary.py │ │ └── schedule_device_capabilities.py │ ├── employee │ │ ├── __init__.py │ │ ├── employee.py │ │ ├── employee_allocation_policy.py │ │ ├── employee_configuration.py │ │ ├── employee_facade.py │ │ ├── employee_id.py │ │ ├── employee_repository.py │ │ ├── employee_summary.py │ │ ├── schedule_employee_capabilities.py │ │ └── seniority.py │ └── resource_facade.py ├── risk │ ├── __init__.py │ ├── risk_configuration.py │ ├── risk_periodic_check_saga.py │ ├── risk_periodic_check_saga_dispatcher.py │ ├── risk_periodic_check_saga_id.py │ ├── risk_periodic_check_saga_repository.py │ ├── risk_periodic_check_saga_step.py │ ├── risk_push_notification.py │ ├── verify_critical_resource_available_during_planning.py │ ├── verify_enough_demands_during_planning.py │ └── verify_needed_resources_available_in_time_slot.py ├── shared │ ├── __init__.py │ ├── capability │ │ └── capability.py │ ├── capability_selector.py │ ├── event_bus.py │ ├── events_publisher.py │ ├── private_event.py │ ├── published_event.py │ ├── repository.py │ ├── resource_name.py │ ├── sqlalchemy_extensions.py │ ├── timeslot │ │ ├── __init__.py │ │ └── time_slot.py │ └── typing_extensions.py ├── simulation │ ├── __init__.py │ ├── additional_priced_capability.py │ ├── available_resource_capability.py │ ├── demand.py │ ├── demands.py │ ├── project_id.py │ ├── simulated_capabilities.py │ ├── simulated_project.py │ └── simulation_facade.py └── sorter │ ├── __init__.py │ ├── edge.py │ ├── feedback_arc_se_on_graph.py │ ├── graph_topological_sort.py │ ├── node.py │ ├── nodes.py │ └── sorted_nodes.py ├── tach.yml └── tests ├── __init__.py ├── conftest.py ├── smartschedule ├── __init__.py ├── allocation │ ├── __init__.py │ ├── availability_assert.py │ ├── capabilityscheduling │ │ ├── __init__.py │ │ ├── legacyacl │ │ │ ├── __init__.py │ │ │ └── test_translate_to_capability_selector.py │ │ └── test_capability_scheduling.py │ ├── cashflow │ │ ├── __init__.py │ │ ├── in_memory_cashflow_repository.py │ │ ├── test_cash_flow_facade.py │ │ └── test_earnings.py │ ├── conftest.py │ ├── in_memory_project_allocations_repository.py │ ├── test_allocations_to_project.py │ ├── test_capability_allocating.py │ ├── test_create_hourly_demands_summary_service.py │ ├── test_creating_new_project.py │ ├── test_demand_scheduling.py │ ├── test_potential_transfer_scenarios.py │ ├── test_project_allocations_repository.py │ └── test_resource_allocating.py ├── availability │ ├── __init__.py │ ├── conftest.py │ ├── segment │ │ ├── __init__.py │ │ ├── test_segments.py │ │ └── test_slot_to_normalized_slot.py │ ├── test_availability_calendar.py │ ├── test_availability_facade.py │ ├── test_resource_availability.py │ ├── test_resource_availability_loading.py │ ├── test_resource_availability_optimistic_locking.py │ ├── test_resource_availability_uniqueness.py │ └── test_taking_random_resource.py ├── optimization │ ├── __init__.py │ ├── capability_capacity_dimension.py │ ├── conftest.py │ ├── test_optimization.py │ └── test_optimization_for_timed_capabilities.py ├── planning │ ├── __init__.py │ ├── in_memory_project_repository.py │ ├── parallelization │ │ ├── __init__.py │ │ ├── test_dependency_removal_suggesting.py │ │ ├── test_duration_calculator.py │ │ └── test_parallelization.py │ ├── schedule │ │ ├── __init__.py │ │ ├── assertions │ │ │ ├── __init__.py │ │ │ ├── schedule_assert.py │ │ │ └── stage_assert.py │ │ └── test_schedule_calculation.py │ ├── test_planning_facade.py │ ├── test_rd.py │ ├── test_redis_repository.py │ ├── test_specialized_waterfall.py │ ├── test_standard_waterfall.py │ ├── test_time_critical_waterfall.py │ └── test_vision.py ├── resource │ ├── __init__.py │ ├── device │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_creating_device.py │ │ └── test_schedule_device_capabilities.py │ └── employee │ │ ├── __init__.py │ │ ├── test_allocation_policies.py │ │ ├── test_creating_employee.py │ │ └── test_schedule_employee_capabilities.py ├── risk │ ├── __init__.py │ ├── test_risk_periodic_check_saga.py │ ├── test_risk_periodic_check_saga_dispatcher_e2_e.py │ └── test_verify_enough_demands_during_planning.py ├── shared │ ├── __init__.py │ ├── test_capability_selector.py │ ├── test_event_bus.py │ └── timeslot │ │ ├── __init__.py │ │ └── test_time_slot.py ├── simulation │ ├── __init__.py │ ├── available_capabilities_factory.py │ ├── simulated_projects_factory.py │ └── test_simulation_scenarios.py ├── sorter │ ├── __init__.py │ ├── test_feedback_arc_set_on_graph.py │ └── test_graph_topological_sort.py └── task_executor_configuration.py └── timeout.py /.gitignore: -------------------------------------------------------------------------------- 1 | NOTES.md 2 | .vscode/ 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | qa: 2 | ruff check . --extend-select I --fix 3 | ruff format . 4 | mypy --strict --enable-incomplete-feature=NewGenericSyntax . 5 | pytest tests/ 6 | tach check 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Running tests 2 | 3 | ## Prerequisites 4 | 5 | - Install [Python3.12](https://www.python.org/downloads/) 6 | - Install [poetry](https://python-poetry.org/) 7 | - Install project dependencies in a poetry's managed virtualenv `poetry install --with=dev` 8 | - Have docker up and running (tests use [TestContainers](https://testcontainers.com/)) 9 | 10 | ## Running linters and tests 11 | 12 | ### If you have make 13 | ```bash 14 | make qa 15 | ``` 16 | 17 | ### If you don't have make 18 | ```bash 19 | ruff check . --extend-select I --fix 20 | ruff format . 21 | mypy --strict --enable-incomplete-feature=NewGenericSyntax . 22 | pytest tests/ 23 | tach check 24 | ``` 25 | 26 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dd-python" 3 | version = "0.0.0" 4 | description = "" 5 | authors = ["Sebastian Buczyński "] 6 | license = "GPL-3.0-only" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.12" 11 | python-dateutil = "*" 12 | sqlalchemy = "*" 13 | pydantic-settings = "*" 14 | fastapi = {version = "*", extras = ["all"]} 15 | pydantic = "*" 16 | lagom = "*" 17 | psycopg2-binary = "*" 18 | redis = "*" 19 | 20 | [tool.poetry.group.dev.dependencies] 21 | ruff = "*" 22 | pytest = "*" 23 | factory-boy = "*" 24 | tach = "*" 25 | types-python-dateutil = "*" 26 | testcontainers = {version = "*", extras = ["postgres"]} 27 | mockito = "*" 28 | pytest-mockito = "*" 29 | time-machine = "*" 30 | 31 | [build-system] 32 | requires = ["poetry-core"] 33 | build-backend = "poetry.core.masonry.api" 34 | -------------------------------------------------------------------------------- /smartschedule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/__init__.py -------------------------------------------------------------------------------- /smartschedule/allocation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/allocation/__init__.py -------------------------------------------------------------------------------- /smartschedule/allocation/allocated_capability.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 4 | AllocatableCapabilityId, 5 | ) 6 | from smartschedule.shared.capability_selector import CapabilitySelector 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | @dataclass(frozen=True) 11 | class AllocatedCapability: 12 | allocated_capability_id: AllocatableCapabilityId 13 | capability: CapabilitySelector 14 | time_slot: TimeSlot 15 | -------------------------------------------------------------------------------- /smartschedule/allocation/allocations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.allocation.allocated_capability import AllocatedCapability 6 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 7 | AllocatableCapabilityId, 8 | ) 9 | from smartschedule.shared.timeslot.time_slot import TimeSlot 10 | 11 | 12 | @dataclass(frozen=True) 13 | class Allocations: 14 | all: set[AllocatedCapability] 15 | 16 | @staticmethod 17 | def none() -> Allocations: 18 | return Allocations(all=set()) 19 | 20 | def add(self, allocated_capability: AllocatedCapability) -> Allocations: 21 | return Allocations(all=self.all.union({allocated_capability})) 22 | 23 | def remove( 24 | self, to_remove: AllocatableCapabilityId, time_slot: TimeSlot 25 | ) -> Allocations: 26 | allocated_capability = self.find(to_remove) 27 | if allocated_capability is None: 28 | return self 29 | return self._remove_from_slot(allocated_capability, time_slot) 30 | 31 | def _remove_from_slot( 32 | self, allocated_capability: AllocatedCapability, time_slot: TimeSlot 33 | ) -> Allocations: 34 | difference = allocated_capability.time_slot.leftover_after_removing_common_with( 35 | time_slot 36 | ) 37 | leftovers: set[AllocatedCapability] = { 38 | AllocatedCapability( 39 | allocated_capability.allocated_capability_id, 40 | allocated_capability.capability, 41 | leftover, 42 | ) 43 | for leftover in difference 44 | if leftover.within(allocated_capability.time_slot) 45 | } 46 | 47 | return Allocations( 48 | all=self.all.difference({allocated_capability}).union(leftovers) 49 | ) 50 | 51 | def find( 52 | self, allocated_capability_id: AllocatableCapabilityId 53 | ) -> AllocatedCapability | None: 54 | return next( 55 | ( 56 | allocation 57 | for allocation in self.all 58 | if allocation.allocated_capability_id == allocated_capability_id 59 | ), 60 | None, 61 | ) 62 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilities_allocated.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from smartschedule.allocation.demands import Demands 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.shared.private_event import PrivateEvent 8 | 9 | 10 | @dataclass(frozen=True) 11 | class CapabilitiesAllocated(PrivateEvent): 12 | allocated_capability_id: UUID 13 | project_id: ProjectAllocationsId 14 | missing_demands: Demands 15 | occurred_at: datetime 16 | event_id: UUID = field(default_factory=uuid4, compare=False) 17 | -------------------------------------------------------------------------------- /smartschedule/allocation/capability_released.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from smartschedule.allocation.demands import Demands 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.shared.private_event import PrivateEvent 8 | 9 | 10 | @dataclass(frozen=True) 11 | class CapabilityReleased(PrivateEvent): 12 | project_id: ProjectAllocationsId 13 | missing_demands: Demands 14 | occurred_at: datetime 15 | event_id: UUID = field(default_factory=uuid4) 16 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/allocation/capabilityscheduling/__init__.py -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/allocatable_capabilities_summary.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_summary import ( 4 | AllocatableCapabilitySummary, 5 | ) 6 | 7 | 8 | @dataclass(frozen=True) 9 | class AllocatableCapabilitiesSummary: 10 | all: list[AllocatableCapabilitySummary] 11 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/allocatable_capability.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import DateTime 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 7 | AllocatableCapabilityId, 8 | ) 9 | from smartschedule.allocation.capabilityscheduling.allocatable_resource_id import ( 10 | AllocatableResourceId, 11 | ) 12 | from smartschedule.shared.capability.capability import Capability 13 | from smartschedule.shared.capability_selector import ( 14 | CapabilitySelector, 15 | ) 16 | from smartschedule.shared.sqlalchemy_extensions import AsJSON, EmbeddedUUID, registry 17 | from smartschedule.shared.timeslot.time_slot import TimeSlot 18 | 19 | 20 | @registry.mapped_as_dataclass() 21 | class AllocatableCapability: 22 | __tablename__ = "allocatable_capabilities" 23 | 24 | id: Mapped[AllocatableCapabilityId] = mapped_column( 25 | EmbeddedUUID[AllocatableCapabilityId], primary_key=True 26 | ) 27 | possible_capabilities: Mapped[CapabilitySelector] = mapped_column( 28 | AsJSON[CapabilitySelector] 29 | ) 30 | resource_id: Mapped[AllocatableResourceId] = mapped_column( 31 | EmbeddedUUID[AllocatableResourceId] 32 | ) 33 | _from_date: Mapped[datetime] = mapped_column( 34 | DateTime(timezone=True), name="from_date" 35 | ) 36 | _to_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), name="to_date") 37 | 38 | def __init__( 39 | self, 40 | resource_id: AllocatableResourceId, 41 | possible_capabilities: CapabilitySelector, 42 | time_slot: TimeSlot, 43 | ) -> None: 44 | self.id = AllocatableCapabilityId.new_one() 45 | self.resource_id = resource_id 46 | self.possible_capabilities = possible_capabilities 47 | self._from_date = time_slot.from_ 48 | self._to_date = time_slot.to 49 | 50 | @property 51 | def time_slot(self) -> TimeSlot: 52 | return TimeSlot(self._from_date, self._to_date) 53 | 54 | def can_perform(self, capability: set[Capability]) -> bool: 55 | return self.possible_capabilities.can_perform(*capability) 56 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/allocatable_capability_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | from smartschedule.availability.resource_id import ResourceId 7 | 8 | 9 | @dataclass(frozen=True) 10 | class AllocatableCapabilityId: 11 | _id: UUID 12 | 13 | @property 14 | def id(self) -> UUID: 15 | return self._id 16 | 17 | @staticmethod 18 | def new_one() -> AllocatableCapabilityId: 19 | return AllocatableCapabilityId(uuid4()) 20 | 21 | @staticmethod 22 | def none() -> AllocatableCapabilityId: 23 | return AllocatableCapabilityId(UUID(int=0)) 24 | 25 | def to_availability_resource_id(self) -> ResourceId: 26 | return ResourceId(self._id) 27 | 28 | @staticmethod 29 | def from_availability_resource_id( 30 | resource_id: ResourceId, 31 | ) -> AllocatableCapabilityId: 32 | return AllocatableCapabilityId(resource_id.id) 33 | 34 | def __eq__(self, other: object) -> bool: 35 | if not isinstance(other, AllocatableCapabilityId): 36 | return False 37 | return self._id == other._id 38 | 39 | def __hash__(self) -> int: 40 | return hash(self._id) 41 | 42 | def __repr__(self) -> str: 43 | return f"AllocatableCapabilityId(UUID(hex='{self._id}'))" 44 | 45 | def __str__(self) -> str: 46 | return str(self._id) 47 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/allocatable_capability_summary.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 4 | AllocatableCapabilityId, 5 | ) 6 | from smartschedule.allocation.capabilityscheduling.allocatable_resource_id import ( 7 | AllocatableResourceId, 8 | ) 9 | from smartschedule.shared.capability_selector import ( 10 | CapabilitySelector, 11 | ) 12 | from smartschedule.shared.timeslot.time_slot import TimeSlot 13 | 14 | 15 | @dataclass(frozen=True) 16 | class AllocatableCapabilitySummary: 17 | id: AllocatableCapabilityId 18 | allocatable_resource_id: AllocatableResourceId 19 | capabilities: CapabilitySelector 20 | time_slot: TimeSlot 21 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/allocatable_resource_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | 7 | @dataclass(frozen=True) 8 | class AllocatableResourceId: 9 | _resource_id: UUID 10 | 11 | @property 12 | def id(self) -> UUID: 13 | return self._resource_id 14 | 15 | @staticmethod 16 | def new_one() -> AllocatableResourceId: 17 | return AllocatableResourceId(uuid4()) 18 | 19 | def __eq__(self, other: object) -> bool: 20 | if not isinstance(other, AllocatableResourceId): 21 | return False 22 | return self._resource_id == other._resource_id 23 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/legacyacl/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/allocation/capabilityscheduling/legacyacl/__init__.py -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/legacyacl/employee_created_in_legacy_system_message_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from smartschedule.allocation.capabilityscheduling.allocatable_resource_id import ( 4 | AllocatableResourceId, 5 | ) 6 | from smartschedule.allocation.capabilityscheduling.capability_scheduler import ( 7 | CapabilityScheduler, 8 | ) 9 | from smartschedule.allocation.capabilityscheduling.legacyacl import ( 10 | translate_to_capability_selector, 11 | ) 12 | from smartschedule.allocation.capabilityscheduling.legacyacl.employee_data_from_legacy_esb_message import ( 13 | EmployeeDataFromLegacyEsbMessage, 14 | ) 15 | 16 | 17 | class EmployeeCreatedInLegacySystemMessageHandler: 18 | def __init__(self, capability_scheduler: CapabilityScheduler) -> None: 19 | self._capability_scheduler = capability_scheduler 20 | 21 | # subscribe to message bus 22 | def handle(self, message: EmployeeDataFromLegacyEsbMessage) -> None: 23 | allocatable_resource_id = AllocatableResourceId(message.resource_id) 24 | capability_selectors = translate_to_capability_selector.translate(message) 25 | self._capability_scheduler.schedule_resource_capabilities_for_period( 26 | allocatable_resource_id, capability_selectors, message.time_slot 27 | ) 28 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/legacyacl/employee_data_from_legacy_esb_message.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from uuid import UUID 3 | 4 | from smartschedule.shared.timeslot.time_slot import TimeSlot 5 | 6 | 7 | @dataclass(frozen=True) 8 | class EmployeeDataFromLegacyEsbMessage: 9 | resource_id: UUID 10 | skills_performed_together: list[list[str]] 11 | exclusive_skills: list[str] 12 | permissions: list[str] 13 | time_slot: TimeSlot 14 | -------------------------------------------------------------------------------- /smartschedule/allocation/capabilityscheduling/legacyacl/translate_to_capability_selector.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from smartschedule.allocation.capabilityscheduling.legacyacl.employee_data_from_legacy_esb_message import ( 4 | EmployeeDataFromLegacyEsbMessage, 5 | ) 6 | from smartschedule.shared.capability.capability import Capability 7 | from smartschedule.shared.capability_selector import CapabilitySelector 8 | 9 | 10 | def translate(message: EmployeeDataFromLegacyEsbMessage) -> list[CapabilitySelector]: 11 | employee_skills = [ 12 | CapabilitySelector.can_perform_all_at_the_time( 13 | {Capability.skill(skill_name) for skill_name in skill_names} 14 | ) 15 | for skill_names in message.skills_performed_together 16 | ] 17 | employee_exclusive_skills = [ 18 | CapabilitySelector.can_just_perform(Capability.skill(skill_name)) 19 | for skill_name in message.exclusive_skills 20 | ] 21 | employee_permissions = list( 22 | chain.from_iterable( 23 | _multiple_permissions(permission) for permission in message.permissions 24 | ) 25 | ) 26 | 27 | return employee_skills + employee_exclusive_skills + employee_permissions 28 | 29 | 30 | def _multiple_permissions(permission_legacy_code: str) -> list[CapabilitySelector]: 31 | parts = permission_legacy_code.split("<>") 32 | permission = parts[0] 33 | times = int(parts[1]) 34 | return [ 35 | CapabilitySelector.can_just_perform(Capability.permission(permission)) 36 | for _ in range(times) 37 | ] 38 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/allocation/cashflow/__init__.py -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/cash_flow_facade.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from smartschedule.allocation.cashflow.cashflow import Cashflow 4 | from smartschedule.allocation.cashflow.cashflow_repository import CashflowRepository 5 | from smartschedule.allocation.cashflow.cost import Cost 6 | from smartschedule.allocation.cashflow.earnings import Earnings 7 | from smartschedule.allocation.cashflow.earnings_recalculated import EarningsRecalculated 8 | from smartschedule.allocation.cashflow.income import Income 9 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 10 | from smartschedule.shared.events_publisher import EventsPublisher 11 | from smartschedule.shared.repository import NotFound 12 | 13 | 14 | class CashFlowFacade: 15 | def __init__( 16 | self, 17 | cash_flow_repository: CashflowRepository, 18 | events_publisher: EventsPublisher, 19 | ) -> None: 20 | self._cash_flow_repository = cash_flow_repository 21 | self._events_publisher = events_publisher 22 | 23 | def add_income_and_cost( 24 | self, project_id: ProjectAllocationsId, income: Income, cost: Cost 25 | ) -> None: 26 | try: 27 | cashflow = self._cash_flow_repository.get(project_id) 28 | cashflow.income = income 29 | cashflow.cost = cost 30 | except NotFound: 31 | cashflow = Cashflow(project_id=project_id, income=income, cost=cost) 32 | self._cash_flow_repository.add(cashflow) 33 | 34 | event = EarningsRecalculated(project_id, cashflow.earnings, datetime.now()) 35 | self._events_publisher.publish(event) 36 | 37 | def find(self, project_id: ProjectAllocationsId) -> Earnings: 38 | cashflow = self._cash_flow_repository.get(project_id) 39 | return cashflow.earnings 40 | 41 | def find_all(self) -> dict[ProjectAllocationsId, Earnings]: 42 | cashflows = self._cash_flow_repository.get_all() 43 | return {cashflow.project_id: cashflow.earnings for cashflow in cashflows} 44 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/cashflow.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped, mapped_column 2 | 3 | from smartschedule.allocation.cashflow.cost import Cost 4 | from smartschedule.allocation.cashflow.earnings import Earnings 5 | from smartschedule.allocation.cashflow.income import Income 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.shared.sqlalchemy_extensions import AsJSON, EmbeddedUUID, registry 8 | 9 | 10 | @registry.mapped_as_dataclass() 11 | class Cashflow: 12 | __tablename__ = "cashflows" 13 | 14 | project_id: Mapped[ProjectAllocationsId] = mapped_column( 15 | EmbeddedUUID[ProjectAllocationsId], primary_key=True 16 | ) 17 | income: Mapped[Income] = mapped_column(AsJSON[Income]) 18 | cost: Mapped[Cost] = mapped_column(AsJSON[Cost]) 19 | 20 | def __init__( 21 | self, project_id: ProjectAllocationsId, income: Income, cost: Cost 22 | ) -> None: 23 | self.project_id = project_id 24 | self.income = income 25 | self.cost = cost 26 | 27 | @property 28 | def earnings(self) -> Earnings: 29 | return self.income - self.cost 30 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/cashflow_repository.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Sequence 3 | 4 | from smartschedule.allocation.cashflow.cashflow import Cashflow 5 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 6 | 7 | 8 | class CashflowRepository(abc.ABC): 9 | @abc.abstractmethod 10 | def get(self, project_id: ProjectAllocationsId) -> Cashflow: 11 | pass 12 | 13 | @abc.abstractmethod 14 | def get_all( 15 | self, ids: list[ProjectAllocationsId] | None = None 16 | ) -> Sequence[Cashflow]: 17 | pass 18 | 19 | @abc.abstractmethod 20 | def add(self, cashflow: Cashflow) -> None: 21 | pass 22 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/cost.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from decimal import Decimal 3 | 4 | 5 | @dataclass(init=False, unsafe_hash=True) 6 | class Cost: 7 | _cost: Decimal 8 | 9 | def __init__(self, cost: Decimal | int) -> None: 10 | self._cost = Decimal(cost) 11 | 12 | @property 13 | def value(self) -> Decimal: 14 | return self._cost 15 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/earnings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from dataclasses import dataclass 5 | from decimal import Decimal 6 | 7 | 8 | @functools.total_ordering 9 | @dataclass(init=False, unsafe_hash=True) 10 | class Earnings: 11 | _earnings: Decimal 12 | 13 | def __init__(self, value: Decimal | int) -> None: 14 | self._earnings = Decimal(value) 15 | 16 | def to_decimal(self) -> Decimal: 17 | return self._earnings 18 | 19 | def __gt__(self, other: Earnings) -> bool: 20 | return self._earnings > other._earnings 21 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/earnings_recalculated.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from smartschedule.allocation.cashflow.earnings import Earnings 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.shared.published_event import PublishedEvent 8 | 9 | 10 | @dataclass(frozen=True) 11 | class EarningsRecalculated(PublishedEvent): 12 | project_id: ProjectAllocationsId 13 | earnings: Earnings 14 | occurred_at: datetime 15 | uuid: UUID = field(default_factory=uuid4) 16 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/income.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from decimal import Decimal 3 | 4 | from smartschedule.allocation.cashflow.cost import Cost 5 | from smartschedule.allocation.cashflow.earnings import Earnings 6 | 7 | 8 | @dataclass(init=False, unsafe_hash=True) 9 | class Income: 10 | _income: Decimal 11 | 12 | def __init__(self, value: Decimal | int) -> None: 13 | self._income = Decimal(value) 14 | 15 | def __sub__(self, other: Cost) -> Earnings: 16 | return Earnings(self._income - other.value) 17 | -------------------------------------------------------------------------------- /smartschedule/allocation/cashflow/sqlalchemy_cashflow_repository.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.cashflow.cashflow import Cashflow 2 | from smartschedule.allocation.cashflow.cashflow_repository import CashflowRepository 3 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 4 | from smartschedule.shared.sqlalchemy_extensions import SQLAlchemyRepository 5 | 6 | 7 | class SqlAlchemyCashflowRepository( 8 | SQLAlchemyRepository[Cashflow, ProjectAllocationsId], CashflowRepository 9 | ): 10 | pass 11 | -------------------------------------------------------------------------------- /smartschedule/allocation/demand.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.shared.capability.capability import Capability 4 | from smartschedule.shared.timeslot.time_slot import TimeSlot 5 | 6 | 7 | @dataclass(frozen=True) 8 | class Demand: 9 | capability: Capability 10 | time_slot: TimeSlot 11 | -------------------------------------------------------------------------------- /smartschedule/allocation/demands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.allocation.allocations import Allocations 6 | from smartschedule.allocation.demand import Demand 7 | from smartschedule.shared.capability.capability import Capability 8 | from smartschedule.shared.timeslot.time_slot import TimeSlot 9 | 10 | 11 | @dataclass(frozen=True) 12 | class Demands: 13 | all: list[Demand] 14 | 15 | @staticmethod 16 | def none() -> Demands: 17 | return Demands(all=[]) 18 | 19 | @staticmethod 20 | def of(*demands: Demand) -> Demands: 21 | return Demands(all=list(demands)) 22 | 23 | @staticmethod 24 | def all_in_same_time_slot( 25 | time_slot: TimeSlot, *capabilities: Capability 26 | ) -> Demands: 27 | return Demands.of( 28 | *[Demand(capability, time_slot) for capability in capabilities] 29 | ) 30 | 31 | def with_new(self, new_demands: Demands) -> Demands: 32 | self.all.extend(new_demands.all) 33 | return Demands(all=self.all[:]) 34 | 35 | def missing_demands(self, allocations: Allocations) -> Demands: 36 | return Demands( 37 | [ 38 | demand 39 | for demand in self.all 40 | if not self._satisfied_by(demand, allocations) 41 | ] 42 | ) 43 | 44 | def _satisfied_by(self, demand: Demand, allocations: Allocations) -> bool: 45 | return any( 46 | allocation.capability.can_perform(demand.capability) 47 | and demand.time_slot.within(allocation.time_slot) 48 | for allocation in allocations.all 49 | ) 50 | -------------------------------------------------------------------------------- /smartschedule/allocation/not_satisfied_demands.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from typing import Self 4 | from uuid import UUID, uuid4 5 | 6 | from smartschedule.allocation.demands import Demands 7 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 8 | from smartschedule.shared.published_event import PublishedEvent 9 | 10 | 11 | @dataclass(frozen=True) 12 | class NotSatisfiedDemands(PublishedEvent): 13 | missing_demands: dict[ProjectAllocationsId, Demands] 14 | occurred_at: datetime 15 | uuid: UUID = field(default_factory=uuid4) 16 | 17 | @classmethod 18 | def for_one_project( 19 | cls, 20 | project_id: ProjectAllocationsId, 21 | scheduled_demands: Demands, 22 | occurred_at: datetime, 23 | ) -> Self: 24 | return cls({project_id: scheduled_demands}, occurred_at) 25 | 26 | @classmethod 27 | def all_satisfied( 28 | cls, project_id: ProjectAllocationsId, occurred_at: datetime 29 | ) -> Self: 30 | return cls({project_id: Demands.none()}, occurred_at) 31 | -------------------------------------------------------------------------------- /smartschedule/allocation/project_allocation_scheduled.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 6 | from smartschedule.shared.private_event import PrivateEvent 7 | from smartschedule.shared.published_event import PublishedEvent 8 | from smartschedule.shared.timeslot.time_slot import TimeSlot 9 | 10 | 11 | @dataclass(frozen=True) 12 | class ProjectAllocationScheduled(PrivateEvent, PublishedEvent): 13 | project_id: ProjectAllocationsId 14 | from_to: TimeSlot 15 | occurred_at: datetime 16 | uuid: UUID = field(default_factory=uuid4) 17 | -------------------------------------------------------------------------------- /smartschedule/allocation/project_allocations_demands_scheduled.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from smartschedule.allocation.demands import Demands 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.shared.private_event import PrivateEvent 8 | 9 | 10 | @dataclass(frozen=True) 11 | class ProjectAllocationsDemandsScheduled(PrivateEvent): 12 | project_id: ProjectAllocationsId 13 | missing_demands: Demands 14 | occurred_at: datetime 15 | uuid: UUID = field(default_factory=uuid4) 16 | -------------------------------------------------------------------------------- /smartschedule/allocation/project_allocations_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import UUID, uuid4 4 | 5 | 6 | class ProjectAllocationsId: 7 | def __init__(self, uuid: UUID) -> None: 8 | self._project_allocations_id = uuid 9 | 10 | @property 11 | def id(self) -> UUID: 12 | return self._project_allocations_id 13 | 14 | @staticmethod 15 | def new_one() -> ProjectAllocationsId: 16 | return ProjectAllocationsId(uuid4()) 17 | 18 | def __eq__(self, other: object) -> bool: 19 | if not isinstance(other, ProjectAllocationsId): 20 | return False 21 | return self._project_allocations_id == other._project_allocations_id 22 | 23 | def __hash__(self) -> int: 24 | return hash(self._project_allocations_id) 25 | 26 | def __repr__(self) -> str: 27 | return f"ProjectAllocationsId(UUID(hex='{self._project_allocations_id}'))" 28 | 29 | def __str__(self) -> str: 30 | return str(self._project_allocations_id) 31 | -------------------------------------------------------------------------------- /smartschedule/allocation/project_allocations_repository.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from datetime import datetime 3 | from typing import Sequence 4 | 5 | from smartschedule.allocation.project_allocations import ProjectAllocations 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | 8 | 9 | class ProjectAllocationsRepository(abc.ABC): 10 | @abc.abstractmethod 11 | def get(self, id: ProjectAllocationsId) -> ProjectAllocations: 12 | pass 13 | 14 | @abc.abstractmethod 15 | def get_all( 16 | self, ids: list[ProjectAllocationsId] | None = None 17 | ) -> Sequence[ProjectAllocations]: 18 | pass 19 | 20 | @abc.abstractmethod 21 | def add(self, model: ProjectAllocations) -> None: 22 | pass 23 | 24 | @abc.abstractmethod 25 | def find_all_containing_date(self, when: datetime) -> list[ProjectAllocations]: 26 | pass 27 | -------------------------------------------------------------------------------- /smartschedule/allocation/projects_allocations_summary.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.allocation.allocations import Allocations 6 | from smartschedule.allocation.demands import Demands 7 | from smartschedule.allocation.project_allocations import ProjectAllocations 8 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 9 | from smartschedule.shared.timeslot.time_slot import TimeSlot 10 | 11 | 12 | @dataclass(frozen=True) 13 | class ProjectsAllocationsSummary: 14 | time_slots: dict[ProjectAllocationsId, TimeSlot] 15 | project_allocations: dict[ProjectAllocationsId, Allocations] 16 | demands: dict[ProjectAllocationsId, Demands] 17 | 18 | @staticmethod 19 | def of(*all_project_allocations: ProjectAllocations) -> ProjectsAllocationsSummary: 20 | time_slots = { 21 | project_allocations.project_id: project_allocations.time_slot 22 | for project_allocations in all_project_allocations 23 | if project_allocations.has_time_slot() 24 | } 25 | allocations = { 26 | project_allocations.project_id: project_allocations.allocations 27 | for project_allocations in all_project_allocations 28 | } 29 | demands = { 30 | project_allocations.project_id: project_allocations.demands 31 | for project_allocations in all_project_allocations 32 | } 33 | return ProjectsAllocationsSummary(time_slots, allocations, demands) 34 | -------------------------------------------------------------------------------- /smartschedule/allocation/publish_missing_demands_service.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | from smartschedule.allocation.not_satisfied_demands import NotSatisfiedDemands 6 | from smartschedule.allocation.project_allocations import ProjectAllocations 7 | from smartschedule.allocation.project_allocations_repository import ( 8 | ProjectAllocationsRepository, 9 | ) 10 | from smartschedule.shared.events_publisher import EventsPublisher 11 | 12 | 13 | class PublishMissingDemandsService: 14 | def __init__( 15 | self, 16 | repository: ProjectAllocationsRepository, 17 | create_hourly_demands_service: CreateHourlyDemandsSummaryService, 18 | events_publisher: EventsPublisher, 19 | ) -> None: 20 | self._repository = repository 21 | self._create_hourly_demands_service = create_hourly_demands_service 22 | self._events_publisher = events_publisher 23 | 24 | # Run this hourly using some cron job, e.g. Celery Beat 25 | def publish(self) -> None: 26 | when = datetime.now() 27 | project_allocations = self._repository.find_all_containing_date(when) 28 | missing_demands = self._create_hourly_demands_service.create( 29 | project_allocations, when 30 | ) 31 | # add metadata to the event 32 | # if needed call EventStore and translate multiple private events to a new published event 33 | self._events_publisher.publish(missing_demands) 34 | 35 | 36 | class CreateHourlyDemandsSummaryService: 37 | def create( 38 | self, project_allocations: list[ProjectAllocations], when: datetime 39 | ) -> NotSatisfiedDemands: 40 | missing_demands = { 41 | pa.project_id: pa.missing_demands() 42 | for pa in project_allocations 43 | if pa.has_time_slot() 44 | } 45 | return NotSatisfiedDemands(missing_demands=missing_demands, occurred_at=when) 46 | -------------------------------------------------------------------------------- /smartschedule/allocation/sqlalchemy_project_allocations_repository.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import DateTime, cast, select 4 | 5 | from smartschedule.allocation.project_allocations import ProjectAllocations 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.allocation.project_allocations_repository import ( 8 | ProjectAllocationsRepository, 9 | ) 10 | from smartschedule.shared.sqlalchemy_extensions import SQLAlchemyRepository 11 | 12 | 13 | class SqlAlchemyProjectAllocationsRepository( 14 | SQLAlchemyRepository[ProjectAllocations, ProjectAllocationsId], 15 | ProjectAllocationsRepository, 16 | ): 17 | def find_all_containing_date(self, when: datetime) -> list[ProjectAllocations]: 18 | stmt = select(self._type).filter( 19 | cast(self._type.time_slot["from_"].astext, DateTime) <= when, 20 | cast(self._type.time_slot["to"].astext, DateTime) > when, 21 | ) 22 | return list(self._session.execute(stmt).scalars().all()) 23 | -------------------------------------------------------------------------------- /smartschedule/availability/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/availability/__init__.py -------------------------------------------------------------------------------- /smartschedule/availability/blockade.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.availability.owner import Owner 6 | 7 | 8 | @dataclass(frozen=True) 9 | class Blockade: 10 | taken_by: Owner 11 | disabled: bool 12 | 13 | @staticmethod 14 | def none() -> Blockade: 15 | return Blockade(Owner.none(), False) 16 | 17 | @staticmethod 18 | def disabled_by(owner: Owner) -> Blockade: 19 | return Blockade(owner, True) 20 | 21 | @staticmethod 22 | def owned_by(owner: Owner) -> Blockade: 23 | return Blockade(owner, False) 24 | 25 | def can_be_taken_by(self, requester: Owner) -> bool: 26 | return self.taken_by == Owner.none() or self.taken_by == requester 27 | 28 | def is_disabled_by(self, owner: Owner) -> bool: 29 | return self.disabled and self.taken_by == owner 30 | -------------------------------------------------------------------------------- /smartschedule/availability/calendar.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.availability.owner import Owner 6 | from smartschedule.availability.resource_id import ResourceId 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | @dataclass(frozen=True) 11 | class Calendar: 12 | resource_id: ResourceId 13 | calendar: dict[Owner, list[TimeSlot]] 14 | 15 | @staticmethod 16 | def with_available_slots( 17 | resource_id: ResourceId, *available_slots: TimeSlot 18 | ) -> Calendar: 19 | return Calendar(resource_id, {Owner.none(): list(available_slots)}) 20 | 21 | @staticmethod 22 | def empty(resource_id: ResourceId) -> Calendar: 23 | return Calendar(resource_id, {}) 24 | 25 | def available_slots(self) -> list[TimeSlot]: 26 | return self.calendar.get(Owner.none(), []) 27 | 28 | def taken_by(self, requster: Owner) -> list[TimeSlot]: 29 | return self.calendar.get(requster, []) 30 | -------------------------------------------------------------------------------- /smartschedule/availability/calendars.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.availability.calendar import Calendar 6 | from smartschedule.availability.resource_id import ResourceId 7 | 8 | 9 | @dataclass(frozen=True) 10 | class Calendars: 11 | calendars: dict[ResourceId, Calendar] 12 | 13 | @staticmethod 14 | def of(*calendars: Calendar) -> Calendars: 15 | return Calendars({calendar.resource_id: calendar for calendar in calendars}) 16 | 17 | def get(self, resource_id: ResourceId) -> Calendar: 18 | try: 19 | return self.calendars[resource_id] 20 | except KeyError: 21 | return Calendar.empty(resource_id) 22 | -------------------------------------------------------------------------------- /smartschedule/availability/owner.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | 7 | @dataclass(frozen=True) 8 | class Owner: 9 | owner: UUID 10 | 11 | @staticmethod 12 | def none() -> Owner: 13 | return Owner(UUID(int=0)) 14 | 15 | @staticmethod 16 | def new_one() -> Owner: 17 | return Owner(uuid4()) 18 | 19 | def by_none(self) -> bool: 20 | return self.owner == UUID(int=0) 21 | 22 | @property 23 | def id(self) -> UUID: 24 | return self.owner 25 | -------------------------------------------------------------------------------- /smartschedule/availability/resource_availability.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from smartschedule.availability.blockade import Blockade 4 | from smartschedule.availability.owner import Owner 5 | from smartschedule.availability.resource_availability_id import ResourceAvailabilityId 6 | from smartschedule.availability.resource_id import ResourceId 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | class ResourceAvailability: 11 | def __init__( 12 | self, 13 | id: ResourceAvailabilityId, 14 | resource_id: ResourceId, 15 | segment: TimeSlot, 16 | parent_id: ResourceId | None = None, 17 | blockade: Blockade | None = None, 18 | version: int = 0, 19 | ) -> None: 20 | if parent_id is None: 21 | parent_id = ResourceId.new_one() 22 | 23 | if blockade is None: 24 | blockade = Blockade.none() 25 | 26 | self.id: Final = id 27 | self.resource_id: Final = resource_id 28 | self.parent_id: Final = parent_id 29 | self.segment: Final = segment 30 | self.blockade = blockade 31 | self.version = version 32 | 33 | def block(self, requester: Owner) -> bool: 34 | if self._is_available_for(requester): 35 | self.blockade = self.blockade.owned_by(requester) 36 | return True 37 | else: 38 | return False 39 | 40 | def release(self, requester: Owner) -> bool: 41 | if self._is_available_for(requester): 42 | self.blockade = self.blockade.none() 43 | return True 44 | else: 45 | return False 46 | 47 | def disable(self, requester: Owner) -> bool: 48 | self.blockade = Blockade.disabled_by(requester) 49 | return True 50 | 51 | def enable(self, requester: Owner) -> bool: 52 | if self.blockade.can_be_taken_by(requester): 53 | self.blockade = self.blockade.none() 54 | return True 55 | else: 56 | return False 57 | 58 | def is_disabled(self) -> bool: 59 | return self.blockade.disabled 60 | 61 | def is_disabled_by(self, requester: Owner) -> bool: 62 | return self.blockade.is_disabled_by(requester) 63 | 64 | def _is_available_for(self, requester: Owner) -> bool: 65 | return self.blockade.can_be_taken_by(requester) and not self.is_disabled() 66 | 67 | def blocked_by(self) -> Owner: 68 | return self.blockade.taken_by 69 | 70 | def __eq__(self, value: object) -> bool: 71 | return isinstance(value, ResourceAvailability) and value.id == self.id 72 | -------------------------------------------------------------------------------- /smartschedule/availability/resource_availability_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | 7 | @dataclass(frozen=True) 8 | class ResourceAvailabilityId: 9 | id: UUID 10 | 11 | @staticmethod 12 | def none() -> ResourceAvailabilityId: 13 | return ResourceAvailabilityId(UUID(int=0)) 14 | 15 | @staticmethod 16 | def new_one() -> ResourceAvailabilityId: 17 | return ResourceAvailabilityId(uuid4()) 18 | 19 | @staticmethod 20 | def from_str(value: str) -> ResourceAvailabilityId: 21 | return ResourceAvailabilityId(UUID(hex=value)) 22 | -------------------------------------------------------------------------------- /smartschedule/availability/resource_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | 7 | @dataclass(frozen=True) 8 | class ResourceId: 9 | _resource_id: UUID 10 | 11 | @property 12 | def id(self) -> UUID: 13 | return self._resource_id 14 | 15 | @staticmethod 16 | def new_one() -> ResourceId: 17 | return ResourceId(uuid4()) 18 | 19 | def __eq__(self, other: object) -> bool: 20 | if not isinstance(other, ResourceId): 21 | return False 22 | return self._resource_id == other._resource_id 23 | 24 | def __hash__(self) -> int: 25 | return hash(self._resource_id) 26 | 27 | def __repr__(self) -> str: 28 | return f"ResourceId(UUID(hex='{self._resource_id}'))" 29 | 30 | def __str__(self) -> str: 31 | return str(self._resource_id) 32 | -------------------------------------------------------------------------------- /smartschedule/availability/resource_taken_over.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from smartschedule.availability.owner import Owner 6 | from smartschedule.availability.resource_id import ResourceId 7 | from smartschedule.shared.published_event import PublishedEvent 8 | from smartschedule.shared.timeslot.time_slot import TimeSlot 9 | 10 | 11 | @dataclass(frozen=True) 12 | class ResourceTakenOver(PublishedEvent): 13 | resource_id: ResourceId 14 | previous_owners: set[Owner] 15 | slot: TimeSlot 16 | occurred_at: datetime 17 | uuid: UUID = field(default_factory=uuid4) 18 | -------------------------------------------------------------------------------- /smartschedule/availability/segment/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/availability/segment/__init__.py -------------------------------------------------------------------------------- /smartschedule/availability/segment/segment_in_minutes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import timedelta 4 | from typing import Final 5 | 6 | 7 | class SegmentInMinutes: 8 | DEFAULT_SEGMENT_DURATION: Final = timedelta(minutes=60) 9 | DEFAULT_SEGMENT_DURATION_IN_MINUTES: Final = int( 10 | DEFAULT_SEGMENT_DURATION.total_seconds() / 60 11 | ) 12 | 13 | _value: timedelta 14 | 15 | def __init__(self, minutes: int, slot_duration_in_minutes: int) -> None: 16 | if minutes <= 0: 17 | raise ValueError("SegmentInMinutesDuraton must be greater than 0") 18 | if minutes < slot_duration_in_minutes: 19 | raise ValueError( 20 | f"SegmentInMinutesDuraton must be at least {slot_duration_in_minutes} minutes" 21 | ) 22 | if minutes % slot_duration_in_minutes != 0: 23 | raise ValueError( 24 | f"SegmentInMinutesDuraton must be a multiple of {slot_duration_in_minutes} minutes" 25 | ) 26 | 27 | self._value = timedelta(minutes=minutes) 28 | 29 | @staticmethod 30 | def of(minutes: int) -> SegmentInMinutes: 31 | return SegmentInMinutes( 32 | minutes, SegmentInMinutes.DEFAULT_SEGMENT_DURATION_IN_MINUTES 33 | ) 34 | 35 | @property 36 | def value(self) -> timedelta: 37 | return self._value 38 | 39 | @classmethod 40 | def default_segment(cls) -> SegmentInMinutes: 41 | return SegmentInMinutes( 42 | cls.DEFAULT_SEGMENT_DURATION_IN_MINUTES, 43 | cls.DEFAULT_SEGMENT_DURATION_IN_MINUTES, 44 | ) 45 | -------------------------------------------------------------------------------- /smartschedule/availability/segment/segments.py: -------------------------------------------------------------------------------- 1 | from smartschedule.availability.segment.segment_in_minutes import SegmentInMinutes 2 | from smartschedule.availability.segment.slot_to_normalized_slot import ( 3 | slot_to_normalized_slot, 4 | ) 5 | from smartschedule.availability.segment.slot_to_segments import slot_to_segments 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | 8 | 9 | def split(time_slot: TimeSlot, unit: SegmentInMinutes) -> list[TimeSlot]: 10 | normalized_slot = normalize_to_segment_boundaries(time_slot, unit) 11 | return slot_to_segments(normalized_slot, unit) 12 | 13 | 14 | def normalize_to_segment_boundaries( 15 | time_slot: TimeSlot, unit: SegmentInMinutes 16 | ) -> TimeSlot: 17 | return slot_to_normalized_slot(time_slot, unit) 18 | -------------------------------------------------------------------------------- /smartschedule/availability/segment/slot_to_normalized_slot.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from smartschedule.availability.segment.segment_in_minutes import SegmentInMinutes 4 | from smartschedule.shared.timeslot.time_slot import TimeSlot 5 | 6 | 7 | def slot_to_normalized_slot( 8 | time_slot: TimeSlot, segment_in_minutes: SegmentInMinutes 9 | ) -> TimeSlot: 10 | segment_start = _normalize_start(time_slot.from_, segment_in_minutes) 11 | segment_end = _normalize_end(time_slot.to, segment_in_minutes) 12 | normalized = TimeSlot(segment_start, segment_end) 13 | minimal_segment = TimeSlot(segment_start, segment_start + segment_in_minutes.value) 14 | if normalized.within(minimal_segment): 15 | return minimal_segment 16 | return normalized 17 | 18 | 19 | def _normalize_start( 20 | initial_start: datetime, segment_in_minutes: SegmentInMinutes 21 | ) -> datetime: 22 | closest_segment_start = initial_start.replace(minute=0, second=0, microsecond=0) 23 | if closest_segment_start + segment_in_minutes.value > initial_start: 24 | return closest_segment_start 25 | while closest_segment_start < initial_start: 26 | closest_segment_start += segment_in_minutes.value 27 | return closest_segment_start 28 | 29 | 30 | def _normalize_end( 31 | initial_end: datetime, segment_in_minutes: SegmentInMinutes 32 | ) -> datetime: 33 | closest_segment_end = initial_end.replace(minute=0, second=0, microsecond=0) 34 | while initial_end > closest_segment_end: 35 | closest_segment_end += segment_in_minutes.value 36 | return closest_segment_end 37 | -------------------------------------------------------------------------------- /smartschedule/availability/segment/slot_to_segments.py: -------------------------------------------------------------------------------- 1 | import math 2 | from datetime import datetime 3 | 4 | from smartschedule.availability.segment.segment_in_minutes import SegmentInMinutes 5 | from smartschedule.shared.timeslot.time_slot import TimeSlot 6 | 7 | 8 | def slot_to_segments(time_slot: TimeSlot, duration: SegmentInMinutes) -> list[TimeSlot]: 9 | minimal_segment = TimeSlot(time_slot.from_, time_slot.from_ + duration.value) 10 | if time_slot.within(minimal_segment): 11 | return [minimal_segment] 12 | number_of_segments = _calculate_number_of_segments(time_slot, duration) 13 | 14 | current_start = time_slot.from_ 15 | result = [] 16 | for _ in range(number_of_segments): 17 | current_end = _calculate_end(duration, current_start, time_slot.to) 18 | slot = TimeSlot(current_start, current_end) 19 | result.append(slot) 20 | current_start = current_start + duration.value 21 | return result 22 | 23 | 24 | def _calculate_number_of_segments( 25 | time_slot: TimeSlot, duration: SegmentInMinutes 26 | ) -> int: 27 | return math.ceil( 28 | time_slot.duration.total_seconds() / duration.value.total_seconds() 29 | ) 30 | 31 | 32 | def _calculate_end( 33 | duration: SegmentInMinutes, current_start: datetime, initial_end: datetime 34 | ) -> datetime: 35 | return min(current_start + duration.value, initial_end) 36 | -------------------------------------------------------------------------------- /smartschedule/container.py: -------------------------------------------------------------------------------- 1 | from lagom import Container 2 | from redis import Redis 3 | 4 | from smartschedule.allocation.cashflow.cashflow_repository import CashflowRepository 5 | from smartschedule.allocation.cashflow.sqlalchemy_cashflow_repository import ( 6 | SqlAlchemyCashflowRepository, 7 | ) 8 | from smartschedule.allocation.project_allocations_repository import ( 9 | ProjectAllocationsRepository, 10 | ) 11 | from smartschedule.allocation.sqlalchemy_project_allocations_repository import ( 12 | SqlAlchemyProjectAllocationsRepository, 13 | ) 14 | from smartschedule.planning.project_repository import ProjectRepository 15 | from smartschedule.planning.redis_project_repository import ( 16 | RedisProjectRepository, 17 | ) 18 | from smartschedule.shared.event_bus import EventBus, SyncExecutor 19 | from smartschedule.shared.events_publisher import EventsPublisher 20 | 21 | 22 | def build() -> Container: 23 | container = Container() 24 | executor = SyncExecutor() 25 | container[EventsPublisher] = lambda c: EventBus(c, executor) # type: ignore[type-abstract] 26 | container[EventBus] = lambda c: EventBus(c, executor) 27 | container[CashflowRepository] = SqlAlchemyCashflowRepository # type: ignore[type-abstract] 28 | container[ProjectRepository] = lambda c: RedisProjectRepository(c[Redis]) # type: ignore[type-abstract] 29 | container[ProjectAllocationsRepository] = SqlAlchemyProjectAllocationsRepository # type: ignore[type-abstract] 30 | return container 31 | -------------------------------------------------------------------------------- /smartschedule/optimization/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/optimization/__init__.py -------------------------------------------------------------------------------- /smartschedule/optimization/capacity_dimension.py: -------------------------------------------------------------------------------- 1 | class CapacityDimension: 2 | pass 3 | -------------------------------------------------------------------------------- /smartschedule/optimization/item.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.optimization.capacity_dimension import CapacityDimension 4 | from smartschedule.optimization.total_weight import TotalWeight 5 | 6 | 7 | @dataclass(frozen=True) 8 | class Item[T: CapacityDimension]: 9 | name: str 10 | value: float 11 | total_weight: TotalWeight[T] 12 | 13 | def is_weight_zero(self) -> bool: 14 | return len(self.total_weight.components) == 0 15 | 16 | def __hash__(self) -> int: 17 | return hash((self.name, self.value)) 18 | -------------------------------------------------------------------------------- /smartschedule/optimization/result.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.optimization.capacity_dimension import CapacityDimension 4 | from smartschedule.optimization.item import Item 5 | 6 | 7 | @dataclass(frozen=True) 8 | class Result[T: CapacityDimension]: 9 | profit: float 10 | chosen_items: list[Item[T]] 11 | item_to_capacities: dict[Item[T], set[CapacityDimension]] 12 | 13 | def __str__(self) -> str: 14 | return f"Result{{profit={self.profit}, chosen_items={self.chosen_items}}}" 15 | -------------------------------------------------------------------------------- /smartschedule/optimization/total_capacity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.optimization.capacity_dimension import CapacityDimension 6 | 7 | 8 | @dataclass(frozen=True) 9 | class TotalCapacity: 10 | capacities: list[CapacityDimension] 11 | 12 | @classmethod 13 | def of(cls, *capacities: CapacityDimension) -> TotalCapacity: 14 | return TotalCapacity(list(capacities)) 15 | 16 | @classmethod 17 | def zero(cls) -> TotalCapacity: 18 | return TotalCapacity([]) 19 | 20 | def add(self, capacities: list[CapacityDimension]) -> TotalCapacity: 21 | return TotalCapacity(self.capacities + capacities) 22 | 23 | def __len__(self) -> int: 24 | return len(self.capacities) 25 | -------------------------------------------------------------------------------- /smartschedule/optimization/total_weight.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.optimization.capacity_dimension import CapacityDimension 6 | from smartschedule.optimization.weight_dimension import WeightDimension 7 | 8 | 9 | @dataclass(frozen=True) 10 | class TotalWeight[T: CapacityDimension]: 11 | components: list[WeightDimension[T]] 12 | 13 | @classmethod 14 | def zero(cls) -> TotalWeight[T]: 15 | return TotalWeight[T]([]) 16 | 17 | @classmethod 18 | def of(cls, *components: WeightDimension[T]) -> TotalWeight[T]: 19 | return TotalWeight(list(components)) 20 | -------------------------------------------------------------------------------- /smartschedule/optimization/weight_dimension.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from smartschedule.optimization.capacity_dimension import CapacityDimension 4 | 5 | 6 | class WeightDimension[T: CapacityDimension](abc.ABC): 7 | @abc.abstractmethod 8 | def is_satisfied_by(self, capacity: T) -> bool: 9 | pass 10 | -------------------------------------------------------------------------------- /smartschedule/planning/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/planning/__init__.py -------------------------------------------------------------------------------- /smartschedule/planning/capabilities_demanded.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from uuid import UUID, uuid4 4 | 5 | from smartschedule.planning.demands import Demands 6 | from smartschedule.planning.project_id import ProjectId 7 | from smartschedule.shared.published_event import PublishedEvent 8 | 9 | 10 | @dataclass(frozen=True) 11 | class CapabilitiesDemanded(PublishedEvent): 12 | project_id: ProjectId 13 | demands: Demands 14 | occurred_at: datetime 15 | uuid: UUID = field(default_factory=uuid4) 16 | -------------------------------------------------------------------------------- /smartschedule/planning/chosen_resources.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.availability.resource_id import ResourceId 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | 8 | 9 | @dataclass(frozen=True) 10 | class ChosenResources: 11 | resources: set[ResourceId] 12 | time_slot: TimeSlot 13 | 14 | @staticmethod 15 | def none() -> ChosenResources: 16 | return ChosenResources(set(), TimeSlot.empty()) 17 | -------------------------------------------------------------------------------- /smartschedule/planning/create_project_allocations.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.allocation_facade import AllocationFacade 2 | from smartschedule.planning.project_id import ProjectId 3 | from smartschedule.planning.project_repository import ProjectRepository 4 | 5 | 6 | class CreateProjectAllocations: 7 | def __init__( 8 | self, allocation_facade: AllocationFacade, project_repository: ProjectRepository 9 | ) -> None: 10 | self._allocation_facade = allocation_facade 11 | self._project_repository = project_repository 12 | 13 | # can react to ScheduleCalculated event 14 | def create_project_allocations(self, project_id: ProjectId) -> None: 15 | project = self._project_repository.get(project_id) 16 | schedule = project.schedule # noqa: F841 17 | # for each stage in schedule 18 | # create allocation 19 | # allocate chosen resources (or find equivalents) 20 | # start risk analysis 21 | -------------------------------------------------------------------------------- /smartschedule/planning/critical_stage_planned.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from smartschedule.availability.resource_id import ResourceId 5 | from smartschedule.planning.project_id import ProjectId 6 | from smartschedule.shared.published_event import PublishedEvent 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | @dataclass(frozen=True) 11 | class CriticalStagePlanned(PublishedEvent): 12 | project_id: ProjectId 13 | stage_time_slot: TimeSlot 14 | critical_resource_id: ResourceId | None 15 | occurred_at: datetime 16 | -------------------------------------------------------------------------------- /smartschedule/planning/demand.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.shared.capability.capability import Capability 6 | 7 | 8 | @dataclass(frozen=True) 9 | class Demand: 10 | capability: Capability 11 | 12 | @staticmethod 13 | def for_(capability: Capability) -> Demand: 14 | return Demand(capability) 15 | -------------------------------------------------------------------------------- /smartschedule/planning/demands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.planning.demand import Demand 6 | 7 | 8 | @dataclass(frozen=True) 9 | class Demands: 10 | all: list[Demand] 11 | 12 | @staticmethod 13 | def none() -> Demands: 14 | return Demands([]) 15 | 16 | @staticmethod 17 | def of(*demands: Demand) -> Demands: 18 | return Demands(list(demands)) 19 | 20 | def __add__(self, other: Demands) -> Demands: 21 | return Demands(self.all + other.all) 22 | -------------------------------------------------------------------------------- /smartschedule/planning/demands_per_stage.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.planning.demands import Demands 6 | 7 | 8 | @dataclass(frozen=True) 9 | class DemandsPerStage: 10 | demands: dict[str, Demands] 11 | 12 | @staticmethod 13 | def empty() -> DemandsPerStage: 14 | return DemandsPerStage({}) 15 | -------------------------------------------------------------------------------- /smartschedule/planning/edit_stage_date_service.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.allocation_facade import AllocationFacade 2 | from smartschedule.planning.parallelization.stage import Stage 3 | from smartschedule.planning.project_id import ProjectId 4 | from smartschedule.planning.project_repository import ProjectRepository 5 | from smartschedule.shared.timeslot.time_slot import TimeSlot 6 | 7 | 8 | class EditStageDateService: 9 | def __init__( 10 | self, allocation_facade: AllocationFacade, project_repository: ProjectRepository 11 | ) -> None: 12 | self._allocation_facade = allocation_facade 13 | self._project_repository = project_repository 14 | 15 | def edit_stage_date( 16 | self, project_id: ProjectId, stage: Stage, time_slot: TimeSlot 17 | ) -> None: 18 | project = self._project_repository.get(project_id) 19 | schedule = project.schedule # noqa: F841 20 | # redefine schedule 21 | # for each stage in schedule 22 | # recreate allocation 23 | # reallocate chosen resources (or find equivalents) 24 | # start risk analysis 25 | -------------------------------------------------------------------------------- /smartschedule/planning/needed_resource_chosen.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from smartschedule.availability.resource_id import ResourceId 5 | from smartschedule.planning.project_id import ProjectId 6 | from smartschedule.shared.published_event import PublishedEvent 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | @dataclass(frozen=True) 11 | class NeededResourcesChosen(PublishedEvent): 12 | project_id: ProjectId 13 | needed_resources: set[ResourceId] 14 | time_slot: TimeSlot 15 | occurred_at: datetime 16 | -------------------------------------------------------------------------------- /smartschedule/planning/needed_resources_chosen.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/planning/needed_resources_chosen.py -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/planning/parallelization/__init__.py -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/duration_calculator.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from typing import Iterable 3 | 4 | from smartschedule.planning.parallelization.stage import Stage 5 | from smartschedule.planning.parallelization.stage_parallelization import ( 6 | StageParallelization, 7 | ) 8 | 9 | 10 | def calculate_duration(stages: Iterable[Stage]) -> timedelta: 11 | parallelized_stages = StageParallelization().of(set(stages)) 12 | return sum( 13 | (parallel_stages.duration for parallel_stages in parallelized_stages.all), 14 | timedelta(), 15 | ) 16 | -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/parallel_stages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import timedelta 5 | 6 | from smartschedule.planning.parallelization.stage import Stage 7 | 8 | 9 | @dataclass 10 | class ParallelStages: 11 | stages: set[Stage] 12 | 13 | @staticmethod 14 | def of(*stages: Stage) -> ParallelStages: 15 | return ParallelStages(set(stages)) 16 | 17 | @property 18 | def duration(self) -> timedelta: 19 | return max([stage.duration for stage in self.stages], default=timedelta()) 20 | 21 | def __str__(self) -> str: 22 | sorted_stages = sorted(self.stages, key=lambda stage: stage.name) 23 | return ", ".join([str(stage) for stage in sorted_stages]) 24 | -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/parallel_stages_list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Callable 5 | 6 | from smartschedule.planning.parallelization.parallel_stages import ParallelStages 7 | from smartschedule.shared.typing_extensions import Comparable 8 | 9 | 10 | @dataclass 11 | class ParallelStagesList: 12 | all: list[ParallelStages] 13 | 14 | @staticmethod 15 | def empty() -> ParallelStagesList: 16 | return ParallelStagesList([]) 17 | 18 | @staticmethod 19 | def of(*parallel_stages: ParallelStages) -> ParallelStagesList: 20 | return ParallelStagesList(list(parallel_stages)) 21 | 22 | def add(self, parallel_stages: ParallelStages) -> ParallelStagesList: 23 | concatenated_lists = self.all + [parallel_stages] 24 | return ParallelStagesList(concatenated_lists) 25 | 26 | def all_sorted( 27 | self, sort_key_getter: Callable[[ParallelStages], Comparable] 28 | ) -> list[ParallelStages]: 29 | return sorted(self.all, key=sort_key_getter) 30 | 31 | def __str__(self) -> str: 32 | return " | ".join([str(parallel_stages) for parallel_stages in self.all]) 33 | -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/sorted_nodes_to_parallelized_stages.py: -------------------------------------------------------------------------------- 1 | from smartschedule.planning.parallelization.parallel_stages import ParallelStages 2 | from smartschedule.planning.parallelization.parallel_stages_list import ( 3 | ParallelStagesList, 4 | ) 5 | from smartschedule.planning.parallelization.stage import Stage 6 | from smartschedule.sorter.sorted_nodes import SortedNodes 7 | 8 | 9 | class SortedNodesToParallelizedStages: 10 | def calculate(self, sorted_nodes: SortedNodes[Stage]) -> ParallelStagesList: 11 | parallelized = [ 12 | ParallelStages({node.content for node in nodes.nodes}) 13 | for nodes in sorted_nodes.all 14 | ] 15 | return ParallelStagesList(parallelized) 16 | -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/stage.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from datetime import timedelta 5 | 6 | from smartschedule.availability.resource_id import ResourceId 7 | 8 | 9 | @dataclass() 10 | class Stage: 11 | name: str 12 | dependencies: set[Stage] = field(default_factory=set) 13 | resources: frozenset[ResourceId] = field(default_factory=frozenset) 14 | duration: timedelta = field(default_factory=timedelta) 15 | 16 | def depends_on(self, stage: Stage) -> Stage: 17 | new_dependencies = self.dependencies.union({stage}) 18 | self.dependencies = new_dependencies 19 | return Stage(self.name, new_dependencies, self.resources, self.duration) 20 | 21 | def with_chosen_resource_capabilities(self, *resources: ResourceId) -> Stage: 22 | return Stage(self.name, self.dependencies, frozenset(resources), self.duration) 23 | 24 | def of_duration(self, duration: timedelta) -> Stage: 25 | return Stage(self.name, self.dependencies, self.resources, duration) 26 | 27 | def __str__(self) -> str: 28 | return self.name 29 | 30 | def __hash__(self) -> int: 31 | return hash(self.name) 32 | -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/stage_parallelization.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.planning.parallelization.parallel_stages_list import ( 6 | ParallelStagesList, 7 | ) 8 | from smartschedule.planning.parallelization.sorted_nodes_to_parallelized_stages import ( 9 | SortedNodesToParallelizedStages, 10 | ) 11 | from smartschedule.planning.parallelization.stage import Stage 12 | from smartschedule.planning.parallelization.stages_to_nodes import StagesToNodes 13 | from smartschedule.sorter.edge import Edge 14 | from smartschedule.sorter.feedback_arc_se_on_graph import FeedbackArcSeOnGraph 15 | from smartschedule.sorter.graph_topological_sort import GraphTopologicalSort 16 | 17 | 18 | class StageParallelization: 19 | def of(self, stages: set[Stage]) -> ParallelStagesList: 20 | nodes = StagesToNodes().calculate(list(stages)) 21 | sorted_nodes = GraphTopologicalSort[Stage]().sort(nodes) 22 | return SortedNodesToParallelizedStages().calculate(sorted_nodes) 23 | 24 | def what_to_remove(self, stages: set[Stage]) -> RemovalSuggestion: 25 | nodes = StagesToNodes().calculate(list(stages)) 26 | result = FeedbackArcSeOnGraph[Stage]().calculate(list(nodes.nodes)) 27 | return RemovalSuggestion(result) 28 | 29 | 30 | @dataclass(frozen=True) 31 | class RemovalSuggestion: 32 | edges: list[Edge] 33 | 34 | def __str__(self) -> str: 35 | return f"[{', '.join(str(edge) for edge in self.edges)}]" 36 | -------------------------------------------------------------------------------- /smartschedule/planning/parallelization/stages_to_nodes.py: -------------------------------------------------------------------------------- 1 | from smartschedule.planning.parallelization.stage import Stage 2 | from smartschedule.sorter.node import Node 3 | from smartschedule.sorter.nodes import Nodes 4 | 5 | 6 | class StagesToNodes: 7 | def calculate(self, stages: list[Stage]) -> Nodes[Stage]: 8 | result: dict[str, Node[Stage]] = { 9 | stage.name: Node(stage.name, stage) for stage in stages 10 | } 11 | 12 | for i, stage in enumerate(stages): 13 | self._explicit_dependencies(stage, result) 14 | self._shared_resources(stage, stages[i + 1 :], result) 15 | 16 | return Nodes(set(result.values())) 17 | 18 | def _shared_resources( 19 | self, stage: Stage, with_stages: list[Stage], result: dict[str, Node[Stage]] 20 | ) -> None: 21 | for other in with_stages: 22 | if stage.name != other.name: 23 | if not stage.resources.isdisjoint(other.resources): 24 | if len(other.resources) > len(stage.resources): 25 | node = result[stage.name].depends_on(result[other.name]) 26 | result[stage.name] = node 27 | else: 28 | node = result[other.name].depends_on(result[stage.name]) 29 | result[other.name] = node 30 | 31 | def _explicit_dependencies( 32 | self, stage: Stage, result: dict[str, Node[Stage]] 33 | ) -> None: 34 | node_with_explicit_deps = result[stage.name] 35 | for explicit_dependency in stage.dependencies: 36 | node_with_explicit_deps = node_with_explicit_deps.depends_on( 37 | result[explicit_dependency.name] 38 | ) 39 | result[stage.name] = node_with_explicit_deps 40 | -------------------------------------------------------------------------------- /smartschedule/planning/plan_chosen_resources.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from itertools import chain 3 | 4 | from smartschedule.availability.availability_facade import AvailabilityFacade 5 | from smartschedule.availability.calendars import Calendars 6 | from smartschedule.availability.resource_id import ResourceId 7 | from smartschedule.planning.chosen_resources import ChosenResources 8 | from smartschedule.planning.needed_resource_chosen import NeededResourcesChosen 9 | from smartschedule.planning.parallelization.stage import Stage 10 | from smartschedule.planning.project_id import ProjectId 11 | from smartschedule.planning.project_repository import ProjectRepository 12 | from smartschedule.planning.schedule.schedule import Schedule 13 | from smartschedule.shared.events_publisher import EventsPublisher 14 | from smartschedule.shared.timeslot.time_slot import TimeSlot 15 | 16 | 17 | class PlanChosenResources: 18 | def __init__( 19 | self, 20 | project_repository: ProjectRepository, 21 | availability_facade: AvailabilityFacade, 22 | events_publisher: EventsPublisher, 23 | ) -> None: 24 | self._project_repository = project_repository 25 | self._availability_facade = availability_facade 26 | self._events_publisher = events_publisher 27 | 28 | def define_resources_within_dates( 29 | self, 30 | project_id: ProjectId, 31 | resources: set[ResourceId], 32 | time_boundaries: TimeSlot, 33 | ) -> None: 34 | project = self._project_repository.get(id=project_id) 35 | chosen_resources = ChosenResources(resources, time_boundaries) 36 | project.add_chosen_resources(chosen_resources) 37 | event = NeededResourcesChosen( 38 | project_id, resources, time_boundaries, datetime.now() 39 | ) 40 | self._project_repository.save(project) 41 | self._events_publisher.publish(event) 42 | 43 | def adjust_stages_to_resource_availability( 44 | self, project_id: ProjectId, time_boundaries: TimeSlot, *stages: Stage 45 | ) -> None: 46 | needed_resources = set(chain.from_iterable(stage.resources for stage in stages)) 47 | project = self._project_repository.get(id=project_id) 48 | self.define_resources_within_dates( 49 | project_id, needed_resources, time_boundaries 50 | ) 51 | needed_resources_calendars = self._availability_facade.load_calendars( 52 | needed_resources, time_boundaries 53 | ) 54 | schedule = self._create_schedule_adjusting_to_calendars( 55 | needed_resources_calendars, *stages 56 | ) 57 | project.add_schedule(schedule) 58 | self._project_repository.save(project) 59 | 60 | def _create_schedule_adjusting_to_calendars( 61 | self, needed_resources_calendars: Calendars, *stages: Stage 62 | ) -> Schedule: 63 | return Schedule.based_on_chosen_resource_availability( 64 | needed_resources_calendars, list(stages) 65 | ) 66 | -------------------------------------------------------------------------------- /smartschedule/planning/project.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import date 3 | 4 | from smartschedule.planning.chosen_resources import ChosenResources 5 | from smartschedule.planning.demands import Demands 6 | from smartschedule.planning.demands_per_stage import DemandsPerStage 7 | from smartschedule.planning.parallelization.parallel_stages_list import ( 8 | ParallelStagesList, 9 | ) 10 | from smartschedule.planning.parallelization.stage import Stage 11 | from smartschedule.planning.project_id import ProjectId 12 | from smartschedule.planning.schedule.schedule import Schedule 13 | from smartschedule.shared.timeslot.time_slot import TimeSlot 14 | 15 | 16 | @dataclass 17 | class Project: 18 | id: ProjectId 19 | name: str 20 | parallelized_stages: ParallelStagesList 21 | demands_per_stage: DemandsPerStage 22 | all_demands: Demands 23 | chosen_resources: ChosenResources 24 | schedule: Schedule 25 | 26 | def __init__(self, name: str, parallelized_stages: ParallelStagesList) -> None: 27 | self.id = ProjectId.new_one() 28 | self.name = name 29 | self.parallelized_stages = parallelized_stages 30 | self.demands_per_stage = DemandsPerStage.empty() 31 | self.all_demands = Demands.none() 32 | self.chosen_resources = ChosenResources.none() 33 | self.schedule = Schedule.none() 34 | 35 | def add_demands(self, demands: Demands) -> None: 36 | self.all_demands = self.all_demands + demands 37 | 38 | def add_schedule(self, schedule: Schedule) -> None: 39 | self.schedule = schedule 40 | 41 | def add_schedule_by_start_date(self, possible_start_date: date) -> None: 42 | self.schedule = Schedule.based_on_start_day( 43 | possible_start_date, self.parallelized_stages 44 | ) 45 | 46 | def add_schedule_by_critical_stage( 47 | self, critical_stage: Stage, stage_time_slot: TimeSlot 48 | ) -> None: 49 | self.schedule = Schedule.based_on_reference_stage_time_slots( 50 | critical_stage, stage_time_slot, self.parallelized_stages 51 | ) 52 | 53 | def add_chosen_resources(self, needed_resources: ChosenResources) -> None: 54 | self.chosen_resources = needed_resources 55 | 56 | def add_demands_per_stage(self, demands_per_stage: DemandsPerStage) -> None: 57 | self.demands_per_stage = demands_per_stage 58 | unique_demands = set() 59 | for demands in demands_per_stage.demands.values(): 60 | unique_demands.update(demands.all) 61 | self.add_demands(Demands(list(unique_demands))) 62 | 63 | def define_stages(self, parallelized_stages: ParallelStagesList) -> None: 64 | self.parallelized_stages = parallelized_stages 65 | -------------------------------------------------------------------------------- /smartschedule/planning/project_card.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.planning.chosen_resources import ChosenResources 4 | from smartschedule.planning.demands import Demands 5 | from smartschedule.planning.demands_per_stage import DemandsPerStage 6 | from smartschedule.planning.parallelization.parallel_stages_list import ( 7 | ParallelStagesList, 8 | ) 9 | from smartschedule.planning.project_id import ProjectId 10 | from smartschedule.planning.schedule.schedule import Schedule 11 | 12 | 13 | @dataclass(frozen=True) 14 | class ProjectCard: 15 | project_id: ProjectId 16 | name: str 17 | parallelized_stages: ParallelStagesList 18 | demands: Demands 19 | schedule: Schedule 20 | demands_per_stage: DemandsPerStage 21 | needed_resources: ChosenResources 22 | -------------------------------------------------------------------------------- /smartschedule/planning/project_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | 7 | @dataclass(frozen=True) 8 | class ProjectId: 9 | uuid: UUID 10 | 11 | @property 12 | def id(self) -> UUID: 13 | return self.uuid 14 | 15 | @staticmethod 16 | def new_one() -> ProjectId: 17 | return ProjectId(uuid4()) 18 | -------------------------------------------------------------------------------- /smartschedule/planning/project_repository.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Sequence 3 | 4 | from smartschedule.planning.project import Project 5 | from smartschedule.planning.project_id import ProjectId 6 | 7 | 8 | class ProjectRepository(abc.ABC): 9 | @abc.abstractmethod 10 | def get(self, id: ProjectId) -> Project: 11 | pass 12 | 13 | @abc.abstractmethod 14 | def get_all(self, ids: list[ProjectId] | None = None) -> Sequence[Project]: 15 | pass 16 | 17 | @abc.abstractmethod 18 | def save(self, model: Project) -> None: 19 | pass 20 | -------------------------------------------------------------------------------- /smartschedule/planning/redis_project_repository.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Final, Sequence, cast 3 | 4 | from pydantic import TypeAdapter 5 | from redis import Redis 6 | 7 | from smartschedule.planning.project import Project 8 | from smartschedule.planning.project_id import ProjectId 9 | from smartschedule.planning.project_repository import ProjectRepository 10 | from smartschedule.shared.repository import NotFound 11 | 12 | 13 | class RedisProjectRepository(ProjectRepository): 14 | HASH_NAME: Final = "projects" 15 | 16 | def __init__(self, client: Redis) -> None: 17 | self._client = client 18 | self._type_adapter = TypeAdapter[Project](Project) 19 | 20 | def get(self, id: ProjectId) -> Project: 21 | if data := self._client.hget(self.HASH_NAME, self._id_to_key(id)): 22 | as_json = json.loads(cast(str, data)) 23 | return self._type_adapter.validate_python(as_json) 24 | raise NotFound 25 | 26 | def get_all(self, ids: list[ProjectId] | None = None) -> Sequence[Project]: 27 | if ids is None: 28 | hash_contents = cast(dict[str, str], self._client.hgetall(self.HASH_NAME)) 29 | raw_projects = list(hash_contents.values()) 30 | else: 31 | keys = [self._id_to_key(id) for id in ids] 32 | raw_projects = cast(list[str], self._client.hmget(self.HASH_NAME, keys)) 33 | return [ 34 | self._type_adapter.validate_python(json.loads(data)) 35 | for data in raw_projects 36 | if data 37 | ] 38 | 39 | def save(self, model: Project) -> None: 40 | key = self._id_to_key(model.id) 41 | as_dict = self._type_adapter.dump_python(model, mode="json") 42 | as_json = json.dumps(as_dict) 43 | self._client.hset(self.HASH_NAME, key, as_json) 44 | 45 | def _id_to_key(self, id: ProjectId) -> str: 46 | return id.id.hex 47 | -------------------------------------------------------------------------------- /smartschedule/planning/schedule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/planning/schedule/__init__.py -------------------------------------------------------------------------------- /smartschedule/planning/schedule/schedule.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import date 5 | 6 | from smartschedule.availability.calendars import Calendars 7 | from smartschedule.planning.parallelization.parallel_stages import ParallelStages 8 | from smartschedule.planning.parallelization.parallel_stages_list import ( 9 | ParallelStagesList, 10 | ) 11 | from smartschedule.planning.parallelization.stage import Stage 12 | from smartschedule.planning.schedule.schedule_based_on_chosen_resources_availability_calculator import ( 13 | ScheduleBasedOnChosenResourcesAvailabilityCalculator, 14 | ) 15 | from smartschedule.planning.schedule.schedule_based_on_reference_stage_calculator import ( 16 | ScheduleBasedOnReferenceStageCalculator, 17 | ) 18 | from smartschedule.planning.schedule.schedule_based_on_start_day_calculator import ( 19 | ScheduleBasedOnStartDayCalculator, 20 | ) 21 | from smartschedule.shared.timeslot.time_slot import TimeSlot 22 | 23 | 24 | @dataclass(frozen=True) 25 | class Schedule: 26 | dates: dict[str, TimeSlot] 27 | 28 | @staticmethod 29 | def none() -> Schedule: 30 | return Schedule({}) 31 | 32 | @staticmethod 33 | def based_on_start_day( 34 | start_date: date, parallelized_stages: ParallelStagesList 35 | ) -> Schedule: 36 | schedule_dict = ScheduleBasedOnStartDayCalculator().calculate( 37 | start_date, parallelized_stages, printing_comparator 38 | ) 39 | return Schedule(schedule_dict) 40 | 41 | @staticmethod 42 | def based_on_reference_stage_time_slots( 43 | reference_stage: Stage, 44 | stage_proposed_time_slot: TimeSlot, 45 | parallelized_stages: ParallelStagesList, 46 | ) -> Schedule: 47 | schedule_dict = ScheduleBasedOnReferenceStageCalculator().calculate( 48 | reference_stage, 49 | stage_proposed_time_slot, 50 | parallelized_stages, 51 | printing_comparator, 52 | ) 53 | return Schedule(schedule_dict) 54 | 55 | @staticmethod 56 | def based_on_chosen_resource_availability( 57 | chosen_resources_calendars: Calendars, stages: list[Stage] 58 | ) -> Schedule: 59 | schedule_dict = ( 60 | ScheduleBasedOnChosenResourcesAvailabilityCalculator().calculate( 61 | chosen_resources_calendars, 62 | stages, 63 | ) 64 | ) 65 | return Schedule(schedule_dict) 66 | 67 | 68 | def printing_comparator(parallel_stages: ParallelStages) -> int: 69 | print(parallel_stages) 70 | return 0 71 | -------------------------------------------------------------------------------- /smartschedule/planning/schedule/schedule_based_on_chosen_resources_availability_calculator.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from datetime import timedelta 3 | 4 | from smartschedule.availability.calendars import Calendars 5 | from smartschedule.planning.parallelization.stage import Stage 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | 8 | 9 | class ScheduleBasedOnChosenResourcesAvailabilityCalculator: 10 | def calculate( 11 | self, chosen_resources_calendars: Calendars, stages: list[Stage] 12 | ) -> dict[str, TimeSlot]: 13 | schedule = {} 14 | for stage in stages: 15 | proposed_slot = self._find_slot_for_stage(chosen_resources_calendars, stage) 16 | if proposed_slot == TimeSlot.empty(): 17 | return {} 18 | schedule[stage.name] = proposed_slot 19 | return schedule 20 | 21 | def _find_slot_for_stage( 22 | self, chosen_resources_calendars: Calendars, stage: Stage 23 | ) -> TimeSlot: 24 | found_slots = self._possible_slots(chosen_resources_calendars, stage) 25 | if TimeSlot.empty() in found_slots: 26 | return TimeSlot.empty() 27 | 28 | common_slot_for_all_resources = self._find_common_part_of_slots(found_slots) 29 | 30 | while not self._is_slot_long_enough_for_stage( 31 | stage, common_slot_for_all_resources 32 | ): 33 | common_slot_for_all_resources = common_slot_for_all_resources.stretch( 34 | timedelta(days=1) 35 | ) 36 | 37 | return TimeSlot( 38 | common_slot_for_all_resources.from_, 39 | common_slot_for_all_resources.from_ + stage.duration, 40 | ) 41 | 42 | def _is_slot_long_enough_for_stage(self, stage: Stage, slot: TimeSlot) -> bool: 43 | return slot.duration >= stage.duration 44 | 45 | def _find_common_part_of_slots(self, found_slots: list[TimeSlot]) -> TimeSlot: 46 | return functools.reduce(TimeSlot.common_part_with, found_slots) 47 | 48 | def _possible_slots( 49 | self, chosen_resources_calendars: Calendars, stage: Stage 50 | ) -> list[TimeSlot]: 51 | result = [] 52 | for resource in stage.resources: 53 | calendar = chosen_resources_calendars.get(resource) 54 | matching_slots = [ 55 | slot 56 | for slot in calendar.available_slots() 57 | if self._is_slot_long_enough_for_stage(stage, slot) 58 | ] 59 | 60 | matching_slot = next(iter(matching_slots), TimeSlot.empty()) 61 | result.append(matching_slot) 62 | 63 | return result 64 | -------------------------------------------------------------------------------- /smartschedule/planning/schedule/schedule_based_on_start_day_calculator.py: -------------------------------------------------------------------------------- 1 | from datetime import date, datetime 2 | from typing import Callable 3 | 4 | from smartschedule.planning.parallelization.parallel_stages import ParallelStages 5 | from smartschedule.planning.parallelization.parallel_stages_list import ( 6 | ParallelStagesList, 7 | ) 8 | from smartschedule.shared.timeslot.time_slot import TimeSlot 9 | from smartschedule.shared.typing_extensions import Comparable 10 | 11 | 12 | class ScheduleBasedOnStartDayCalculator: 13 | def calculate( 14 | self, 15 | start_date: date, 16 | parallelized_stages: ParallelStagesList, 17 | sort_key_getter: Callable[[ParallelStages], Comparable], 18 | ) -> dict[str, TimeSlot]: 19 | schedule_dict: dict[str, TimeSlot] = {} 20 | current_start = datetime.combine(start_date, datetime.min.time()) 21 | all_sorted = parallelized_stages.all_sorted(sort_key_getter=sort_key_getter) 22 | for stages in all_sorted: 23 | parallelized_stages_end = current_start 24 | for stage in stages.stages: 25 | stage_end = current_start + stage.duration 26 | schedule_dict[stage.name] = TimeSlot(current_start, stage_end) 27 | if stage_end > parallelized_stages_end: 28 | parallelized_stages_end = stage_end 29 | current_start = parallelized_stages_end 30 | return schedule_dict 31 | -------------------------------------------------------------------------------- /smartschedule/resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/resource/__init__.py -------------------------------------------------------------------------------- /smartschedule/resource/device/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/resource/device/__init__.py -------------------------------------------------------------------------------- /smartschedule/resource/device/device.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped, mapped_column 2 | 3 | from smartschedule.resource.device.device_id import DeviceId 4 | from smartschedule.shared.capability.capability import Capability 5 | from smartschedule.shared.sqlalchemy_extensions import AsJSON, EmbeddedUUID, registry 6 | 7 | 8 | @registry.mapped_as_dataclass() 9 | class Device: 10 | __tablename__ = "devices" 11 | 12 | id: Mapped[DeviceId] = mapped_column(EmbeddedUUID[DeviceId], primary_key=True) 13 | _version: Mapped[int] = mapped_column(name="version") 14 | model: Mapped[str] 15 | _capabilities: Mapped[set[Capability]] = mapped_column( 16 | AsJSON[set[Capability]], name="capabilities" 17 | ) 18 | 19 | __mapper_args__ = {"version_id_col": _version} 20 | 21 | def __init__(self, id: DeviceId, model: str, capabilities: set[Capability]) -> None: 22 | self.id = id 23 | self.model = model 24 | self._capabilities = capabilities 25 | 26 | @property 27 | def capabilities(self) -> set[Capability]: 28 | return self._capabilities 29 | -------------------------------------------------------------------------------- /smartschedule/resource/device/device_configuration.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/resource/device/device_configuration.py -------------------------------------------------------------------------------- /smartschedule/resource/device/device_facade.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 2 | AllocatableCapabilityId, 3 | ) 4 | from smartschedule.resource.device.device import Device 5 | from smartschedule.resource.device.device_id import DeviceId 6 | from smartschedule.resource.device.device_repository import DeviceRepository 7 | from smartschedule.resource.device.device_summary import DeviceSummay 8 | from smartschedule.resource.device.schedule_device_capabilities import ( 9 | ScheduleDeviceCapabilities, 10 | ) 11 | from smartschedule.shared.capability.capability import Capability 12 | from smartschedule.shared.timeslot.time_slot import TimeSlot 13 | 14 | 15 | class DeviceFacade: 16 | def __init__( 17 | self, 18 | device_repository: DeviceRepository, 19 | schedule_device_capabilities: ScheduleDeviceCapabilities, 20 | ) -> None: 21 | self._device_repository = device_repository 22 | self._schedule_device_capabilities = schedule_device_capabilities 23 | 24 | def find_device(self, device_id: DeviceId) -> DeviceSummay: 25 | return self._device_repository.find_summary(device_id) 26 | 27 | def find_all_capabilities(self) -> list[Capability]: 28 | return self._device_repository.find_all_capabilities() 29 | 30 | def create_device(self, model: str, assets: set[Capability]) -> DeviceId: 31 | device_id = DeviceId.new_one() 32 | device = Device(device_id, model, assets) 33 | self._device_repository.add(device) 34 | return device.id 35 | 36 | def schedule_capabilities( 37 | self, device_id: DeviceId, time_slot: TimeSlot 38 | ) -> list[AllocatableCapabilityId]: 39 | return self._schedule_device_capabilities.setup_device_capabilities( 40 | device_id, time_slot 41 | ) 42 | -------------------------------------------------------------------------------- /smartschedule/resource/device/device_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | from smartschedule.allocation.capabilityscheduling.allocatable_resource_id import ( 7 | AllocatableResourceId, 8 | ) 9 | 10 | 11 | @dataclass(frozen=True) 12 | class DeviceId: 13 | device_id: UUID 14 | 15 | @staticmethod 16 | def new_one() -> DeviceId: 17 | return DeviceId(uuid4()) 18 | 19 | @property 20 | def id(self) -> UUID: 21 | return self.device_id 22 | 23 | def to_allocatable_resource_id(self) -> AllocatableResourceId: 24 | return AllocatableResourceId(self.device_id) 25 | -------------------------------------------------------------------------------- /smartschedule/resource/device/device_repository.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from smartschedule.resource.device.device import Device 4 | from smartschedule.resource.device.device_id import DeviceId 5 | from smartschedule.resource.device.device_summary import DeviceSummay 6 | from smartschedule.shared.capability.capability import Capability 7 | from smartschedule.shared.sqlalchemy_extensions import SQLAlchemyRepository 8 | 9 | 10 | class DeviceRepository(SQLAlchemyRepository[Device, DeviceId]): 11 | def find_summary(self, device_id: DeviceId) -> DeviceSummay: 12 | device = self.get(device_id) 13 | return DeviceSummay(device.id, device.model, device.capabilities) 14 | 15 | def find_all_capabilities(self) -> list[Capability]: 16 | devices = self.get_all() 17 | capabilities_sets = [device.capabilities for device in devices] 18 | return list(itertools.chain(*capabilities_sets)) 19 | -------------------------------------------------------------------------------- /smartschedule/resource/device/device_summary.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.resource.device.device_id import DeviceId 4 | from smartschedule.shared.capability.capability import Capability 5 | 6 | 7 | @dataclass(frozen=True) 8 | class DeviceSummay: 9 | id: DeviceId 10 | model: str 11 | assets: set[Capability] 12 | -------------------------------------------------------------------------------- /smartschedule/resource/device/schedule_device_capabilities.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 2 | AllocatableCapabilityId, 3 | ) 4 | from smartschedule.allocation.capabilityscheduling.capability_scheduler import ( 5 | CapabilityScheduler, 6 | ) 7 | from smartschedule.resource.device.device_id import DeviceId 8 | from smartschedule.resource.device.device_repository import DeviceRepository 9 | from smartschedule.shared.capability_selector import CapabilitySelector 10 | from smartschedule.shared.timeslot.time_slot import TimeSlot 11 | 12 | 13 | class ScheduleDeviceCapabilities: 14 | def __init__( 15 | self, 16 | device_repository: DeviceRepository, 17 | capability_scheduler: CapabilityScheduler, 18 | ) -> None: 19 | self._device_repository = device_repository 20 | self._capability_scheduler = capability_scheduler 21 | 22 | def setup_device_capabilities( 23 | self, device_id: DeviceId, time_slot: TimeSlot 24 | ) -> list[AllocatableCapabilityId]: 25 | summary = self._device_repository.find_summary(device_id) 26 | return self._capability_scheduler.schedule_resource_capabilities_for_period( 27 | device_id.to_allocatable_resource_id(), 28 | [CapabilitySelector.can_perform_all_at_the_time(summary.assets)], 29 | time_slot, 30 | ) 31 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/resource/employee/__init__.py -------------------------------------------------------------------------------- /smartschedule/resource/employee/employee.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped, mapped_column 2 | 3 | from smartschedule.resource.employee.employee_id import EmployeeId 4 | from smartschedule.resource.employee.seniority import Seniority 5 | from smartschedule.shared.capability.capability import Capability 6 | from smartschedule.shared.sqlalchemy_extensions import AsJSON, EmbeddedUUID, registry 7 | 8 | 9 | @registry.mapped_as_dataclass() 10 | class Employee: 11 | __tablename__ = "employees" 12 | 13 | id: Mapped[EmployeeId] = mapped_column(EmbeddedUUID[EmployeeId], primary_key=True) 14 | _version: Mapped[int] = mapped_column(name="version") 15 | name: Mapped[str] 16 | last_name: Mapped[str] 17 | seniority: Mapped[Seniority] = mapped_column(AsJSON[Seniority]) 18 | _capabilities: Mapped[set[Capability]] = mapped_column( 19 | AsJSON[set[Capability]], name="capabilities" 20 | ) 21 | 22 | __mapper_args__ = {"version_id_col": _version} 23 | 24 | def __init__( 25 | self, 26 | id: EmployeeId, 27 | name: str, 28 | last_name: str, 29 | status: Seniority, 30 | capabilities: set[Capability], 31 | ) -> None: 32 | self.id = id 33 | self.name = name 34 | self.last_name = last_name 35 | self.seniority = status 36 | self._capabilities = capabilities 37 | 38 | @property 39 | def capabilities(self) -> set[Capability]: 40 | return self._capabilities 41 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/employee_allocation_policy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from itertools import chain 5 | 6 | from smartschedule.resource.employee.employee_summary import EmployeeSummary 7 | from smartschedule.shared.capability_selector import CapabilitySelector 8 | 9 | 10 | class EmployeeAllocationPolicy(abc.ABC): 11 | @abc.abstractmethod 12 | def simultaneous_capabilities_of( 13 | self, summary: EmployeeSummary 14 | ) -> list[CapabilitySelector]: 15 | pass 16 | 17 | @staticmethod 18 | def default_policy() -> EmployeeAllocationPolicy: 19 | return DefaultPolicy() 20 | 21 | @staticmethod 22 | def permissions_in_multiple_projects(how_many: int) -> EmployeeAllocationPolicy: 23 | return PermissionsInMultipleProjectsPolicy(how_many) 24 | 25 | @staticmethod 26 | def one_of_skills() -> EmployeeAllocationPolicy: 27 | return OneOfSkillsPolicy() 28 | 29 | @staticmethod 30 | def simultaneous(*policies: EmployeeAllocationPolicy) -> EmployeeAllocationPolicy: 31 | return CompositePolicy(*policies) 32 | 33 | 34 | class DefaultPolicy(EmployeeAllocationPolicy): 35 | def simultaneous_capabilities_of( 36 | self, summary: EmployeeSummary 37 | ) -> list[CapabilitySelector]: 38 | all_capabilities = summary.skills | summary.permissions 39 | return [CapabilitySelector.can_perform_one_of(all_capabilities)] 40 | 41 | 42 | class PermissionsInMultipleProjectsPolicy(EmployeeAllocationPolicy): 43 | def __init__(self, how_many: int) -> None: 44 | self._how_many = how_many 45 | 46 | def simultaneous_capabilities_of( 47 | self, summary: EmployeeSummary 48 | ) -> list[CapabilitySelector]: 49 | flattened = list( 50 | chain( 51 | *[[permission] * self._how_many for permission in summary.permissions] 52 | ) 53 | ) 54 | return [ 55 | CapabilitySelector.can_just_perform(permisson) for permisson in flattened 56 | ] 57 | 58 | 59 | class OneOfSkillsPolicy(EmployeeAllocationPolicy): 60 | def simultaneous_capabilities_of( 61 | self, summary: EmployeeSummary 62 | ) -> list[CapabilitySelector]: 63 | return [CapabilitySelector.can_perform_one_of(summary.skills)] 64 | 65 | 66 | class CompositePolicy(EmployeeAllocationPolicy): 67 | def __init__(self, *policies: EmployeeAllocationPolicy) -> None: 68 | self._policies = policies 69 | 70 | def simultaneous_capabilities_of( 71 | self, summary: EmployeeSummary 72 | ) -> list[CapabilitySelector]: 73 | return list( 74 | chain( 75 | *[ 76 | policy.simultaneous_capabilities_of(summary) 77 | for policy in self._policies 78 | ] 79 | ) 80 | ) 81 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/employee_configuration.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/resource/employee/employee_configuration.py -------------------------------------------------------------------------------- /smartschedule/resource/employee/employee_facade.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 2 | AllocatableCapabilityId, 3 | ) 4 | from smartschedule.resource.employee.employee import Employee 5 | from smartschedule.resource.employee.employee_id import EmployeeId 6 | from smartschedule.resource.employee.employee_repository import EmployeeRepository 7 | from smartschedule.resource.employee.employee_summary import EmployeeSummary 8 | from smartschedule.resource.employee.schedule_employee_capabilities import ( 9 | ScheduleEmployeeCapabilities, 10 | ) 11 | from smartschedule.resource.employee.seniority import Seniority 12 | from smartschedule.shared.capability.capability import Capability 13 | from smartschedule.shared.timeslot.time_slot import TimeSlot 14 | 15 | 16 | class EmployeeFacade: 17 | def __init__( 18 | self, 19 | repository: EmployeeRepository, 20 | schedule_employee_capabilities: ScheduleEmployeeCapabilities, 21 | ) -> None: 22 | self._repository = repository 23 | self._schedule_employee_capabilities = schedule_employee_capabilities 24 | 25 | def find_employee(self, employee_id: EmployeeId) -> EmployeeSummary: 26 | return self._repository.find_summary(employee_id) 27 | 28 | def find_all_capabilities(self) -> list[Capability]: 29 | return self._repository.find_all_capabilities() 30 | 31 | def add_employee( 32 | self, 33 | name: str, 34 | last_name: str, 35 | seniority: Seniority, 36 | skills: set[Capability], 37 | permissions: set[Capability], 38 | ) -> EmployeeId: 39 | employee_id = EmployeeId.new_one() 40 | capabilities = skills | permissions 41 | employee = Employee(employee_id, name, last_name, seniority, capabilities) 42 | self._repository.add(employee) 43 | return employee.id 44 | 45 | def schedule_capabilities( 46 | self, employee_id: EmployeeId, time_slot: TimeSlot 47 | ) -> list[AllocatableCapabilityId]: 48 | return self._schedule_employee_capabilities.setup_employee_capabilities( 49 | employee_id, time_slot 50 | ) 51 | 52 | # TODO: add vacation, call availability 53 | # TODO: add sick leave, call availability 54 | # change skills 55 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/employee_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | from smartschedule.allocation.capabilityscheduling.allocatable_resource_id import ( 7 | AllocatableResourceId, 8 | ) 9 | 10 | 11 | @dataclass(frozen=True) 12 | class EmployeeId: 13 | employee_id: UUID 14 | 15 | @staticmethod 16 | def new_one() -> EmployeeId: 17 | return EmployeeId(uuid4()) 18 | 19 | @property 20 | def id(self) -> UUID: 21 | return self.employee_id 22 | 23 | def to_allocatable_resource_id(self) -> AllocatableResourceId: 24 | return AllocatableResourceId(self.employee_id) 25 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/employee_repository.py: -------------------------------------------------------------------------------- 1 | from smartschedule.resource.employee.employee import Employee 2 | from smartschedule.resource.employee.employee_id import EmployeeId 3 | from smartschedule.resource.employee.employee_summary import EmployeeSummary 4 | from smartschedule.shared.capability.capability import Capability 5 | from smartschedule.shared.sqlalchemy_extensions import SQLAlchemyRepository 6 | 7 | 8 | class EmployeeRepository(SQLAlchemyRepository[Employee, EmployeeId]): 9 | def find_summary(self, employee_id: EmployeeId) -> EmployeeSummary: 10 | employee = self.get(employee_id) 11 | skills = { 12 | capability 13 | for capability in employee.capabilities 14 | if capability.is_of_type("SKILL") 15 | } 16 | permissions = { 17 | capability 18 | for capability in employee.capabilities 19 | if capability.is_of_type("PERMISSION") 20 | } 21 | return EmployeeSummary( 22 | id=employee.id, 23 | name=employee.name, 24 | last_name=employee.last_name, 25 | seniority=employee.seniority, 26 | skills=skills, 27 | permissions=permissions, 28 | ) 29 | 30 | def find_all_capabilities(self) -> list[Capability]: 31 | employees = self.get_all() 32 | return [ 33 | capability for employee in employees for capability in employee.capabilities 34 | ] 35 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/employee_summary.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from smartschedule.resource.employee.employee_id import EmployeeId 4 | from smartschedule.resource.employee.seniority import Seniority 5 | from smartschedule.shared.capability.capability import Capability 6 | 7 | 8 | @dataclass(frozen=True) 9 | class EmployeeSummary: 10 | id: EmployeeId 11 | name: str 12 | last_name: str 13 | seniority: Seniority 14 | skills: set[Capability] 15 | permissions: set[Capability] 16 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/schedule_employee_capabilities.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 2 | AllocatableCapabilityId, 3 | ) 4 | from smartschedule.allocation.capabilityscheduling.capability_scheduler import ( 5 | CapabilityScheduler, 6 | ) 7 | from smartschedule.resource.employee.employee_allocation_policy import ( 8 | EmployeeAllocationPolicy, 9 | ) 10 | from smartschedule.resource.employee.employee_id import EmployeeId 11 | from smartschedule.resource.employee.employee_repository import EmployeeRepository 12 | from smartschedule.resource.employee.employee_summary import EmployeeSummary 13 | from smartschedule.resource.employee.seniority import Seniority 14 | from smartschedule.shared.timeslot.time_slot import TimeSlot 15 | 16 | 17 | class ScheduleEmployeeCapabilities: 18 | def __init__( 19 | self, 20 | employee_repository: EmployeeRepository, 21 | capability_scheduler: CapabilityScheduler, 22 | ) -> None: 23 | self._employee_repository = employee_repository 24 | self._capability_scheduler = capability_scheduler 25 | 26 | def setup_employee_capabilities( 27 | self, employee_id: EmployeeId, time_slot: TimeSlot 28 | ) -> list[AllocatableCapabilityId]: 29 | summary = self._employee_repository.find_summary(employee_id) 30 | policy = self._find_allocation_policy(summary) 31 | capabilities = policy.simultaneous_capabilities_of(summary) 32 | return self._capability_scheduler.schedule_resource_capabilities_for_period( 33 | employee_id.to_allocatable_resource_id(), capabilities, time_slot 34 | ) 35 | 36 | def _find_allocation_policy( 37 | self, summary: EmployeeSummary 38 | ) -> EmployeeAllocationPolicy: 39 | if summary.seniority == Seniority.LEAD: 40 | return EmployeeAllocationPolicy.simultaneous( 41 | EmployeeAllocationPolicy.one_of_skills(), 42 | EmployeeAllocationPolicy.permissions_in_multiple_projects(3), 43 | ) 44 | return EmployeeAllocationPolicy.default_policy() 45 | -------------------------------------------------------------------------------- /smartschedule/resource/employee/seniority.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class Seniority(StrEnum): 5 | JUNIOR = auto() 6 | MID = auto() 7 | SENIOR = auto() 8 | LEAD = auto() 9 | -------------------------------------------------------------------------------- /smartschedule/resource/resource_facade.py: -------------------------------------------------------------------------------- 1 | from smartschedule.resource.device.device_facade import DeviceFacade 2 | from smartschedule.resource.employee.employee_facade import EmployeeFacade 3 | from smartschedule.shared.capability.capability import Capability 4 | 5 | 6 | class ResourceFacade: 7 | def __init__( 8 | self, employee_facade: EmployeeFacade, device_facade: DeviceFacade 9 | ) -> None: 10 | self._employee_facade = employee_facade 11 | self._device_facade = device_facade 12 | 13 | def find_all_capabilities(self) -> list[Capability]: 14 | employee_capabilities = self._employee_facade.find_all_capabilities() 15 | device_capabilities = self._device_facade.find_all_capabilities() 16 | return employee_capabilities + device_capabilities 17 | -------------------------------------------------------------------------------- /smartschedule/risk/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/risk/__init__.py -------------------------------------------------------------------------------- /smartschedule/risk/risk_configuration.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/risk/risk_configuration.py -------------------------------------------------------------------------------- /smartschedule/risk/risk_periodic_check_saga_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from uuid import UUID, uuid4 5 | 6 | 7 | @dataclass(frozen=True) 8 | class RiskPeriodicCheckSagaId: 9 | _project_risk_saga_id: UUID 10 | 11 | @property 12 | def id(self) -> UUID: 13 | return self._project_risk_saga_id 14 | 15 | @staticmethod 16 | def new_one() -> RiskPeriodicCheckSagaId: 17 | return RiskPeriodicCheckSagaId(uuid4()) 18 | 19 | def __eq__(self, other: object) -> bool: 20 | if not isinstance(other, RiskPeriodicCheckSagaId): 21 | return False 22 | return self._project_risk_saga_id == other._project_risk_saga_id 23 | 24 | def __hash__(self) -> int: 25 | return hash(self._project_risk_saga_id) 26 | 27 | def __repr__(self) -> str: 28 | return f"RiskPeriodicCheckSagaId(UUID(hex='{self._project_risk_saga_id}'))" 29 | 30 | def __str__(self) -> str: 31 | return str(self._project_risk_saga_id) 32 | 33 | def __lt__(self, other: RiskPeriodicCheckSagaId) -> bool: 34 | return self._project_risk_saga_id < other._project_risk_saga_id 35 | -------------------------------------------------------------------------------- /smartschedule/risk/risk_periodic_check_saga_repository.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence 2 | 3 | from sqlalchemy import select 4 | from sqlalchemy.exc import NoResultFound 5 | 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.risk.risk_periodic_check_saga import RiskPeriodicCheckSaga 8 | from smartschedule.risk.risk_periodic_check_saga_id import RiskPeriodicCheckSagaId 9 | from smartschedule.shared.repository import NotFound 10 | from smartschedule.shared.sqlalchemy_extensions import SQLAlchemyRepository 11 | 12 | 13 | class RiskPeriodicCheckSagaRepository( 14 | SQLAlchemyRepository[RiskPeriodicCheckSaga, RiskPeriodicCheckSagaId] 15 | ): 16 | def find_by_project_id( 17 | self, project_id: ProjectAllocationsId 18 | ) -> RiskPeriodicCheckSaga: 19 | stmt = select(self._type).filter(self._type.project_id == project_id) 20 | try: 21 | return self._session.execute(stmt).scalar_one() 22 | except NoResultFound: 23 | raise NotFound 24 | 25 | def find_by_project_id_in( 26 | self, interested: list[ProjectAllocationsId] 27 | ) -> Sequence[RiskPeriodicCheckSaga]: 28 | stmt = select(self._type).filter(self._type.project_id.in_(interested)) 29 | return self._session.execute(stmt).scalars().all() 30 | 31 | def find_by_project_id_or_create( 32 | self, project_id: ProjectAllocationsId 33 | ) -> RiskPeriodicCheckSaga: 34 | try: 35 | return self.find_by_project_id(project_id) 36 | except NotFound: 37 | saga = RiskPeriodicCheckSaga(project_id) 38 | self.add(saga) 39 | return saga 40 | 41 | def find_by_project_id_in_or_else_create( 42 | self, interested: list[ProjectAllocationsId] 43 | ) -> Sequence[RiskPeriodicCheckSaga]: 44 | sagas = list(self.find_by_project_id_in(interested)) 45 | found_ids = {found.project_id for found in sagas} 46 | missing_ids = set(interested) - found_ids 47 | for missing_id in missing_ids: 48 | saga = RiskPeriodicCheckSaga(missing_id) 49 | self.add(saga) 50 | sagas.append(saga) 51 | 52 | return sagas 53 | -------------------------------------------------------------------------------- /smartschedule/risk/risk_periodic_check_saga_step.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class RiskPeriodicCheckSagaStep(StrEnum): 5 | FIND_AVAILABLE = auto() 6 | DO_NOTHING = auto() 7 | SUGGEST_REPLACEMENT = auto() 8 | NOTIFY_ABOUT_POSSIBLE_RISK = auto() 9 | NOTIFY_ABOUT_DEMANDS_SATISFIED = auto() 10 | -------------------------------------------------------------------------------- /smartschedule/risk/risk_push_notification.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.allocatable_capabilities_summary import ( 2 | AllocatableCapabilitiesSummary, 3 | ) 4 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 5 | AllocatableCapabilityId, 6 | ) 7 | from smartschedule.allocation.demand import Demand 8 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 9 | from smartschedule.availability.resource_id import ResourceId 10 | from smartschedule.planning.demands import Demands 11 | from smartschedule.planning.project_id import ProjectId 12 | from smartschedule.shared.timeslot.time_slot import TimeSlot 13 | 14 | 15 | class RiskPushNotification: 16 | def notify_demands_satisfied(self, project_id: ProjectAllocationsId) -> None: 17 | pass 18 | 19 | def notify_about_availability( 20 | self, 21 | project_id: ProjectAllocationsId, 22 | available: dict[Demand, AllocatableCapabilitiesSummary], 23 | ) -> None: 24 | pass 25 | 26 | def notify_profitable_relocation_found( 27 | self, 28 | project_id: ProjectAllocationsId, 29 | allocatable_capability_id: AllocatableCapabilityId, 30 | ) -> None: 31 | pass 32 | 33 | def notify_about_possible_risk(self, project_id: ProjectAllocationsId) -> None: 34 | pass 35 | 36 | def notify_about_possible_risk_during_planning( 37 | self, cause: ProjectId, demands: Demands 38 | ) -> None: 39 | pass 40 | 41 | def notify_about_critical_resource_not_available( 42 | self, cause: ProjectId, critical_resource: ResourceId, time_slot: TimeSlot 43 | ) -> None: 44 | pass 45 | 46 | def notify_about_resources_not_available( 47 | self, project_id: ProjectId, not_available: set[ResourceId] 48 | ) -> None: 49 | pass 50 | -------------------------------------------------------------------------------- /smartschedule/risk/verify_critical_resource_available_during_planning.py: -------------------------------------------------------------------------------- 1 | from smartschedule.availability.availability_facade import AvailabilityFacade 2 | from smartschedule.availability.calendar import Calendar 3 | from smartschedule.planning.critical_stage_planned import CriticalStagePlanned 4 | from smartschedule.risk.risk_push_notification import RiskPushNotification 5 | from smartschedule.shared.event_bus import EventBus 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | 8 | 9 | @EventBus.has_event_handlers 10 | class VerifyCriticalResourceAvailableDuringPlanning: 11 | def __init__( 12 | self, 13 | availability_facade: AvailabilityFacade, 14 | risk_push_notification: RiskPushNotification, 15 | ) -> None: 16 | self._availability_facade = availability_facade 17 | self._risk_push_notification = risk_push_notification 18 | 19 | @EventBus.async_event_handler 20 | def handle(self, event: CriticalStagePlanned) -> None: 21 | if event.critical_resource_id is None: 22 | return 23 | 24 | calendar = self._availability_facade.load_calendar( 25 | event.critical_resource_id, event.stage_time_slot 26 | ) 27 | if not self._resource_is_available(event.stage_time_slot, calendar): 28 | self._risk_push_notification.notify_about_critical_resource_not_available( 29 | event.project_id, event.critical_resource_id, event.stage_time_slot 30 | ) 31 | 32 | def _resource_is_available(self, time_slot: TimeSlot, calendar: Calendar) -> bool: 33 | return time_slot in calendar.available_slots() 34 | -------------------------------------------------------------------------------- /smartschedule/risk/verify_needed_resources_available_in_time_slot.py: -------------------------------------------------------------------------------- 1 | from smartschedule.availability.availability_facade import AvailabilityFacade 2 | from smartschedule.availability.resource_id import ResourceId 3 | from smartschedule.planning.needed_resource_chosen import NeededResourcesChosen 4 | from smartschedule.planning.project_id import ProjectId 5 | from smartschedule.risk.risk_push_notification import RiskPushNotification 6 | from smartschedule.shared.event_bus import EventBus 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | @EventBus.has_event_handlers 11 | class VerifyNeededResourcesAvailableInTimeSlot: 12 | def __init__( 13 | self, 14 | availability_facade: AvailabilityFacade, 15 | risk_push_notification: RiskPushNotification, 16 | ) -> None: 17 | self._availability_facade = availability_facade 18 | self._risk_push_notification = risk_push_notification 19 | 20 | @EventBus.async_event_handler 21 | def handle(self, event: NeededResourcesChosen) -> None: 22 | self._notify_about_not_available_resources( 23 | event.needed_resources, event.time_slot, event.project_id 24 | ) 25 | 26 | def _notify_about_not_available_resources( 27 | self, resource_ids: set[ResourceId], time_slot: TimeSlot, project_id: ProjectId 28 | ) -> None: 29 | not_available: set[ResourceId] = set() 30 | calendars = self._availability_facade.load_calendars(resource_ids, time_slot) 31 | for resource_id in resource_ids: 32 | available_slots = calendars.get(resource_id).available_slots() 33 | if not any(time_slot.within(slot) for slot in available_slots): 34 | not_available.add(resource_id) 35 | if len(not_available) > 0: 36 | self._risk_push_notification.notify_about_resources_not_available( 37 | project_id, not_available 38 | ) 39 | -------------------------------------------------------------------------------- /smartschedule/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/shared/__init__.py -------------------------------------------------------------------------------- /smartschedule/shared/capability/capability.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass(frozen=True) 7 | class Capability: 8 | name: str 9 | type: str 10 | 11 | @classmethod 12 | def skill(cls, name: str) -> Capability: 13 | return Capability(name, "SKILL") 14 | 15 | @classmethod 16 | def skills(cls, *names: str) -> set[Capability]: 17 | return {Capability(name, "SKILL") for name in names} 18 | 19 | @classmethod 20 | def permission(cls, name: str) -> Capability: 21 | return Capability(name, "PERMISSION") 22 | 23 | @classmethod 24 | def permissions(cls, *names: str) -> set[Capability]: 25 | return {Capability(name, "PERMISSION") for name in names} 26 | 27 | @classmethod 28 | def asset(cls, asset: str) -> Capability: 29 | return Capability(asset, "ASSET") 30 | 31 | @classmethod 32 | def assets(cls, *assets: str) -> set[Capability]: 33 | return {Capability(asset, "ASSET") for asset in assets} 34 | 35 | def is_of_type(self, type: str) -> bool: 36 | return self.type == type 37 | -------------------------------------------------------------------------------- /smartschedule/shared/capability_selector.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from enum import StrEnum, auto 5 | 6 | from smartschedule.shared.capability.capability import Capability 7 | 8 | 9 | class SelectingPolicy(StrEnum): 10 | ALL_SIMULTANEOUSLY = auto() 11 | ONE_OF_ALL = auto() 12 | 13 | 14 | @dataclass(frozen=True) 15 | class CapabilitySelector: 16 | capabilities: frozenset[Capability] 17 | selecting_policy: SelectingPolicy 18 | 19 | @staticmethod 20 | def can_perform_all_at_the_time( 21 | capabilities: set[Capability], 22 | ) -> CapabilitySelector: 23 | return CapabilitySelector( 24 | frozenset(capabilities), SelectingPolicy.ALL_SIMULTANEOUSLY 25 | ) 26 | 27 | @staticmethod 28 | def can_perform_one_of(capabilities: set[Capability]) -> CapabilitySelector: 29 | return CapabilitySelector(frozenset(capabilities), SelectingPolicy.ONE_OF_ALL) 30 | 31 | @staticmethod 32 | def can_just_perform(capability: Capability) -> CapabilitySelector: 33 | return CapabilitySelector(frozenset({capability}), SelectingPolicy.ONE_OF_ALL) 34 | 35 | def can_perform(self, *capabilities: Capability) -> bool: 36 | if len(capabilities) == 1: 37 | return capabilities[0] in self.capabilities 38 | 39 | return ( 40 | self.selecting_policy == SelectingPolicy.ALL_SIMULTANEOUSLY 41 | and self.capabilities.issuperset(set(capabilities)) 42 | ) 43 | -------------------------------------------------------------------------------- /smartschedule/shared/events_publisher.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from smartschedule.shared.published_event import PublishedEvent 4 | 5 | 6 | class EventsPublisher(abc.ABC): 7 | @abc.abstractmethod 8 | def publish(self, event: PublishedEvent) -> None: 9 | pass 10 | -------------------------------------------------------------------------------- /smartschedule/shared/private_event.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from dataclasses import dataclass, is_dataclass 3 | from datetime import datetime 4 | 5 | 6 | # metadata: 7 | # - correlation_id 8 | # - potential aggregate's id 9 | # - causation_id - id of a message that caused this message 10 | # - message_id - unique id of the message 11 | # - user - if there is any (might be a system event) 12 | @dataclass(frozen=True) 13 | class PrivateEvent: 14 | def __init_subclass__(cls) -> None: 15 | if not is_dataclass(cls): 16 | raise TypeError("PrivateEvent subclasses must be dataclasses") 17 | annotations = inspect.get_annotations(cls) 18 | if "occurred_at" not in annotations or annotations["occurred_at"] != datetime: 19 | raise TypeError( 20 | "PrivateEvent subclasses must have an occurred_at field of type datetime" 21 | ) 22 | return super().__init_subclass__() 23 | -------------------------------------------------------------------------------- /smartschedule/shared/published_event.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from dataclasses import dataclass, is_dataclass 3 | from datetime import datetime 4 | 5 | 6 | # metadata: 7 | # - correlation_id 8 | # - potential aggregate's id 9 | # - causation_id - id of a message that caused this message 10 | # - message_id - unique id of the message 11 | # - user - if there is any (might be a system event) 12 | @dataclass(frozen=True) 13 | class PublishedEvent: 14 | def __init_subclass__(cls) -> None: 15 | if not is_dataclass(cls): 16 | raise TypeError("PublishedEvent subclasses must be dataclasses") 17 | annotations = inspect.get_annotations(cls) 18 | if "occurred_at" not in annotations or annotations["occurred_at"] != datetime: 19 | raise TypeError( 20 | "PublishedEvent subclasses must have an occurred_at field of type datetime" 21 | ) 22 | return super().__init_subclass__() 23 | -------------------------------------------------------------------------------- /smartschedule/shared/repository.py: -------------------------------------------------------------------------------- 1 | class NotFound(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /smartschedule/shared/resource_name.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias 2 | 3 | ResourceName: TypeAlias = str 4 | -------------------------------------------------------------------------------- /smartschedule/shared/timeslot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/shared/timeslot/__init__.py -------------------------------------------------------------------------------- /smartschedule/shared/timeslot/time_slot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from datetime import date, datetime, time, timedelta, timezone 5 | 6 | from dateutil import relativedelta 7 | 8 | 9 | @dataclass(frozen=True) 10 | class TimeSlot: 11 | from_: datetime 12 | to: datetime 13 | 14 | @staticmethod 15 | def empty() -> TimeSlot: 16 | return TimeSlot(datetime.min, datetime.min) 17 | 18 | @classmethod 19 | def create_daily_time_slot_at_utc(cls, year: int, month: int, day: int) -> TimeSlot: 20 | return cls.create_daily_time_slot_at_utc_duration( 21 | year, month, day, timedelta(days=1) 22 | ) 23 | 24 | @classmethod 25 | def create_daily_time_slot_at_utc_duration( 26 | cls, year: int, month: int, day: int, duration: timedelta 27 | ) -> TimeSlot: 28 | this_day = date(year, month, day) 29 | day_start_in_utc = time.min.replace(tzinfo=timezone.utc) 30 | from_ = datetime.combine(this_day, day_start_in_utc) 31 | return TimeSlot(from_, from_ + duration) 32 | 33 | @classmethod 34 | def create_monthly_time_slot_at_utc(cls, year: int, month: int) -> TimeSlot: 35 | start_of_month = date(year, month, 1) 36 | end_of_month = start_of_month + relativedelta.relativedelta(months=1) 37 | day_start_in_utc = time.min.replace(tzinfo=timezone.utc) 38 | from_ = datetime.combine(start_of_month, day_start_in_utc) 39 | to = datetime.combine(end_of_month, day_start_in_utc) 40 | return TimeSlot(from_, to) 41 | 42 | def within(self, other: TimeSlot) -> bool: 43 | return not self.from_ < other.from_ and not self.to > other.to 44 | 45 | def overlaps(self, other: TimeSlot) -> bool: 46 | return self.from_ <= other.to and self.to >= other.from_ 47 | 48 | def leftover_after_removing_common_with(self, other: TimeSlot) -> list[TimeSlot]: 49 | result: list[TimeSlot] = [] 50 | if self == other: 51 | return [] 52 | if not other.overlaps(self): 53 | return [self, other] 54 | if self.from_ < other.from_: 55 | result.append(TimeSlot(self.from_, other.from_)) 56 | elif other.from_ < self.from_: 57 | result.append(TimeSlot(other.from_, self.from_)) 58 | if self.to > other.to: 59 | result.append(TimeSlot(other.to, self.to)) 60 | elif other.to > self.to: 61 | result.append(TimeSlot(self.to, other.to)) 62 | return result 63 | 64 | def is_empty(self) -> bool: 65 | return self.from_ == self.to 66 | 67 | def common_part_with(self, other: TimeSlot) -> TimeSlot: 68 | if not self.overlaps(other): 69 | return TimeSlot(self.from_, self.from_) 70 | return TimeSlot(max(self.from_, other.from_), min(self.to, other.to)) 71 | 72 | @property 73 | def duration(self) -> timedelta: 74 | return self.to - self.from_ 75 | 76 | def stretch(self, duration: timedelta) -> TimeSlot: 77 | return TimeSlot(self.from_ - duration, self.to + duration) 78 | -------------------------------------------------------------------------------- /smartschedule/shared/typing_extensions.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Protocol, TypeAlias 2 | 3 | 4 | class SupportsDunderLT(Protocol): 5 | def __lt__(self, __other: Any) -> bool: ... 6 | 7 | 8 | class SupportsDunderGT(Protocol): 9 | def __gt__(self, __other: Any) -> bool: ... 10 | 11 | 12 | Comparable = SupportsDunderLT | SupportsDunderGT 13 | 14 | JSON: TypeAlias = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None 15 | -------------------------------------------------------------------------------- /smartschedule/simulation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/simulation/__init__.py -------------------------------------------------------------------------------- /smartschedule/simulation/additional_priced_capability.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from decimal import Decimal 3 | 4 | from smartschedule.simulation.available_resource_capability import ( 5 | AvailableResourceCapability, 6 | ) 7 | 8 | 9 | @dataclass(frozen=True) 10 | class AdditionalPricedCapability: 11 | value: Decimal 12 | available_resource_capability: AvailableResourceCapability 13 | -------------------------------------------------------------------------------- /smartschedule/simulation/available_resource_capability.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Self 3 | from uuid import UUID 4 | 5 | from smartschedule.optimization.capacity_dimension import CapacityDimension 6 | from smartschedule.shared.capability.capability import Capability 7 | from smartschedule.shared.capability_selector import ( 8 | CapabilitySelector, 9 | ) 10 | from smartschedule.shared.timeslot.time_slot import TimeSlot 11 | 12 | 13 | @dataclass(frozen=True) 14 | class AvailableResourceCapability(CapacityDimension): 15 | resource_id: UUID 16 | capability_selector: CapabilitySelector 17 | time_slot: TimeSlot 18 | 19 | @classmethod 20 | def with_capability( 21 | cls, resource_id: UUID, capability: Capability, time_slot: TimeSlot 22 | ) -> Self: 23 | return cls( 24 | resource_id, CapabilitySelector.can_just_perform(capability), time_slot 25 | ) 26 | 27 | def performs(self, capability: Capability) -> bool: 28 | return self.capability_selector.can_perform(capability) 29 | -------------------------------------------------------------------------------- /smartschedule/simulation/demand.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.optimization.weight_dimension import WeightDimension 6 | from smartschedule.shared.capability.capability import Capability 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | from smartschedule.simulation.available_resource_capability import ( 9 | AvailableResourceCapability, 10 | ) 11 | 12 | 13 | @dataclass(frozen=True) 14 | class Demand(WeightDimension[AvailableResourceCapability]): 15 | capability: Capability 16 | slot: TimeSlot 17 | 18 | @classmethod 19 | def demand_for(cls, capability: Capability, slot: TimeSlot) -> Demand: 20 | return Demand(capability, slot) 21 | 22 | def is_satisfied_by( 23 | self, available_capability: AvailableResourceCapability 24 | ) -> bool: 25 | return available_capability.performs(self.capability) and self.slot.within( 26 | available_capability.time_slot 27 | ) 28 | -------------------------------------------------------------------------------- /smartschedule/simulation/demands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.simulation.demand import Demand 6 | 7 | 8 | @dataclass(frozen=True) 9 | class Demands: 10 | all: list[Demand] 11 | 12 | @classmethod 13 | def of(cls, demands: list[Demand]) -> Demands: 14 | return Demands(all=demands) 15 | -------------------------------------------------------------------------------- /smartschedule/simulation/project_id.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from uuid import UUID, uuid4 4 | 5 | 6 | class ProjectId: 7 | def __init__(self, uuid: UUID) -> None: 8 | self._project_id = uuid 9 | 10 | @property 11 | def id(self) -> UUID: 12 | return self._project_id 13 | 14 | def __eq__(self, other: object) -> bool: 15 | if not isinstance(other, ProjectId): 16 | return False 17 | return self._project_id == other._project_id 18 | 19 | def __hash__(self) -> int: 20 | return hash(self._project_id) 21 | 22 | def __str__(self) -> str: 23 | return str(self._project_id) 24 | 25 | @staticmethod 26 | def new_one() -> ProjectId: 27 | return ProjectId(uuid4()) 28 | -------------------------------------------------------------------------------- /smartschedule/simulation/simulated_capabilities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.simulation.available_resource_capability import ( 6 | AvailableResourceCapability, 7 | ) 8 | 9 | 10 | @dataclass(frozen=True) 11 | class SimulatedCapabilities: 12 | capabilities: list[AvailableResourceCapability] 13 | 14 | @staticmethod 15 | def none() -> SimulatedCapabilities: 16 | return SimulatedCapabilities([]) 17 | 18 | def add( 19 | self, *new_capabilities: AvailableResourceCapability 20 | ) -> SimulatedCapabilities: 21 | new_availabilities = self.capabilities + list(new_capabilities) 22 | return SimulatedCapabilities(new_availabilities) 23 | -------------------------------------------------------------------------------- /smartschedule/simulation/simulated_project.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from decimal import Decimal 3 | from typing import Callable 4 | 5 | from smartschedule.simulation.demands import Demands 6 | from smartschedule.simulation.project_id import ProjectId 7 | 8 | 9 | @dataclass(frozen=True) 10 | class SimulatedProject: 11 | project_id: ProjectId 12 | value_getter: Callable[[], Decimal] 13 | missing_demands: Demands 14 | 15 | @property 16 | def value(self) -> Decimal: 17 | return self.value_getter() 18 | 19 | def __hash__(self) -> int: 20 | return hash(self.project_id) 21 | -------------------------------------------------------------------------------- /smartschedule/sorter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/smartschedule/sorter/__init__.py -------------------------------------------------------------------------------- /smartschedule/sorter/edge.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass(frozen=True) 5 | class Edge: 6 | source: int 7 | target: int 8 | 9 | def __str__(self) -> str: 10 | return f"({self.source} -> {self.target})" 11 | -------------------------------------------------------------------------------- /smartschedule/sorter/feedback_arc_se_on_graph.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from smartschedule.sorter.edge import Edge 4 | from smartschedule.sorter.node import Node 5 | 6 | 7 | class FeedbackArcSeOnGraph[T]: 8 | def calculate(self, initial_nodes: list[Node[T]]) -> list[Edge]: 9 | initial_nodes = sorted(initial_nodes, key=lambda node: node.name) 10 | adjacency_list = self._create_adjacency_list(initial_nodes) 11 | v = len(adjacency_list) 12 | feedback_edges = [] 13 | visited = [0] * (v + 1) 14 | for i in adjacency_list.keys(): 15 | neighbours = adjacency_list[i] 16 | if len(neighbours) != 0: 17 | visited[i] = 1 18 | for j in range(len(neighbours)): 19 | if visited[neighbours[j]] == 1: 20 | feedback_edges.append(Edge(i, neighbours[j])) 21 | else: 22 | visited[neighbours[j]] = 1 23 | return feedback_edges 24 | 25 | def _create_adjacency_list( 26 | self, initial_nodes: list[Node[T]] 27 | ) -> dict[int, list[int]]: 28 | adjacency_list: dict[int, list[int]] = {} 29 | 30 | for i in range(1, len(initial_nodes) + 1): 31 | adjacency_list[i] = [] 32 | 33 | for i in range(len(initial_nodes)): 34 | dependencies = [] 35 | for dependency in initial_nodes[i].dependencies.nodes: 36 | dependencies.append(initial_nodes.index(dependency) + 1) 37 | adjacency_list[i + 1] = dependencies 38 | return adjacency_list 39 | -------------------------------------------------------------------------------- /smartschedule/sorter/graph_topological_sort.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from smartschedule.sorter.nodes import Nodes 4 | from smartschedule.sorter.sorted_nodes import SortedNodes 5 | 6 | 7 | class GraphTopologicalSort[T]: 8 | def sort(self, nodes: Nodes[T]) -> SortedNodes[T]: 9 | return self._create_sorted_nodes_recursively(nodes, SortedNodes[T].empty()) 10 | 11 | def _create_sorted_nodes_recursively( 12 | self, remaining_nodes: Nodes[T], accumulated_sorted_nodes: SortedNodes[T] 13 | ) -> SortedNodes[T]: 14 | accumulated_nodes = [nodes.nodes for nodes in accumulated_sorted_nodes.all] 15 | already_processed_nodes = list(chain.from_iterable(accumulated_nodes)) 16 | 17 | nodes_without_dependencies = remaining_nodes.with_all_dependencies_present_in( 18 | already_processed_nodes 19 | ) 20 | 21 | if not nodes_without_dependencies.all: 22 | return accumulated_sorted_nodes 23 | 24 | new_sorted_nodes = accumulated_sorted_nodes.add(nodes_without_dependencies) 25 | remaining_nodes = remaining_nodes.remove_all(nodes_without_dependencies.all) 26 | return self._create_sorted_nodes_recursively(remaining_nodes, new_sorted_nodes) 27 | -------------------------------------------------------------------------------- /smartschedule/sorter/node.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | 5 | from smartschedule.sorter.nodes import Nodes 6 | 7 | 8 | @dataclass 9 | class Node[T]: 10 | name: str 11 | content: T 12 | dependencies: Nodes[T] = field(default_factory=lambda: Nodes[T](set())) 13 | 14 | def depends_on(self, node: Node[T]) -> Node[T]: 15 | return Node[T]( 16 | name=self.name, 17 | content=self.content, 18 | dependencies=self.dependencies.add(node), 19 | ) 20 | 21 | def __str__(self) -> str: 22 | return self.name 23 | 24 | def __eq__(self, value: object) -> bool: 25 | if not isinstance(value, Node): 26 | return False 27 | return self.name == value.name 28 | 29 | def __hash__(self) -> int: 30 | return hash(self.name) 31 | -------------------------------------------------------------------------------- /smartschedule/sorter/nodes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Iterable 5 | 6 | if TYPE_CHECKING: 7 | from smartschedule.sorter.node import Node 8 | 9 | 10 | @dataclass 11 | class Nodes[T]: 12 | nodes: set[Node[T]] 13 | 14 | @property 15 | def all(self) -> set[Node[T]]: 16 | return self.nodes.copy() 17 | 18 | def add(self, node: Node[T]) -> Nodes[T]: 19 | new_nodes = self.nodes.copy() 20 | new_nodes.add(node) 21 | return Nodes[T](new_nodes) 22 | 23 | def with_all_dependencies_present_in(self, nodes: Iterable[Node[T]]) -> Nodes[T]: 24 | return Nodes[T]( 25 | {node for node in self.nodes if node.dependencies.nodes.issubset(nodes)} 26 | ) 27 | 28 | def remove_all(self, nodes: set[Node[T]]) -> Nodes[T]: 29 | return Nodes[T]({node for node in self.nodes if node not in nodes}) 30 | 31 | def __str__(self) -> str: 32 | return f"Nodes{{node={self.nodes}}}" 33 | -------------------------------------------------------------------------------- /smartschedule/sorter/sorted_nodes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from smartschedule.sorter.nodes import Nodes 6 | 7 | 8 | @dataclass 9 | class SortedNodes[T]: 10 | all: list[Nodes[T]] 11 | 12 | @classmethod 13 | def empty(cls) -> SortedNodes[T]: 14 | return cls([]) 15 | 16 | def add(self, new_nodes: Nodes[T]) -> SortedNodes[T]: 17 | return SortedNodes[T](self.all + [new_nodes]) 18 | 19 | def __str__(self) -> str: 20 | return f"SortedNodes: {self.all}" 21 | -------------------------------------------------------------------------------- /tach.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/gauge-sh/tach/v0.9.2/public/tach-yml-schema.json 2 | modules: 3 | - path: smartschedule.allocation 4 | depends_on: 5 | - path: smartschedule.allocation.capabilityscheduling 6 | - path: smartschedule.availability 7 | - path: smartschedule.shared 8 | - path: smartschedule.simulation 9 | - path: smartschedule.allocation.capabilityscheduling 10 | depends_on: 11 | - path: smartschedule.availability 12 | - path: smartschedule.shared 13 | - path: smartschedule.allocation.capabilityscheduling.legacyacl 14 | depends_on: 15 | - path: smartschedule.allocation.capabilityscheduling 16 | - path: smartschedule.shared 17 | - path: smartschedule.availability 18 | depends_on: 19 | - path: smartschedule.shared 20 | - path: smartschedule.container 21 | depends_on: 22 | - path: smartschedule.shared 23 | - path: smartschedule.allocation 24 | - path: smartschedule.planning 25 | - path: smartschedule.optimization 26 | depends_on: 27 | - path: smartschedule.shared 28 | - path: smartschedule.planning 29 | depends_on: 30 | - path: smartschedule.allocation 31 | - path: smartschedule.availability 32 | - path: smartschedule.planning.parallelization 33 | - path: smartschedule.shared 34 | - path: smartschedule.planning.parallelization 35 | depends_on: 36 | - path: smartschedule.availability 37 | - path: smartschedule.shared 38 | - path: smartschedule.sorter 39 | - path: smartschedule.resource 40 | depends_on: 41 | - path: smartschedule.resource.device 42 | - path: smartschedule.resource.employee 43 | - path: smartschedule.shared 44 | - path: smartschedule.resource.device 45 | depends_on: 46 | - path: smartschedule.allocation.capabilityscheduling 47 | - path: smartschedule.shared 48 | - path: smartschedule.resource.employee 49 | depends_on: 50 | - path: smartschedule.allocation.capabilityscheduling 51 | - path: smartschedule.shared 52 | - path: smartschedule.risk 53 | depends_on: 54 | - path: smartschedule.shared 55 | - path: smartschedule.planning 56 | - path: smartschedule.availability 57 | - path: smartschedule.simulation 58 | - path: smartschedule.allocation 59 | - path: smartschedule.allocation.capabilityscheduling 60 | - path: smartschedule.resource 61 | - path: smartschedule.shared 62 | depends_on: [] 63 | - path: smartschedule.simulation 64 | depends_on: 65 | - path: smartschedule.optimization 66 | - path: smartschedule.shared 67 | - path: smartschedule.sorter 68 | depends_on: [] 69 | exclude: 70 | - .*__pycache__ 71 | - .*egg-info 72 | - docs 73 | - tests 74 | - venv 75 | source_roots: 76 | - . 77 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/allocation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/allocation/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/allocation/availability_assert.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 2 | AllocatableCapabilityId, 3 | ) 4 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 5 | from smartschedule.availability.availability_facade import AvailabilityFacade 6 | from smartschedule.availability.owner import Owner 7 | from smartschedule.availability.resource_id import ResourceId 8 | from smartschedule.shared.timeslot.time_slot import TimeSlot 9 | 10 | 11 | class AvailabilityAssert: 12 | def __init__(self, availability_facade: AvailabilityFacade) -> None: 13 | self._availability_facade = availability_facade 14 | 15 | def assert_availability_was_blocked( 16 | self, 17 | resource_id: ResourceId, 18 | period: TimeSlot, 19 | project_id: ProjectAllocationsId, 20 | ) -> None: 21 | __tracebackhide__ = True 22 | 23 | owner = Owner(project_id.id) 24 | calendars = self._availability_facade.load_calendars({resource_id}, period) 25 | assert all( 26 | calendar.taken_by(owner) == [period] 27 | for calendar in calendars.calendars.values() 28 | ) 29 | 30 | def assert_availability_is_released( 31 | self, 32 | time_slot: TimeSlot, 33 | allocatable_capability_id: AllocatableCapabilityId, 34 | project_id: ProjectAllocationsId, 35 | ) -> None: 36 | __tracebackhide__ = True 37 | 38 | owner = Owner(project_id.id) 39 | calendars = self._availability_facade.load_calendars( 40 | {allocatable_capability_id.to_availability_resource_id()}, time_slot 41 | ) 42 | assert all( 43 | calendar.taken_by(owner) == [] for calendar in calendars.calendars.values() 44 | ) 45 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/capabilityscheduling/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/allocation/capabilityscheduling/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/allocation/capabilityscheduling/legacyacl/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/allocation/capabilityscheduling/legacyacl/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/allocation/capabilityscheduling/legacyacl/test_translate_to_capability_selector.py: -------------------------------------------------------------------------------- 1 | import factory # type: ignore 2 | 3 | from smartschedule.allocation.capabilityscheduling.legacyacl.employee_data_from_legacy_esb_message import ( 4 | EmployeeDataFromLegacyEsbMessage, 5 | ) 6 | from smartschedule.allocation.capabilityscheduling.legacyacl.translate_to_capability_selector import ( 7 | translate, 8 | ) 9 | from smartschedule.shared.capability.capability import Capability 10 | from smartschedule.shared.capability_selector import CapabilitySelector 11 | from smartschedule.shared.timeslot.time_slot import TimeSlot 12 | 13 | 14 | class EmployeeDataFromLegacyEsbMessageFactory(factory.Factory): # type: ignore 15 | class Meta: 16 | model = EmployeeDataFromLegacyEsbMessage 17 | 18 | resource_id = factory.Faker("uuid4") 19 | skills_performed_together = factory.LazyFunction(list) 20 | exclusive_skills = factory.LazyFunction(list) 21 | permissions = factory.LazyFunction(list) 22 | time_slot = TimeSlot.empty() 23 | 24 | 25 | class TestTranslateToCapabilitySelector: 26 | def test_translate_legacy_esb_message_to_capability_selector_model(self) -> None: 27 | legacy_permissions = ["ADMIN<>2", "ROOT<>1"] 28 | legacy_skills_performed_together = [ 29 | ["JAVA", "CSHARP", "PYTHON"], 30 | ["RUST", "CSHARP", "PYTHON"], 31 | ] 32 | legacy_exclusive_skills = ["YT DRAMA COMMENTS"] 33 | message = EmployeeDataFromLegacyEsbMessageFactory( 34 | permissions=legacy_permissions, 35 | skills_performed_together=legacy_skills_performed_together, 36 | exclusive_skills=legacy_exclusive_skills, 37 | ) 38 | 39 | result = translate(message) 40 | 41 | assert len(result) == 6 42 | assert ( 43 | result.count( 44 | CapabilitySelector.can_perform_one_of({Capability.permission("ADMIN")}) 45 | ) 46 | == 2 47 | ) 48 | assert set(result) == { 49 | CapabilitySelector.can_perform_one_of( 50 | {Capability.skill("YT DRAMA COMMENTS")} 51 | ), 52 | CapabilitySelector.can_perform_all_at_the_time( 53 | Capability.skills("JAVA", "CSHARP", "PYTHON") 54 | ), 55 | CapabilitySelector.can_perform_all_at_the_time( 56 | Capability.skills("RUST", "CSHARP", "PYTHON") 57 | ), 58 | CapabilitySelector.can_perform_one_of({Capability.permission("ADMIN")}), 59 | CapabilitySelector.can_perform_one_of({Capability.permission("ROOT")}), 60 | } 61 | 62 | def test_zero_means_no_permission_nowhere(self) -> None: 63 | legacy_permissions = ["ADMIN<>0"] 64 | message = EmployeeDataFromLegacyEsbMessageFactory( 65 | permissions=legacy_permissions 66 | ) 67 | 68 | result = translate(message) 69 | 70 | assert len(result) == 0 71 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/cashflow/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/allocation/cashflow/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/allocation/cashflow/in_memory_cashflow_repository.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Sequence 3 | 4 | from smartschedule.allocation.cashflow.cashflow import Cashflow 5 | from smartschedule.allocation.cashflow.cashflow_repository import CashflowRepository 6 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 7 | from smartschedule.shared.repository import NotFound 8 | 9 | 10 | class InMemoryCashflowRepository(CashflowRepository): 11 | def __init__(self) -> None: 12 | self._data: dict[ProjectAllocationsId, Cashflow] = {} 13 | 14 | def get(self, project_id: ProjectAllocationsId) -> Cashflow: 15 | try: 16 | return deepcopy(self._data[project_id]) 17 | except KeyError: 18 | raise NotFound 19 | 20 | def get_all( 21 | self, ids: list[ProjectAllocationsId] | None = None 22 | ) -> Sequence[Cashflow]: 23 | if ids is None: 24 | return [deepcopy(cashflow) for cashflow in self._data.values()] 25 | 26 | present_ids = set(self._data.keys()) & set(ids) 27 | return [deepcopy(self._data[project_id]) for project_id in present_ids] 28 | 29 | def add(self, cashflow: Cashflow) -> None: 30 | self._data[cashflow.project_id] = deepcopy(cashflow) 31 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/cashflow/test_cash_flow_facade.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | from lagom import Container 5 | from mockito import verify # type: ignore 6 | from mockito.matchers import arg_that # type: ignore 7 | 8 | from smartschedule.allocation.cashflow.cash_flow_facade import CashFlowFacade 9 | from smartschedule.allocation.cashflow.cashflow_repository import CashflowRepository 10 | from smartschedule.allocation.cashflow.cost import Cost 11 | from smartschedule.allocation.cashflow.earnings import Earnings 12 | from smartschedule.allocation.cashflow.earnings_recalculated import EarningsRecalculated 13 | from smartschedule.allocation.cashflow.income import Income 14 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 15 | from smartschedule.shared.event_bus import EventBus 16 | from tests.smartschedule.allocation.cashflow.in_memory_cashflow_repository import ( 17 | InMemoryCashflowRepository, 18 | ) 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def container(container: Container) -> Container: 23 | test_container = container.clone() 24 | test_container[CashflowRepository] = InMemoryCashflowRepository # type: ignore[type-abstract] 25 | return test_container 26 | 27 | 28 | class TestCashFlowFacade: 29 | def test_saves_cashflow(self, cash_flow_facade: CashFlowFacade) -> None: 30 | project_id = ProjectAllocationsId.new_one() 31 | 32 | cash_flow_facade.add_income_and_cost(project_id, Income(100), Cost(50)) 33 | 34 | earnings = cash_flow_facade.find(project_id) 35 | assert earnings == Earnings(50) 36 | 37 | def test_updating_cash_flow_emits_an_event( 38 | self, cash_flow_facade: CashFlowFacade, when: Any 39 | ) -> None: 40 | when(EventBus).publish(...) 41 | project_id = ProjectAllocationsId.new_one() 42 | income = Income(100) 43 | cost = Cost(50) 44 | 45 | cash_flow_facade.add_income_and_cost(project_id, income, cost) 46 | 47 | verify(EventBus).publish( 48 | arg_that( 49 | lambda event: self._is_earnings_recalculated_event( 50 | event, project_id, Earnings(50) 51 | ) 52 | ) 53 | ) 54 | 55 | def _is_earnings_recalculated_event( 56 | self, event: Any, project_id: ProjectAllocationsId, earnings: Earnings 57 | ) -> Any: 58 | return ( 59 | isinstance(event, EarningsRecalculated) 60 | and event.project_id == project_id 61 | and event.earnings == earnings 62 | ) 63 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/cashflow/test_earnings.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | 5 | from smartschedule.allocation.cashflow.cost import Cost 6 | from smartschedule.allocation.cashflow.earnings import Earnings 7 | from smartschedule.allocation.cashflow.income import Income 8 | 9 | 10 | class TestEarnings: 11 | TEN = Decimal(10) 12 | 13 | @pytest.mark.parametrize( 14 | "expected_earnings, income, cost", 15 | [ 16 | (9, 10, 1), 17 | (8, 10, 2), 18 | (7, 10, 3), 19 | (-70, 100, 170), 20 | ], 21 | ) 22 | def test_income_minus_cost( 23 | self, expected_earnings: int, income: int, cost: int 24 | ) -> None: 25 | assert Income(income) - Cost(cost) == Earnings(expected_earnings) 26 | 27 | @pytest.mark.parametrize( 28 | "expected, left, right", 29 | [ 30 | (True, 10, 9), 31 | (True, 10, 0), 32 | (True, 10, -1), 33 | (False, 10, 10), 34 | (False, 10, 11), 35 | ], 36 | ) 37 | def test_greater_than(self, expected: bool, left: int, right: int) -> None: 38 | assert (Earnings(left) > Earnings(right)) is expected 39 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypeAlias 2 | 3 | import pytest 4 | from lagom import Container 5 | 6 | from smartschedule.allocation.capabilityscheduling.allocatable_capability_id import ( 7 | AllocatableCapabilityId, 8 | ) 9 | from smartschedule.allocation.capabilityscheduling.allocatable_resource_id import ( 10 | AllocatableResourceId, 11 | ) 12 | from smartschedule.allocation.capabilityscheduling.capability_scheduler import ( 13 | CapabilityScheduler, 14 | ) 15 | from smartschedule.availability.availability_facade import AvailabilityFacade 16 | from smartschedule.shared.capability.capability import Capability 17 | from smartschedule.shared.capability_selector import CapabilitySelector 18 | from smartschedule.shared.timeslot.time_slot import TimeSlot 19 | from tests.smartschedule.allocation.availability_assert import AvailabilityAssert 20 | 21 | AllocatableResourceFactory: TypeAlias = Callable[ 22 | [TimeSlot, Capability, AllocatableResourceId], AllocatableCapabilityId 23 | ] 24 | 25 | 26 | @pytest.fixture() 27 | def allocatable_resource_factory( 28 | capability_scheduler: CapabilityScheduler, 29 | ) -> AllocatableResourceFactory: 30 | def _create_allocatable_resource( 31 | period: TimeSlot, capability: Capability, resource_id: AllocatableResourceId 32 | ) -> AllocatableCapabilityId: 33 | capabilities = [CapabilitySelector.can_just_perform(capability)] 34 | allocatable_capability_ids = ( 35 | capability_scheduler.schedule_resource_capabilities_for_period( 36 | resource_id, capabilities, period 37 | ) 38 | ) 39 | assert len(allocatable_capability_ids) == 1 40 | return allocatable_capability_ids[0] 41 | 42 | return _create_allocatable_resource 43 | 44 | 45 | @pytest.fixture() 46 | def availability_assert(availability_facade: AvailabilityFacade) -> AvailabilityAssert: 47 | return AvailabilityAssert(availability_facade) 48 | 49 | 50 | @pytest.fixture() 51 | def capability_scheduler(container: Container) -> CapabilityScheduler: 52 | return container.resolve(CapabilityScheduler) 53 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/in_memory_project_allocations_repository.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from smartschedule.allocation.project_allocations import ProjectAllocations 4 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 5 | from smartschedule.allocation.project_allocations_repository import ( 6 | ProjectAllocationsRepository, 7 | ) 8 | from smartschedule.shared.repository import NotFound 9 | 10 | 11 | class InMemoryProjectAllocationsRepository(ProjectAllocationsRepository): 12 | def __init__(self) -> None: 13 | self._data: dict[ProjectAllocationsId, ProjectAllocations] = {} 14 | 15 | def get(self, id: ProjectAllocationsId) -> ProjectAllocations: 16 | try: 17 | return self._data[id] 18 | except KeyError: 19 | raise NotFound 20 | 21 | def get_all( 22 | self, ids: list[ProjectAllocationsId] | None = None 23 | ) -> list[ProjectAllocations]: 24 | if ids is None: 25 | return list(self._data.values()) 26 | return [self._data[id] for id in ids] 27 | 28 | def add(self, model: ProjectAllocations) -> None: 29 | self._data[model.project_id] = model 30 | 31 | def find_all_containing_date(self, when: datetime) -> list[ProjectAllocations]: 32 | return [ 33 | project 34 | for project in self._data.values() 35 | if project.time_slot.from_ <= when and project.time_slot.to > when 36 | ] 37 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/test_create_hourly_demands_summary_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Final 3 | 4 | from smartschedule.allocation.allocations import Allocations 5 | from smartschedule.allocation.demand import Demand 6 | from smartschedule.allocation.demands import Demands 7 | from smartschedule.allocation.project_allocations import ProjectAllocations 8 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 9 | from smartschedule.allocation.publish_missing_demands_service import ( 10 | CreateHourlyDemandsSummaryService, 11 | ) 12 | from smartschedule.shared.capability.capability import Capability 13 | from smartschedule.shared.timeslot.time_slot import TimeSlot 14 | 15 | 16 | class TestCreateHourlyDemandsSummaryService: 17 | NOW: Final = datetime.now() 18 | JAN: Final = TimeSlot.create_monthly_time_slot_at_utc(2021, 1) 19 | CSHARP: Final = Demands.of(Demand(Capability.skill("CSHARP"), JAN)) 20 | JAVA: Final = Demands.of(Demand(Capability.skill("JAVA"), JAN)) 21 | 22 | service = CreateHourlyDemandsSummaryService() 23 | 24 | def test_creates_missing_demands_summary_for_all_given_projects(self) -> None: 25 | csharp_project_id = ProjectAllocationsId.new_one() 26 | java_project_id = ProjectAllocationsId.new_one() 27 | csharp_project = ProjectAllocations( 28 | csharp_project_id, Allocations.none(), self.CSHARP, self.JAN 29 | ) 30 | java_project = ProjectAllocations( 31 | java_project_id, Allocations.none(), self.JAVA, self.JAN 32 | ) 33 | 34 | result = self.service.create([csharp_project, java_project], self.NOW) 35 | 36 | assert result.occurred_at == self.NOW 37 | expected_missing_demands = { 38 | java_project_id: self.JAVA, 39 | csharp_project_id: self.CSHARP, 40 | } 41 | assert result.missing_demands == expected_missing_demands 42 | 43 | def test_takes_into_account_only_projects_with_time_slot(self) -> None: 44 | with_time_slot_id = ProjectAllocationsId.new_one() 45 | without_time_slot_id = ProjectAllocationsId.new_one() 46 | with_time_slot = ProjectAllocations( 47 | with_time_slot_id, Allocations.none(), self.CSHARP, self.JAN 48 | ) 49 | without_time_slot = ProjectAllocations( 50 | without_time_slot_id, Allocations.none(), self.CSHARP, TimeSlot.empty() 51 | ) 52 | 53 | result = self.service.create([with_time_slot, without_time_slot], self.NOW) 54 | 55 | assert result.occurred_at == self.NOW 56 | assert result.missing_demands == {with_time_slot_id: self.CSHARP} 57 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/test_demand_scheduling.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | from unittest.mock import Mock 3 | 4 | import pytest 5 | 6 | from smartschedule.allocation.allocation_facade import AllocationFacade 7 | from smartschedule.allocation.capabilityscheduling.capability_finder import ( 8 | CapabilityFinder, 9 | ) 10 | from smartschedule.allocation.demand import Demand 11 | from smartschedule.allocation.demands import Demands 12 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 13 | from smartschedule.availability.availability_facade import AvailabilityFacade 14 | from smartschedule.shared.capability.capability import Capability 15 | from smartschedule.shared.events_publisher import EventsPublisher 16 | from smartschedule.shared.timeslot.time_slot import TimeSlot 17 | from tests.smartschedule.allocation.in_memory_project_allocations_repository import ( 18 | InMemoryProjectAllocationsRepository, 19 | ) 20 | 21 | 22 | @pytest.fixture() 23 | def allocation_facade() -> AllocationFacade: 24 | return AllocationFacade( 25 | project_allocations_repository=InMemoryProjectAllocationsRepository(), 26 | availability_facade=Mock(spec_set=AvailabilityFacade), 27 | capability_finder=Mock(CapabilityFinder), 28 | event_publisher=Mock(spec_set=EventsPublisher), 29 | ) 30 | 31 | 32 | class TestDemandScheduling: 33 | JAVA: Final = Demand( 34 | Capability.skill("JAVA"), TimeSlot.create_daily_time_slot_at_utc(2022, 2, 2) 35 | ) 36 | 37 | def test_schedule_project_demands( 38 | self, allocation_facade: AllocationFacade 39 | ) -> None: 40 | project_id = ProjectAllocationsId.new_one() 41 | demands = Demands.of(self.JAVA) 42 | 43 | allocation_facade.schedule_project_allocations_demands(project_id, demands) 44 | 45 | summary = allocation_facade.find_all_projects_allocations() 46 | assert len(summary.project_allocations[project_id].all) == 0 47 | assert summary.demands[project_id].all == [self.JAVA] 48 | -------------------------------------------------------------------------------- /tests/smartschedule/allocation/test_project_allocations_repository.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | from lagom import Container 5 | 6 | from smartschedule.allocation.allocations import Allocations 7 | from smartschedule.allocation.demands import Demands 8 | from smartschedule.allocation.project_allocations import ProjectAllocations 9 | from smartschedule.allocation.project_allocations_id import ProjectAllocationsId 10 | from smartschedule.allocation.project_allocations_repository import ( 11 | ProjectAllocationsRepository, 12 | ) 13 | from smartschedule.shared.timeslot.time_slot import TimeSlot 14 | 15 | 16 | @pytest.fixture() 17 | def repository(container: Container) -> ProjectAllocationsRepository: 18 | return container.resolve(ProjectAllocationsRepository) # type: ignore[type-abstract] 19 | 20 | 21 | class TestProjectAllocationsRepository: 22 | def test_finds_projects_containing_date( 23 | self, repository: ProjectAllocationsRepository 24 | ) -> None: 25 | project_allocations_before = self._project_allocations( 26 | ProjectAllocationsId.new_one(), 27 | TimeSlot.create_daily_time_slot_at_utc(2021, 1, 1), 28 | ) 29 | project_id_within = ProjectAllocationsId.new_one() 30 | project_allocations_within = self._project_allocations( 31 | project_id_within, 32 | TimeSlot.create_daily_time_slot_at_utc(2021, 2, 1), 33 | ) 34 | project_allocations_after = self._project_allocations( 35 | ProjectAllocationsId.new_one(), 36 | TimeSlot.create_daily_time_slot_at_utc(2021, 3, 1), 37 | ) 38 | repository.add(project_allocations_before) 39 | repository.add(project_allocations_within) 40 | repository.add(project_allocations_after) 41 | 42 | result = repository.find_all_containing_date(datetime(2021, 2, 1)) 43 | 44 | assert len(result) == 1 45 | assert result[0].project_id == project_id_within 46 | 47 | def _project_allocations( 48 | self, project_id: ProjectAllocationsId, time_slot: TimeSlot 49 | ) -> ProjectAllocations: 50 | return ProjectAllocations( 51 | project_id, Allocations.none(), Demands.none(), time_slot 52 | ) 53 | -------------------------------------------------------------------------------- /tests/smartschedule/availability/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/availability/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/availability/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from lagom import Container 3 | 4 | from smartschedule.availability.resource_availability_repository import ( 5 | ResourceAvailabilityRepository, 6 | ) 7 | 8 | 9 | @pytest.fixture() 10 | def repository(container: Container) -> ResourceAvailabilityRepository: 11 | return container.resolve(ResourceAvailabilityRepository) 12 | -------------------------------------------------------------------------------- /tests/smartschedule/availability/segment/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/availability/segment/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/availability/segment/test_slot_to_normalized_slot.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Final 3 | 4 | import pytest 5 | 6 | from smartschedule.availability.segment.segment_in_minutes import SegmentInMinutes 7 | from smartschedule.availability.segment.slot_to_normalized_slot import ( 8 | slot_to_normalized_slot, 9 | ) 10 | from smartschedule.shared.timeslot.time_slot import TimeSlot 11 | 12 | 13 | class TestSlotToNormalizedSlod: 14 | FIFTEEN_MINUTES_SEGMENT_DURATION: Final = 15 15 | 16 | def test_has_no_effect_when_slot_already_normalized(self) -> None: 17 | start = datetime(2023, 9, 9) 18 | end = datetime(2023, 9, 9, 1) 19 | time_slot = TimeSlot(start, end) 20 | one_hour = SegmentInMinutes(60, self.FIFTEEN_MINUTES_SEGMENT_DURATION) 21 | 22 | normalized = slot_to_normalized_slot(time_slot, one_hour) 23 | 24 | assert time_slot == normalized 25 | 26 | def test_normalization_to_1_hour(self) -> None: 27 | start = datetime(2023, 9, 9, 0, 10) 28 | end = datetime(2023, 9, 9, 0, 59) 29 | time_slot = TimeSlot(start, end) 30 | one_hour = SegmentInMinutes(60, self.FIFTEEN_MINUTES_SEGMENT_DURATION) 31 | 32 | normalized = slot_to_normalized_slot(time_slot, one_hour) 33 | 34 | assert normalized == TimeSlot(datetime(2023, 9, 9), datetime(2023, 9, 9, 1)) 35 | 36 | def test_normalized_short_slot_overlapping_two_segments(self) -> None: 37 | start = datetime(2023, 9, 9, 0, 29) 38 | end = datetime(2023, 9, 9, 0, 31) 39 | time_slot = TimeSlot(start, end) 40 | one_hour = SegmentInMinutes(60, self.FIFTEEN_MINUTES_SEGMENT_DURATION) 41 | 42 | normalized = slot_to_normalized_slot(time_slot, one_hour) 43 | 44 | assert normalized == TimeSlot(datetime(2023, 9, 9), datetime(2023, 9, 9, 1)) 45 | 46 | @pytest.mark.parametrize( 47 | "start, end", 48 | [ 49 | (datetime(2023, 9, 9, 0, 15), datetime(2023, 9, 9, 0, 30)), 50 | (datetime(2023, 9, 9, 0, 30), datetime(2023, 9, 9, 0, 45)), 51 | ], 52 | ) 53 | def test_no_normalization_when_slot_starts_at_segment_start( 54 | self, start: datetime, end: datetime 55 | ) -> None: 56 | fifteen_minutes = SegmentInMinutes(15, self.FIFTEEN_MINUTES_SEGMENT_DURATION) 57 | 58 | normalized = slot_to_normalized_slot(TimeSlot(start, end), fifteen_minutes) 59 | 60 | assert normalized == TimeSlot(start, end) 61 | -------------------------------------------------------------------------------- /tests/smartschedule/availability/test_resource_availability_loading.py: -------------------------------------------------------------------------------- 1 | from smartschedule.availability.resource_availability import ResourceAvailability 2 | from smartschedule.availability.resource_availability_id import ResourceAvailabilityId 3 | from smartschedule.availability.resource_availability_repository import ( 4 | ResourceAvailabilityRepository, 5 | ) 6 | from smartschedule.availability.resource_id import ResourceId 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | class TestResourceAvailabilityLoading: 11 | ONE_MONTH = TimeSlot.create_monthly_time_slot_at_utc(2021, 1) 12 | 13 | def test_saves_and_loads_by_id( 14 | self, repository: ResourceAvailabilityRepository 15 | ) -> None: 16 | resource_availablity_id = ResourceAvailabilityId.new_one() 17 | resource_id = ResourceId.new_one() 18 | resource_availability = ResourceAvailability( 19 | resource_availablity_id, resource_id, self.ONE_MONTH 20 | ) 21 | 22 | repository.save_new(resource_availability) 23 | 24 | loaded = repository.load_by_id(resource_availability.id) 25 | assert loaded == resource_availability 26 | assert loaded.segment == self.ONE_MONTH 27 | assert loaded.resource_id == resource_availability.resource_id 28 | assert loaded.blocked_by() == resource_availability.blocked_by() 29 | -------------------------------------------------------------------------------- /tests/smartschedule/availability/test_resource_availability_optimistic_locking.py: -------------------------------------------------------------------------------- 1 | from concurrent.futures import ThreadPoolExecutor 2 | 3 | from smartschedule.availability.owner import Owner 4 | from smartschedule.availability.resource_availability import ResourceAvailability 5 | from smartschedule.availability.resource_availability_id import ResourceAvailabilityId 6 | from smartschedule.availability.resource_availability_repository import ( 7 | ResourceAvailabilityRepository, 8 | ) 9 | from smartschedule.availability.resource_id import ResourceId 10 | from smartschedule.shared.timeslot.time_slot import TimeSlot 11 | 12 | 13 | class TestResourceAvailabilityOptimisticLocking: 14 | ONE_MONTH = TimeSlot.create_monthly_time_slot_at_utc(2021, 1) 15 | 16 | def test_update_bumps_version( 17 | self, repository: ResourceAvailabilityRepository 18 | ) -> None: 19 | resource_availability_id = ResourceAvailabilityId.new_one() 20 | resource_id = ResourceId.new_one() 21 | resource_availability = ResourceAvailability( 22 | resource_availability_id, resource_id, self.ONE_MONTH 23 | ) 24 | repository.save_new(resource_availability) 25 | 26 | loaded = repository.load_by_id(resource_availability.id) 27 | loaded.block(Owner.new_one()) 28 | repository.save_checking_version(loaded) 29 | 30 | loaded_again = repository.load_by_id(resource_availability.id) 31 | assert loaded_again.version == 1 32 | 33 | def test_cant_update_concurrently( 34 | self, repository: ResourceAvailabilityRepository 35 | ) -> None: 36 | resource_availability_id = ResourceAvailabilityId.new_one() 37 | resource_id = ResourceId.new_one() 38 | resource_availability = ResourceAvailability( 39 | resource_availability_id, resource_id, self.ONE_MONTH 40 | ) 41 | repository.save_new(resource_availability) 42 | 43 | results: list[bool] = [] 44 | 45 | def try_to_block() -> None: 46 | loaded = repository.load_by_id(resource_availability.id) 47 | loaded.block(Owner.new_one()) 48 | result = repository.save_checking_version(loaded) 49 | results.append(result) 50 | 51 | pool = ThreadPoolExecutor(max_workers=5) 52 | with pool: 53 | for _ in range(10): 54 | pool.submit(try_to_block) 55 | 56 | assert results.count(True) > 0 57 | assert results.count(False) > 0 58 | loaded = repository.load_by_id(resource_availability.id) 59 | assert loaded.version < 10 60 | -------------------------------------------------------------------------------- /tests/smartschedule/availability/test_resource_availability_uniqueness.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.exc import IntegrityError 3 | 4 | from smartschedule.availability.resource_availability import ResourceAvailability 5 | from smartschedule.availability.resource_availability_id import ResourceAvailabilityId 6 | from smartschedule.availability.resource_availability_repository import ( 7 | ResourceAvailabilityRepository, 8 | ) 9 | from smartschedule.availability.resource_id import ResourceId 10 | from smartschedule.shared.timeslot.time_slot import TimeSlot 11 | 12 | 13 | class TestResourceAvailabilityUniqueness: 14 | ONE_MONTH = TimeSlot.create_monthly_time_slot_at_utc(2021, 1) 15 | 16 | def test_cant_save_two_availabilities_with_the_same_resource_id_and_segment( 17 | self, repository: ResourceAvailabilityRepository 18 | ) -> None: 19 | resource_id = ResourceId.new_one() 20 | another_resource_id = ResourceId.new_one() 21 | resource_availability_id = ResourceAvailabilityId.new_one() 22 | resource_availability = ResourceAvailability( 23 | resource_availability_id, resource_id, self.ONE_MONTH 24 | ) 25 | repository.save_new(resource_availability) 26 | 27 | another_resource_availability = ResourceAvailability( 28 | resource_availability_id, another_resource_id, self.ONE_MONTH 29 | ) 30 | 31 | with pytest.raises(IntegrityError): 32 | repository.save_new(another_resource_availability) 33 | -------------------------------------------------------------------------------- /tests/smartschedule/availability/test_taking_random_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from smartschedule.availability.availability_facade import AvailabilityFacade 4 | from smartschedule.availability.owner import Owner 5 | from smartschedule.availability.resource_id import ResourceId 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | 8 | 9 | class TestTakingRandomResource: 10 | @pytest.fixture(autouse=True) 11 | def setup(self, availability_facade: AvailabilityFacade) -> None: 12 | self._availability = availability_facade 13 | 14 | def test_can_take_random_resource_from_pool(self) -> None: 15 | resource_id = ResourceId.new_one() 16 | resource_id2 = ResourceId.new_one() 17 | resource_id3 = ResourceId.new_one() 18 | resources_pool = {resource_id, resource_id2, resource_id3} 19 | owner_1 = Owner.new_one() 20 | owner_2 = Owner.new_one() 21 | owner_3 = Owner.new_one() 22 | one_day = TimeSlot.create_daily_time_slot_at_utc(2021, 1, 1) 23 | self._availability.create_resource_slots(resource_id, one_day) 24 | self._availability.create_resource_slots(resource_id2, one_day) 25 | self._availability.create_resource_slots(resource_id3, one_day) 26 | 27 | taken_1 = self._availability.block_random_available( 28 | resources_pool, one_day, owner_1 29 | ) 30 | assert taken_1 in resources_pool 31 | assert self._resource_is_taken_by_owner(taken_1, owner_1, one_day) 32 | 33 | taken_2 = self._availability.block_random_available( 34 | resources_pool, one_day, owner_2 35 | ) 36 | assert taken_2 in resources_pool 37 | assert self._resource_is_taken_by_owner(taken_2, owner_2, one_day) 38 | 39 | taken_3 = self._availability.block_random_available( 40 | resources_pool, one_day, owner_3 41 | ) 42 | assert taken_3 in resources_pool 43 | assert self._resource_is_taken_by_owner(taken_3, owner_3, one_day) 44 | 45 | taken_4 = self._availability.block_random_available( 46 | resources_pool, one_day, owner_3 47 | ) 48 | assert taken_4 is None 49 | 50 | def test_nothing_is_taken_when_no_resource_in_pool(self) -> None: 51 | resources = {ResourceId.new_one(), ResourceId.new_one(), ResourceId.new_one()} 52 | jan_1 = TimeSlot.create_daily_time_slot_at_utc(2021, 1, 1) 53 | 54 | taken_1 = self._availability.block_random_available( 55 | resources, jan_1, Owner.new_one() 56 | ) 57 | 58 | assert taken_1 is None 59 | 60 | def _resource_is_taken_by_owner( 61 | self, resource_id: ResourceId, owner: Owner, one_day: TimeSlot 62 | ) -> bool: 63 | resource_availability = self._availability.find(resource_id, one_day) 64 | return all( 65 | availability.blocked_by() == owner 66 | for availability in resource_availability.resource_availabilities 67 | ) 68 | -------------------------------------------------------------------------------- /tests/smartschedule/optimization/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/optimization/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/optimization/capability_capacity_dimension.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from uuid import UUID, uuid4 3 | 4 | from smartschedule.optimization.capacity_dimension import CapacityDimension 5 | from smartschedule.optimization.weight_dimension import WeightDimension 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | 8 | 9 | @dataclass(frozen=True) 10 | class CapabilityCapacityDimension(CapacityDimension): 11 | uuid: UUID = field(default_factory=uuid4, init=False) 12 | id: str 13 | capacity_name: str 14 | capacity_type: str 15 | 16 | 17 | @dataclass(frozen=True) 18 | class CapabilityWeightDimension(WeightDimension[CapabilityCapacityDimension]): 19 | name: str 20 | type: str 21 | 22 | def is_satisfied_by(self, capacity: CapabilityCapacityDimension) -> bool: 23 | return ( 24 | capacity.capacity_name == self.name and capacity.capacity_type == self.type 25 | ) 26 | 27 | 28 | @dataclass(frozen=True) 29 | class CapabilityTimedCapacityDimension(CapacityDimension): 30 | uuid: UUID = field(default_factory=uuid4, init=False) 31 | id: str 32 | capacity_name: str 33 | capacity_type: str 34 | time_slot: TimeSlot 35 | 36 | 37 | @dataclass(frozen=True) 38 | class CapabilityTimedWeightDimension(WeightDimension[CapabilityTimedCapacityDimension]): 39 | name: str 40 | type: str 41 | time_slot: TimeSlot 42 | 43 | def is_satisfied_by(self, capacity: CapabilityTimedCapacityDimension) -> bool: 44 | return ( 45 | capacity.capacity_name == self.name 46 | and capacity.capacity_type == self.type 47 | and self.time_slot.within(capacity.time_slot) 48 | ) 49 | -------------------------------------------------------------------------------- /tests/smartschedule/optimization/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from smartschedule.optimization.optimization_facade import OptimizationFacade 4 | 5 | 6 | @pytest.fixture() 7 | def optimization_facade() -> OptimizationFacade: 8 | return OptimizationFacade() 9 | -------------------------------------------------------------------------------- /tests/smartschedule/optimization/test_optimization_for_timed_capabilities.py: -------------------------------------------------------------------------------- 1 | from smartschedule.optimization.item import Item 2 | from smartschedule.optimization.optimization_facade import OptimizationFacade 3 | from smartschedule.optimization.total_capacity import TotalCapacity 4 | from smartschedule.optimization.total_weight import TotalWeight 5 | from smartschedule.shared.timeslot.time_slot import TimeSlot 6 | from tests.smartschedule.optimization.capability_capacity_dimension import ( 7 | CapabilityTimedCapacityDimension, 8 | CapabilityTimedWeightDimension, 9 | ) 10 | 11 | 12 | class TestOptimizationForTimedCapabilities: 13 | def test_nothing_is_chosen_when_no_capacities_in_time_slot( 14 | self, optimization_facade: OptimizationFacade 15 | ) -> None: 16 | june = TimeSlot.create_monthly_time_slot_at_utc(2020, 6) 17 | october = TimeSlot.create_monthly_time_slot_at_utc(2020, 10) 18 | 19 | items = [ 20 | Item( 21 | "Item1", 22 | 100, 23 | TotalWeight.of( 24 | CapabilityTimedWeightDimension("COMMON SENSE", "Skill", june) 25 | ), 26 | ), 27 | Item( 28 | "Item2", 29 | 100, 30 | TotalWeight.of( 31 | CapabilityTimedWeightDimension("THINKING", "Skill", june) 32 | ), 33 | ), 34 | ] 35 | 36 | result = optimization_facade.calculate( 37 | items, 38 | TotalCapacity.of( 39 | CapabilityTimedCapacityDimension( 40 | "anna", "COMMON SENSE", "Skill", october 41 | ) 42 | ), 43 | ) 44 | 45 | assert result.profit == 0 46 | assert len(result.chosen_items) == 0 47 | 48 | def test_most_profitable_item_is_chosen( 49 | self, optimization_facade: OptimizationFacade 50 | ) -> None: 51 | june = TimeSlot.create_monthly_time_slot_at_utc(2020, 6) 52 | 53 | items = [ 54 | Item( 55 | "Item1", 56 | 200, 57 | TotalWeight.of( 58 | CapabilityTimedWeightDimension("COMMON SENSE", "Skill", june) 59 | ), 60 | ), 61 | Item( 62 | "Item2", 63 | 100, 64 | TotalWeight.of( 65 | CapabilityTimedWeightDimension("THINKING", "Skill", june) 66 | ), 67 | ), 68 | ] 69 | 70 | result = optimization_facade.calculate( 71 | items, 72 | TotalCapacity.of( 73 | CapabilityTimedCapacityDimension("anna", "COMMON SENSE", "Skill", june) 74 | ), 75 | ) 76 | 77 | assert result.profit == 200 78 | assert len(result.chosen_items) == 1 79 | -------------------------------------------------------------------------------- /tests/smartschedule/planning/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/planning/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/planning/in_memory_project_repository.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | from typing import Sequence 3 | 4 | from smartschedule.planning.project import Project 5 | from smartschedule.planning.project_id import ProjectId 6 | from smartschedule.planning.project_repository import ProjectRepository 7 | from smartschedule.shared.repository import NotFound 8 | 9 | 10 | class InMemoryProjectRepository(ProjectRepository): 11 | def __init__(self) -> None: 12 | self._data: dict[ProjectId, Project] = {} 13 | 14 | def get(self, id: ProjectId) -> Project: 15 | try: 16 | return self._data[id] 17 | except KeyError: 18 | raise NotFound 19 | 20 | def get_all(self, ids: list[ProjectId] | None = None) -> Sequence[Project]: 21 | if ids is None: 22 | return [project for project in self._data.values()] 23 | 24 | present_ids = set(self._data.keys()) & set(ids) 25 | return [self._data[id] for id in present_ids] 26 | 27 | def save(self, model: Project) -> None: 28 | self._data[model.id] = deepcopy(model) 29 | -------------------------------------------------------------------------------- /tests/smartschedule/planning/parallelization/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/planning/parallelization/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/planning/parallelization/test_dependency_removal_suggesting.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from smartschedule.planning.parallelization.stage import Stage 4 | from smartschedule.planning.parallelization.stage_parallelization import ( 5 | StageParallelization, 6 | ) 7 | from smartschedule.sorter.edge import Edge 8 | 9 | 10 | @pytest.fixture() 11 | def stage_parallelization() -> StageParallelization: 12 | return StageParallelization() 13 | 14 | 15 | class TestDependencyRemovalSuggesting: 16 | def test_suggests_breaking_cycle_in_schedule( 17 | self, stage_parallelization: StageParallelization 18 | ) -> None: 19 | stage1 = Stage("Stage1") 20 | stage2 = Stage("Stage2") 21 | stage3 = Stage("Stage3") 22 | stage4 = Stage("Stage4") 23 | stage1 = stage1.depends_on(stage2) 24 | stage2 = stage2.depends_on(stage3) 25 | stage4 = stage4.depends_on(stage3) 26 | stage1 = stage1.depends_on(stage4) 27 | stage3 = stage3.depends_on(stage1) 28 | 29 | suggestion = stage_parallelization.what_to_remove( 30 | {stage1, stage2, stage3, stage4} 31 | ) 32 | 33 | assert set(suggestion.edges) == {Edge(3, 1), Edge(4, 3)} 34 | -------------------------------------------------------------------------------- /tests/smartschedule/planning/parallelization/test_duration_calculator.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from smartschedule.planning.parallelization.duration_calculator import ( 4 | calculate_duration, 5 | ) 6 | from smartschedule.planning.parallelization.stage import Stage 7 | 8 | 9 | class TestDurationCalculator: 10 | def test_longest_stage_is_taken_into_account(self) -> None: 11 | stage_1 = Stage("Stage 1", duration=timedelta()) 12 | stage_2 = Stage("Stage 2", duration=timedelta(days=3)) 13 | stage_3 = Stage("Stage 3", duration=timedelta(days=2)) 14 | stage_4 = Stage("Stage 4", duration=timedelta(days=5)) 15 | 16 | duration = calculate_duration([stage_1, stage_2, stage_3, stage_4]) 17 | 18 | assert duration == timedelta(days=5) 19 | 20 | def test_sum_is_taken_into_account_when_nothing_is_parallel(self) -> None: 21 | stage_1 = Stage("Stage 1", duration=timedelta(hours=10)) 22 | stage_2 = Stage("Stage 2", duration=timedelta(hours=24)) 23 | stage_3 = Stage("Stage 3", duration=timedelta(days=2)) 24 | stage_4 = Stage("Stage 4", duration=timedelta(days=1)) 25 | stage_4 = stage_4.depends_on(stage_3) 26 | stage_3 = stage_3.depends_on(stage_2) 27 | stage_2 = stage_2.depends_on(stage_1) 28 | 29 | duration = calculate_duration([stage_1, stage_2, stage_3, stage_4]) 30 | 31 | assert duration == timedelta(hours=106) 32 | -------------------------------------------------------------------------------- /tests/smartschedule/planning/parallelization/test_parallelization.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from smartschedule.availability.resource_id import ResourceId 4 | from smartschedule.planning.parallelization.stage import Stage 5 | from smartschedule.planning.parallelization.stage_parallelization import ( 6 | StageParallelization, 7 | ) 8 | 9 | LEON = ResourceId.new_one() 10 | ERYK = ResourceId.new_one() 11 | SLAWEK = ResourceId.new_one() 12 | KUBA = ResourceId.new_one() 13 | 14 | 15 | class TestParallelization: 16 | @pytest.fixture() 17 | def stage_parallelization(self) -> StageParallelization: 18 | return StageParallelization() 19 | 20 | def test_everything_can_be_done_in_parallel_if_there_are_no_dependencies( 21 | self, stage_parallelization: StageParallelization 22 | ) -> None: 23 | stage_1 = Stage("Stage1") 24 | stage_2 = Stage("Stage2") 25 | 26 | sorted_stages = stage_parallelization.of({stage_1, stage_2}) 27 | 28 | assert len(sorted_stages.all) == 1 29 | 30 | def test_simple_dependencies( 31 | self, stage_parallelization: StageParallelization 32 | ) -> None: 33 | stage_1 = Stage("Stage1") 34 | stage_2 = Stage("Stage2") 35 | stage_3 = Stage("Stage3") 36 | stage_4 = Stage("Stage4") 37 | stage_2 = stage_2.depends_on(stage_1) 38 | stage_3 = stage_3.depends_on(stage_1) 39 | stage_4 = stage_4.depends_on(stage_2) 40 | 41 | sorted_stages = stage_parallelization.of({stage_1, stage_2, stage_3, stage_4}) 42 | 43 | assert str(sorted_stages) == "Stage1 | Stage2, Stage3 | Stage4" 44 | 45 | def test_cant_be_done_when_there_is_a_cycle( 46 | self, stage_parallelization: StageParallelization 47 | ) -> None: 48 | stage_1 = Stage("Stage1") 49 | stage_2 = Stage("Stage2") 50 | stage_2 = stage_2.depends_on(stage_1) 51 | stage_1 = stage_1.depends_on(stage_2) # making it cyclic 52 | 53 | sorted_stages = stage_parallelization.of({stage_1, stage_2}) 54 | 55 | assert len(sorted_stages.all) == 0 56 | 57 | def test_takes_into_account_shared_resources( 58 | self, stage_parallelization: StageParallelization 59 | ) -> None: 60 | stage_1 = Stage("Stage1").with_chosen_resource_capabilities(LEON) 61 | stage_2 = Stage("Stage2").with_chosen_resource_capabilities(ERYK, LEON) 62 | stage_3 = Stage("Stage3").with_chosen_resource_capabilities(SLAWEK) 63 | stage_4 = Stage("Stage4").with_chosen_resource_capabilities(SLAWEK, KUBA) 64 | 65 | parallel_stages = stage_parallelization.of({stage_1, stage_2, stage_3, stage_4}) 66 | 67 | assert str(parallel_stages) in [ 68 | "Stage1, Stage3 | Stage2, Stage4", 69 | "Stage2, Stage4 | Stage1, Stage3", 70 | ] 71 | -------------------------------------------------------------------------------- /tests/smartschedule/planning/schedule/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/planning/schedule/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/planning/schedule/assertions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/planning/schedule/assertions/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/planning/schedule/assertions/schedule_assert.py: -------------------------------------------------------------------------------- 1 | from smartschedule.planning.schedule.schedule import Schedule 2 | from tests.smartschedule.planning.schedule.assertions.stage_assert import ( 3 | StageScheduleAssert, 4 | ) 5 | 6 | 7 | class ScheduleAssert: 8 | def __init__(self, schedule: Schedule) -> None: 9 | self._schedule = schedule 10 | 11 | def assert_has_stages(self, number: int) -> None: 12 | __tracebackhide__ = True 13 | 14 | actual = len(self._schedule.dates) 15 | assert actual == number, f"Expected to have {number} stages, but got {actual}" 16 | 17 | def assert_has_stage(self, stage_name: str) -> StageScheduleAssert: 18 | __tracebackhide__ = True 19 | 20 | all_stages_names = list(self._schedule.dates.keys()) 21 | assert ( 22 | stage_name in all_stages_names 23 | ), f"Expected to have stage {stage_name}, but not found in {all_stages_names}" 24 | return StageScheduleAssert(self._schedule.dates[stage_name], self._schedule) 25 | 26 | def assert_is_empty(self) -> None: 27 | __tracebackhide__ = True 28 | 29 | assert not self._schedule.dates, "Expected to be empty, but it's not" 30 | -------------------------------------------------------------------------------- /tests/smartschedule/planning/schedule/assertions/stage_assert.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Self 5 | 6 | from smartschedule.planning.schedule.schedule import Schedule 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | class StageAssert: 11 | def __init__(self, actual: TimeSlot) -> None: 12 | self._actual = actual 13 | 14 | def assert_starts_at(self, start: datetime) -> None: 15 | __tracebackhide__ = True 16 | 17 | assert ( 18 | self._actual.from_ == start 19 | ), f"Expected to start at {start}, but got {self._actual.from_}" 20 | 21 | def assert_with_slot(self, expected: TimeSlot) -> Self: 22 | __tracebackhide__ = True 23 | 24 | assert ( 25 | self._actual == expected 26 | ), f"Expected to be {expected}, but got {self._actual}" 27 | return self 28 | 29 | def assert_ends_at(self, end: datetime) -> None: 30 | __tracebackhide__ = True 31 | 32 | assert ( 33 | self._actual.to == end 34 | ), f"Expected to end at {end}, but got {self._actual.to}" 35 | 36 | def with_schedule(self, schedule: Schedule) -> StageScheduleAssert: 37 | return StageScheduleAssert(self._actual, schedule) 38 | 39 | 40 | class StageScheduleAssert(StageAssert): 41 | def __init__(self, actual: TimeSlot, schedule: Schedule) -> None: 42 | super().__init__(actual) 43 | self._schedule = schedule 44 | 45 | def assert_is_before(self, stage_name: str) -> None: 46 | __tracebackhide__ = True 47 | 48 | schedule_from = self._schedule.dates[stage_name].from_ 49 | 50 | assert ( 51 | self._actual.to <= schedule_from 52 | ), f"Expected to be before {schedule_from}, but is not ({self._actual})" 53 | 54 | def assert_starts_together_with(self, stage_name: str) -> None: 55 | __tracebackhide__ = True 56 | 57 | schedule_from = self._schedule.dates[stage_name].from_ 58 | 59 | assert ( 60 | self._actual.from_ == schedule_from 61 | ), f"Expected to start together with {schedule_from}, but is not ({self._actual.from_})" 62 | 63 | def assert_is_after(self, stage_name: str) -> None: 64 | __tracebackhide__ = True 65 | 66 | schedule_to = self._schedule.dates[stage_name].to 67 | 68 | assert ( 69 | self._actual.from_ >= schedule_to 70 | ), f"Expected to be after {schedule_to}, but is not ({self._actual})" 71 | -------------------------------------------------------------------------------- /tests/smartschedule/planning/test_time_critical_waterfall.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Final 3 | 4 | from smartschedule.planning.parallelization.stage import Stage 5 | from smartschedule.planning.planning_facade import PlanningFacade 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | from tests.smartschedule.planning.schedule.assertions.schedule_assert import ( 8 | ScheduleAssert, 9 | ) 10 | 11 | 12 | class TestTimeCriticalWaterfall: 13 | JAN_1_5: Final = TimeSlot(datetime(2020, 1, 1), datetime(2020, 1, 5)) 14 | JAN_1_3: Final = TimeSlot(datetime(2020, 1, 1), datetime(2020, 1, 3)) 15 | JAN_1_4: Final = TimeSlot(datetime(2020, 1, 1), datetime(2020, 1, 4)) 16 | 17 | def test_time_critical_waterfall_project_process( 18 | self, planning_facade: PlanningFacade 19 | ) -> None: 20 | project_id = planning_facade.add_new_project("waterfall") 21 | stage_before_critical = Stage("Stage1", duration=timedelta(days=2)) 22 | critical_stage = Stage("Stage2", duration=self.JAN_1_5.duration) 23 | stage_after_critical = Stage("Stage3", duration=timedelta(days=3)) 24 | planning_facade.define_project_stages( 25 | project_id, stage_before_critical, critical_stage, stage_after_critical 26 | ) 27 | 28 | planning_facade.plan_critical_stage(project_id, critical_stage, self.JAN_1_5) 29 | 30 | schedule = planning_facade.load(project_id).schedule 31 | schedule_assert = ScheduleAssert(schedule) 32 | schedule_assert.assert_has_stage("Stage1").assert_with_slot(self.JAN_1_3) 33 | schedule_assert.assert_has_stage("Stage2").assert_with_slot(self.JAN_1_5) 34 | schedule_assert.assert_has_stage("Stage3").assert_with_slot(self.JAN_1_4) 35 | -------------------------------------------------------------------------------- /tests/smartschedule/resource/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/resource/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/resource/device/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/resource/device/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/resource/device/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from lagom import Container 3 | 4 | from smartschedule.resource.device.device_facade import DeviceFacade 5 | 6 | 7 | @pytest.fixture() 8 | def device_facade(container: Container) -> DeviceFacade: 9 | return container.resolve(DeviceFacade) 10 | -------------------------------------------------------------------------------- /tests/smartschedule/resource/device/test_creating_device.py: -------------------------------------------------------------------------------- 1 | from smartschedule.resource.device.device_facade import DeviceFacade 2 | from smartschedule.shared.capability.capability import Capability 3 | 4 | 5 | class TestCreatingDevice: 6 | def test_creates_and_loads_devices(self, device_facade: DeviceFacade) -> None: 7 | device_id = device_facade.create_device( 8 | "super-excavator-1000", Capability.assets("BULLDOZER", "EXCAVATOR") 9 | ) 10 | 11 | loaded = device_facade.find_device(device_id) 12 | 13 | assert loaded.assets == Capability.assets("BULLDOZER", "EXCAVATOR") 14 | assert loaded.model == "super-excavator-1000" 15 | 16 | def test_find_all_capabilities(self, device_facade: DeviceFacade) -> None: 17 | device_facade.create_device( 18 | "super-excavator-1000", Capability.assets("SMALL-EXCAVATOR", "BULLDOZER") 19 | ) 20 | device_facade.create_device( 21 | "super-excavator-1000", 22 | Capability.assets("MEDIUM-EXCAVATOR", "UBER-BULLDOZER"), 23 | ) 24 | device_facade.create_device( 25 | "super-excavator-1000", Capability.assets("BIG-EXCAVATOR") 26 | ) 27 | 28 | loaded = device_facade.find_all_capabilities() 29 | 30 | assert set(loaded) == { 31 | Capability.asset("SMALL-EXCAVATOR"), 32 | Capability.asset("BULLDOZER"), 33 | Capability.asset("MEDIUM-EXCAVATOR"), 34 | Capability.asset("UBER-BULLDOZER"), 35 | Capability.asset("BIG-EXCAVATOR"), 36 | } 37 | -------------------------------------------------------------------------------- /tests/smartschedule/resource/device/test_schedule_device_capabilities.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.capability_finder import ( 2 | CapabilityFinder, 3 | ) 4 | from smartschedule.resource.device.device_facade import DeviceFacade 5 | from smartschedule.shared.capability.capability import Capability 6 | from smartschedule.shared.timeslot.time_slot import TimeSlot 7 | 8 | 9 | class TestScheduleDeviceCapabilities: 10 | def test_can_setup_capabilities_according_to_policy( 11 | self, device_facade: DeviceFacade, capability_finder: CapabilityFinder 12 | ) -> None: 13 | device_id = device_facade.create_device( 14 | "super-bulldozer-3000", Capability.assets("EXCAVATOR", "BULLDOZER") 15 | ) 16 | 17 | one_day = TimeSlot.create_daily_time_slot_at_utc(2021, 1, 1) 18 | allocations = device_facade.schedule_capabilities(device_id, one_day) 19 | 20 | loaded = capability_finder.find_by_id(*allocations) 21 | assert len(loaded.all) == len(allocations) 22 | -------------------------------------------------------------------------------- /tests/smartschedule/resource/employee/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/resource/employee/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/resource/employee/test_allocation_policies.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from smartschedule.resource.employee.employee_allocation_policy import ( 4 | EmployeeAllocationPolicy, 5 | ) 6 | from smartschedule.resource.employee.employee_id import EmployeeId 7 | from smartschedule.resource.employee.employee_summary import EmployeeSummary 8 | from smartschedule.resource.employee.seniority import Seniority 9 | from smartschedule.shared.capability.capability import Capability 10 | from smartschedule.shared.capability_selector import CapabilitySelector 11 | 12 | 13 | class TestAllocationPolicies: 14 | def test_default_policy_returns_just_one_skill_at_once(self) -> None: 15 | summary = EmployeeSummary( 16 | EmployeeId.new_one(), 17 | "resourceName", 18 | "lastName", 19 | Seniority.LEAD, 20 | Capability.skills("JAVA"), 21 | Capability.permissions("ADMIN"), 22 | ) 23 | 24 | capabilities = ( 25 | EmployeeAllocationPolicy.default_policy().simultaneous_capabilities_of( 26 | summary 27 | ) 28 | ) 29 | 30 | assert len(capabilities) == 1 31 | assert capabilities[0].capabilities == { 32 | Capability.skill("JAVA"), 33 | Capability.permission("ADMIN"), 34 | } 35 | 36 | def test_permissions_can_be_shared_between_projects(self) -> None: 37 | policy = EmployeeAllocationPolicy.permissions_in_multiple_projects(3) 38 | employee = EmployeeSummary( 39 | EmployeeId.new_one(), 40 | "resourceName", 41 | "lastName", 42 | Seniority.LEAD, 43 | Capability.skills("JAVA"), 44 | Capability.permissions("ADMIN"), 45 | ) 46 | 47 | capabilities = policy.simultaneous_capabilities_of(employee) 48 | 49 | assert len(capabilities) == 3 50 | all_capabilities = list( 51 | itertools.chain(*[c.capabilities for c in capabilities]) 52 | ) 53 | assert all_capabilities == [Capability.permission("ADMIN")] * 3 54 | 55 | def test_can_create_composite_policy(self) -> None: 56 | policy = EmployeeAllocationPolicy.simultaneous( 57 | EmployeeAllocationPolicy.permissions_in_multiple_projects(3), 58 | EmployeeAllocationPolicy.one_of_skills(), 59 | ) 60 | summary = EmployeeSummary( 61 | EmployeeId.new_one(), 62 | "resourceName", 63 | "lastName", 64 | Seniority.LEAD, 65 | Capability.skills("JAVA", "PYTHON"), 66 | Capability.permissions("ADMIN"), 67 | ) 68 | 69 | capabilities = policy.simultaneous_capabilities_of(summary) 70 | 71 | assert len(capabilities) == 4 72 | assert ( 73 | CapabilitySelector.can_perform_one_of(Capability.skills("JAVA", "PYTHON")) 74 | in capabilities 75 | ) 76 | assert ( 77 | capabilities.count( 78 | CapabilitySelector.can_just_perform(Capability.permission("ADMIN")) 79 | ) 80 | == 3 81 | ) 82 | -------------------------------------------------------------------------------- /tests/smartschedule/resource/employee/test_creating_employee.py: -------------------------------------------------------------------------------- 1 | from smartschedule.resource.employee.employee_facade import EmployeeFacade 2 | from smartschedule.resource.employee.seniority import Seniority 3 | from smartschedule.shared.capability.capability import Capability 4 | 5 | 6 | class TestCreatingEmployee: 7 | def test_creates_and_loads_employee(self, employee_facade: EmployeeFacade) -> None: 8 | employee_id = employee_facade.add_employee( 9 | "John", 10 | "Doe", 11 | Seniority.SENIOR, 12 | Capability.skills("JAVA", "PYTHON"), 13 | Capability.permissions("ADMIN", "COURT"), 14 | ) 15 | 16 | loaded = employee_facade.find_employee(employee_id) 17 | 18 | assert loaded.skills == Capability.skills("JAVA", "PYTHON") 19 | assert loaded.permissions == Capability.permissions("ADMIN", "COURT") 20 | assert loaded.name == "John" 21 | assert loaded.last_name == "Doe" 22 | assert loaded.seniority == Seniority.SENIOR 23 | 24 | def test_finds_all_capabilities(self, employee_facade: EmployeeFacade) -> None: 25 | employee_facade.add_employee( 26 | "staszek", 27 | "lastName", 28 | Seniority.SENIOR, 29 | Capability.skills("JAVA12", "PYTHON21"), 30 | Capability.permissions("ADMIN1", "COURT1"), 31 | ) 32 | employee_facade.add_employee( 33 | "leon", 34 | "lastName", 35 | Seniority.SENIOR, 36 | Capability.skills("JAVA12", "PYTHON21"), 37 | Capability.permissions("ADMIN2", "COURT2"), 38 | ) 39 | employee_facade.add_employee( 40 | "sławek", 41 | "lastName", 42 | Seniority.SENIOR, 43 | Capability.skills("JAVA12", "PYTHON21"), 44 | Capability.permissions("ADMIN3", "COURT3"), 45 | ) 46 | 47 | loaded = employee_facade.find_all_capabilities() 48 | 49 | assert set(loaded) == { 50 | Capability("JAVA12", "SKILL"), 51 | Capability("PYTHON21", "SKILL"), 52 | Capability("ADMIN1", "PERMISSION"), 53 | Capability("COURT1", "PERMISSION"), 54 | Capability("ADMIN2", "PERMISSION"), 55 | Capability("COURT2", "PERMISSION"), 56 | Capability("ADMIN3", "PERMISSION"), 57 | Capability("COURT3", "PERMISSION"), 58 | } 59 | -------------------------------------------------------------------------------- /tests/smartschedule/resource/employee/test_schedule_employee_capabilities.py: -------------------------------------------------------------------------------- 1 | from smartschedule.allocation.capabilityscheduling.capability_finder import ( 2 | CapabilityFinder, 3 | ) 4 | from smartschedule.resource.employee.employee_facade import EmployeeFacade 5 | from smartschedule.resource.employee.seniority import Seniority 6 | from smartschedule.shared.capability.capability import Capability 7 | from smartschedule.shared.timeslot.time_slot import TimeSlot 8 | 9 | 10 | class TestScheduleEmployeeCapabilities: 11 | def test_can_setup_capabilities_according_to_policy( 12 | self, employee_facade: EmployeeFacade, capability_finder: CapabilityFinder 13 | ) -> None: 14 | employee_id = employee_facade.add_employee( 15 | "resourceName", 16 | "lastName", 17 | Seniority.LEAD, 18 | Capability.skills("JAVA, PYTHON"), 19 | Capability.permissions("ADMIN"), 20 | ) 21 | 22 | one_day = TimeSlot.create_daily_time_slot_at_utc(2021, 1, 1) 23 | allocations = employee_facade.schedule_capabilities(employee_id, one_day) 24 | 25 | loaded = capability_finder.find_by_id(*allocations) 26 | assert len(loaded.all) == len(allocations) 27 | -------------------------------------------------------------------------------- /tests/smartschedule/risk/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/risk/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/shared/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/shared/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/shared/test_capability_selector.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from smartschedule.shared.capability.capability import Capability 4 | from smartschedule.shared.capability_selector import ( 5 | CapabilitySelector, 6 | ) 7 | 8 | 9 | class TestCapabilitySelector: 10 | RUST: Final = Capability.skill("RUST") 11 | BEING_AN_ADMIN: Final = Capability.permission("ADMIN") 12 | JAVA: Final = Capability.skill("JAVA") 13 | 14 | def test_allocatable_resources_can_perform_only_one_of_present_capabilities( 15 | self, 16 | ) -> None: 17 | admin_or_rust = CapabilitySelector.can_perform_one_of( 18 | {self.RUST, self.BEING_AN_ADMIN} 19 | ) 20 | 21 | assert admin_or_rust.can_perform(self.BEING_AN_ADMIN) 22 | assert admin_or_rust.can_perform(self.RUST) 23 | assert not admin_or_rust.can_perform(self.BEING_AN_ADMIN, self.RUST) 24 | assert not admin_or_rust.can_perform(Capability.skill("JAVA")) 25 | assert not admin_or_rust.can_perform(Capability.permission("LAWYER")) 26 | 27 | def test_allocatable_resource_can_perform_simultanous_capabilities(self) -> None: 28 | admin_and_rust = CapabilitySelector.can_perform_all_at_the_time( 29 | {self.BEING_AN_ADMIN, self.RUST} 30 | ) 31 | 32 | assert admin_and_rust.can_perform(self.BEING_AN_ADMIN) 33 | assert admin_and_rust.can_perform(self.RUST) 34 | assert admin_and_rust.can_perform(self.BEING_AN_ADMIN, self.RUST) 35 | assert not admin_and_rust.can_perform(self.RUST, self.BEING_AN_ADMIN, self.JAVA) 36 | assert not admin_and_rust.can_perform(self.JAVA) 37 | assert not admin_and_rust.can_perform(Capability.permission("LAWYER")) 38 | -------------------------------------------------------------------------------- /tests/smartschedule/shared/test_event_bus.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from unittest.mock import Mock 4 | 5 | import pytest 6 | from lagom import Container 7 | 8 | from smartschedule.shared.event_bus import EventBus 9 | from smartschedule.shared.published_event import PublishedEvent 10 | from tests.timeout import timeout 11 | 12 | 13 | @dataclass(frozen=True) 14 | class DummyEvent(PublishedEvent): 15 | occurred_at: datetime 16 | 17 | 18 | @pytest.fixture() 19 | def event_bus(container: Container) -> EventBus: 20 | return container.resolve(EventBus) 21 | 22 | 23 | class TestEventBus: 24 | def test_calls_listener(self, event_bus: EventBus) -> None: 25 | spy = Mock() 26 | 27 | @EventBus.has_event_handlers 28 | class DummyListener: 29 | @EventBus.async_event_handler 30 | def handle(self, event: DummyEvent) -> None: 31 | spy(event) 32 | 33 | event = DummyEvent(occurred_at=datetime.now()) 34 | event_bus.publish(event) 35 | 36 | def assertions() -> None: 37 | assert spy.called 38 | spy.assert_called_once_with(event) 39 | 40 | timeout(milliseconds=1000, callable=assertions) 41 | -------------------------------------------------------------------------------- /tests/smartschedule/shared/timeslot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/shared/timeslot/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/simulation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/simulation/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/simulation/available_capabilities_factory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import factory # type: ignore 4 | 5 | from smartschedule.shared.capability.capability import Capability 6 | from smartschedule.shared.capability_selector import CapabilitySelector, SelectingPolicy 7 | from smartschedule.simulation.available_resource_capability import ( 8 | AvailableResourceCapability, 9 | ) 10 | from smartschedule.simulation.simulated_capabilities import SimulatedCapabilities 11 | 12 | 13 | class SimulatedCapabilitiesFactory(factory.Factory): # type: ignore 14 | class Meta: 15 | model = SimulatedCapabilities 16 | 17 | class Params: 18 | num_capabilities = 0 19 | 20 | @factory.lazy_attribute # type: ignore 21 | def capabilities(self) -> list[AvailableResourceCapability]: 22 | result = [] 23 | argument_src = self._Resolver__declarations["capabilities"].context 24 | for index in range(self.num_capabilities): 25 | brings = argument_src[f"{index}__brings"] 26 | if isinstance(brings, Capability): 27 | capabilities = frozenset([brings]) 28 | selecting_policy = SelectingPolicy.ONE_OF_ALL 29 | elif isinstance(brings, set): 30 | if not all(isinstance(x, Capability) for x in brings): 31 | raise ValueError( 32 | "All elements of the set must be of type Capability" 33 | ) 34 | capabilities = frozenset(brings) 35 | selecting_policy = SelectingPolicy.ALL_SIMULTANEOUSLY 36 | 37 | capability = AvailableResourceCapability( 38 | resource_id=argument_src[f"{index}__resource_id"], 39 | capability_selector=CapabilitySelector( 40 | capabilities=capabilities, selecting_policy=selecting_policy 41 | ), 42 | time_slot=argument_src[f"{index}__time_slot"], 43 | ) 44 | result.append(capability) 45 | return result 46 | -------------------------------------------------------------------------------- /tests/smartschedule/simulation/simulated_projects_factory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import factory # type: ignore 4 | 5 | from smartschedule.simulation.demands import Demands 6 | from smartschedule.simulation.project_id import ProjectId 7 | from smartschedule.simulation.simulated_project import SimulatedProject 8 | 9 | 10 | class SimulatedProjectFactory(factory.Factory): # type: ignore 11 | class Meta: 12 | model = SimulatedProject 13 | 14 | class Params: 15 | value = 0 16 | 17 | project_id = factory.LazyAttribute(lambda _: ProjectId.new_one()) 18 | value_getter = factory.LazyAttribute(lambda o: lambda: o.value) 19 | missing_demands = factory.LazyAttribute(lambda _: Demands.of([])) 20 | -------------------------------------------------------------------------------- /tests/smartschedule/sorter/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/sorter/__init__.py -------------------------------------------------------------------------------- /tests/smartschedule/sorter/test_feedback_arc_set_on_graph.py: -------------------------------------------------------------------------------- 1 | from smartschedule.sorter.edge import Edge 2 | from smartschedule.sorter.feedback_arc_se_on_graph import FeedbackArcSeOnGraph 3 | from smartschedule.sorter.node import Node 4 | 5 | 6 | class TestFeedbackArcSeOnGraph: 7 | def test_can_find_minimum_number_of_edges_to_remove_to_make_the_graph_acyclic( 8 | self, 9 | ) -> None: 10 | node1 = Node("1", "node1") 11 | node2 = Node("2", "node2") 12 | node3 = Node("3", "node3") 13 | node4 = Node("4", "node4") 14 | node1 = node1.depends_on(node2) 15 | node2 = node2.depends_on(node3) 16 | node4 = node4.depends_on(node3) 17 | node1 = node1.depends_on(node4) 18 | node3 = node3.depends_on(node1) 19 | 20 | to_remove = FeedbackArcSeOnGraph[str]().calculate([node1, node2, node3, node4]) 21 | 22 | assert to_remove == [Edge(3, 1), Edge(4, 3)] 23 | 24 | def test_when_graph_is_acyclic_there_is_nothing_to_remove(self) -> None: 25 | node1 = Node("1", "node1") 26 | node2 = Node("2", "node2") 27 | node3 = Node("3", "node3") 28 | node4 = Node("4", "node4") 29 | node1 = node1.depends_on(node2) 30 | node2 = node2.depends_on(node3) 31 | node1 = node1.depends_on(node4) 32 | 33 | to_remove = FeedbackArcSeOnGraph[str]().calculate([node1, node2, node3, node4]) 34 | 35 | assert to_remove == [] 36 | -------------------------------------------------------------------------------- /tests/smartschedule/task_executor_configuration.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DomainDrivers/dd-python/a21f3809ea7677ef907c3e0a7e27a2615bc884b6/tests/smartschedule/task_executor_configuration.py -------------------------------------------------------------------------------- /tests/timeout.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Callable 3 | 4 | 5 | def timeout(milliseconds: int, callable: Callable[[], None]) -> None: 6 | start = time.monotonic() 7 | end = start + milliseconds / 1000 8 | while time.monotonic() <= end: 9 | try: 10 | callable() 11 | except AssertionError: 12 | time.sleep(0.05) 13 | else: 14 | return 15 | 16 | raise TimeoutError("Timeout reached") 17 | 18 | 19 | def timeout_never(milliseconds: int, callable: Callable[[], None]) -> None: 20 | __tracebackhide__ = True 21 | 22 | start = time.monotonic() 23 | end = start + milliseconds / 1000 24 | while time.monotonic() <= end: 25 | try: 26 | callable() 27 | except AssertionError: 28 | time.sleep(0.05) 29 | else: 30 | raise AssertionError("Condition should never be met") 31 | --------------------------------------------------------------------------------