Files
smoothschedule/smoothschedule/schedule/services.py
poduck 8d0cc1e90a feat(time-blocks): Add comprehensive time blocking system with contracts
- Add TimeBlock and Holiday models with recurrence support (one-time, weekly, monthly, yearly, holiday)
- Implement business-level and resource-level blocking with hard/soft block types
- Add multi-select holiday picker for bulk holiday blocking
- Add calendar overlay visualization with distinct colors:
  - Business blocks: Red (hard) / Yellow (soft)
  - Resource blocks: Purple (hard) / Cyan (soft)
- Add month view resource indicators showing 1/n width per resource
- Add yearly calendar view for block overview
- Add My Availability page for staff self-service
- Add contracts module with templates, signing flow, and PDF generation
- Update scheduler with click-to-day navigation in week view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:19:12 -05:00

159 lines
6.2 KiB
Python

"""
Availability Service - Resource capacity checking with concurrency management and time blocks
"""
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from .models import Event, Participant, Resource, TimeBlock
class AvailabilityService:
"""
Service for checking resource availability with concurrency limits and time blocks.
CRITICAL Features:
- Handles max_concurrent_events==0 as unlimited capacity
- Filters out CANCELED events to prevent ghost bookings
- Uses correct overlap logic: start < query_end AND end > query_start
- Checks business-level and resource-level time blocks
- Returns soft block warnings separately from hard blocks
"""
@staticmethod
def check_availability(resource, start_time, end_time, exclude_event_id=None):
"""
Check if resource has capacity for a new/updated event.
Args:
resource (Resource): The resource to check
start_time (datetime): Proposed event start
end_time (datetime): Proposed event end
exclude_event_id (int, optional): Event ID to exclude (when updating)
Returns:
tuple: (is_available: bool, reason: str, soft_block_warnings: list)
- is_available: False if hard-blocked or capacity exceeded
- reason: Human-readable explanation
- soft_block_warnings: List of soft block warnings (can be overridden)
"""
soft_block_warnings = []
# Step 1: Check time blocks (business-level first, then resource-level)
block_result = AvailabilityService._check_time_blocks(
resource, start_time, end_time
)
if block_result['hard_blocked']:
return False, block_result['reason'], []
soft_block_warnings.extend(block_result['soft_warnings'])
# Step 2: Calculate search window with buffer
query_start = start_time - resource.buffer_duration
query_end = end_time + resource.buffer_duration
# Step 3: Find all events for this resource
resource_content_type = ContentType.objects.get_for_model(Resource)
resource_participants = Participant.objects.filter(
content_type=resource_content_type,
object_id=resource.id,
role=Participant.Role.RESOURCE
).select_related('event')
# Step 4: Filter for overlapping events
overlapping_events = []
for participant in resource_participants:
event = participant.event
# Skip if this is the event being updated
# CRITICAL: Convert exclude_event_id to int for comparison (frontend may send string)
if exclude_event_id and event.id == int(exclude_event_id):
continue
# CRITICAL: Skip cancelled events (prevents ghost bookings)
if event.status == Event.Status.CANCELED:
continue
# CRITICAL: Check overlap using correct logic
# Overlap exists when: event.start < query_end AND event.end > query_start
if event.start_time < query_end and event.end_time > query_start:
overlapping_events.append(event)
current_count = len(overlapping_events)
# Step 5: Check capacity limit
# CRITICAL: Handle infinite capacity (0 = unlimited)
if resource.max_concurrent_events == 0:
return True, "Unlimited capacity resource", soft_block_warnings
# Check if we've hit the limit
if current_count >= resource.max_concurrent_events:
return False, (
f"Resource capacity exceeded. "
f"{current_count}/{resource.max_concurrent_events} slots occupied."
), []
# Available!
return True, f"Available ({current_count + 1}/{resource.max_concurrent_events} slots)", soft_block_warnings
@staticmethod
def _check_time_blocks(resource, start_time, end_time):
"""
Check if a time period is blocked by any time blocks.
Checks both business-level blocks (resource=null) and resource-level blocks.
Business-level blocks are checked first as they apply to all resources.
Args:
resource (Resource): The resource to check
start_time (datetime): Proposed event start
end_time (datetime): Proposed event end
Returns:
dict: {
'hard_blocked': bool,
'reason': str,
'soft_warnings': list of warning strings
}
"""
result = {
'hard_blocked': False,
'reason': '',
'soft_warnings': []
}
# Get active time blocks (business-level + resource-level)
blocks = TimeBlock.objects.filter(
Q(resource__isnull=True) | Q(resource=resource),
is_active=True
).order_by('resource') # Business blocks first (null sorts first)
for block in blocks:
# Check if this block applies to the requested datetime range
if block.blocks_datetime_range(start_time, end_time):
if block.block_type == TimeBlock.BlockType.HARD:
# Hard block - immediately return unavailable
level = "Business closed" if block.resource is None else f"{resource.name} unavailable"
result['hard_blocked'] = True
result['reason'] = f"{level}: {block.title}"
return result
else:
# Soft block - add warning but continue
level = "Business advisory" if block.resource is None else f"{resource.name} advisory"
result['soft_warnings'].append(f"{level}: {block.title}")
return result
@staticmethod
def check_availability_simple(resource, start_time, end_time, exclude_event_id=None):
"""
Simple availability check that returns just (bool, str) for backwards compatibility.
Use check_availability() for full soft block warning support.
"""
is_available, reason, _ = AvailabilityService.check_availability(
resource, start_time, end_time, exclude_event_id
)
return is_available, reason