- 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>
159 lines
6.2 KiB
Python
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
|