feat(time-off): Reset approval when staff edits approved request

- Add pre_save signal to track changes to approved time blocks
- Reset to PENDING status when staff modifies approved time-off
- Send re-approval notifications to managers with changed fields
- Update email templates for modified requests
- Allow managers to have self-approval permission revoked (default: allowed)

A changed request is treated as a new request requiring re-approval.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-07 20:35:47 -05:00
parent f4332153f4
commit 8440ac945a
3 changed files with 135 additions and 23 deletions

View File

@@ -57,7 +57,8 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
} }
// Handle time-off request notifications - navigate to time blocks page // Handle time-off request notifications - navigate to time blocks page
if (notification.data?.type === 'time_off_request') { // Includes both new requests and modified requests that need re-approval
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
navigate('/time-blocks'); navigate('/time-blocks');
setIsOpen(false); setIsOpen(false);
return; return;
@@ -79,8 +80,8 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
}; };
const getNotificationIcon = (notification: Notification) => { const getNotificationIcon = (notification: Notification) => {
// Check for time-off request type in data // Check for time-off request type in data (new or modified)
if (notification.data?.type === 'time_off_request') { if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
return <Clock size={16} className="text-amber-500" />; return <Clock size={16} className="text-amber-500" />;
} }

View File

@@ -533,6 +533,70 @@ def emit_status_change(event, old_status, new_status, changed_by, tenant, skip_n
# Custom signal for time-off request notifications # Custom signal for time-off request notifications
time_off_request_submitted = Signal() time_off_request_submitted = Signal()
# Fields that trigger re-approval when changed on an approved block
TIME_BLOCK_APPROVAL_FIELDS = [
'title', 'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
'recurrence_type', 'recurrence_pattern', 'recurrence_start', 'recurrence_end',
]
@receiver(pre_save, sender='schedule.TimeBlock')
def track_time_block_changes(sender, instance, **kwargs):
"""
Track changes to TimeBlock before save and reset approval if needed.
When a staff member edits an already-approved time block:
1. Tracks which fields changed
2. If approval-sensitive fields changed, resets status to PENDING
3. Marks the instance so post_save knows to send notifications
This ensures that any edit to an approved time-off request triggers
a new approval workflow.
"""
instance._needs_re_approval_notification = False
if instance.pk:
try:
from .models import TimeBlock
old_instance = TimeBlock.objects.get(pk=instance.pk)
instance._old_approval_status = old_instance.approval_status
instance._was_approved = old_instance.approval_status == TimeBlock.ApprovalStatus.APPROVED
# Track which approval-sensitive fields changed
instance._changed_fields = []
for field in TIME_BLOCK_APPROVAL_FIELDS:
old_value = getattr(old_instance, field)
new_value = getattr(instance, field)
if old_value != new_value:
instance._changed_fields.append(field)
# If this was an approved block and significant fields changed,
# reset to PENDING status (treated as a new request)
if instance._was_approved and instance._changed_fields:
# Only reset if the user editing is not self-approving
# (owners/managers edits stay approved)
created_by = instance.created_by
if created_by and not created_by.can_self_approve_time_off():
logger.info(
f"TimeBlock {instance.id} was approved but modified "
f"(changed: {instance._changed_fields}). "
f"Resetting to PENDING for re-approval."
)
instance.approval_status = TimeBlock.ApprovalStatus.PENDING
instance.reviewed_by = None
instance.reviewed_at = None
instance.review_notes = ''
instance._needs_re_approval_notification = True
except sender.DoesNotExist:
instance._old_approval_status = None
instance._was_approved = False
instance._changed_fields = []
else:
instance._old_approval_status = None
instance._was_approved = False
instance._changed_fields = []
def is_notifications_available(): def is_notifications_available():
"""Check if the notifications app is installed and migrated.""" """Check if the notifications app is installed and migrated."""
@@ -568,16 +632,20 @@ def create_notification_safe(recipient, actor, verb, action_object=None, target=
@receiver(post_save, sender='schedule.TimeBlock') @receiver(post_save, sender='schedule.TimeBlock')
def notify_managers_on_pending_time_off(sender, instance, created, **kwargs): def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
""" """
When a TimeBlock is created with PENDING approval status, When a TimeBlock is created with PENDING approval status or when
notify all managers and owners in the business. an approved TimeBlock is modified, notify all managers and owners.
"""
if not created:
return
Handles two scenarios:
1. New time-off request created with PENDING status
2. Approved time-off request modified (reset to PENDING)
"""
from .models import TimeBlock from .models import TimeBlock
# Only notify for pending requests (staff time-off that needs approval) # Check if this is a new pending request OR a modified approved request
if instance.approval_status != TimeBlock.ApprovalStatus.PENDING: is_new_pending = created and instance.approval_status == TimeBlock.ApprovalStatus.PENDING
needs_re_approval = getattr(instance, '_needs_re_approval_notification', False)
if not is_new_pending and not needs_re_approval:
return return
# Only for resource-level blocks (staff time-off) # Only for resource-level blocks (staff time-off)
@@ -589,10 +657,23 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
if not requester: if not requester:
return return
logger.info( # Determine notification type for logging and data
f"Time-off request submitted by {requester.get_full_name() or requester.email} " if needs_re_approval:
f"for resource '{instance.resource.name}'" changed_fields = getattr(instance, '_changed_fields', [])
) verb = 'modified time off request'
notification_type = 'time_off_request_modified'
logger.info(
f"Time-off request modified by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}' (changed: {changed_fields}). "
f"Request returned to pending status for re-approval."
)
else:
verb = 'requested time off'
notification_type = 'time_off_request'
logger.info(
f"Time-off request submitted by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}'"
)
# Find all managers and owners to notify # Find all managers and owners to notify
from smoothschedule.users.models import User from smoothschedule.users.models import User
@@ -607,7 +688,7 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
notification = create_notification_safe( notification = create_notification_safe(
recipient=reviewer, recipient=reviewer,
actor=requester, actor=requester,
verb='requested time off', verb=verb,
action_object=instance, action_object=instance,
target=instance.resource, target=instance.resource,
data={ data={
@@ -615,7 +696,9 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
'title': instance.title, 'title': instance.title,
'resource_name': instance.resource.name if instance.resource else None, 'resource_name': instance.resource.name if instance.resource else None,
'requester_name': requester.get_full_name() or requester.email, 'requester_name': requester.get_full_name() or requester.email,
'type': 'time_off_request', 'type': notification_type,
'is_modification': needs_re_approval,
'changed_fields': getattr(instance, '_changed_fields', []) if needs_re_approval else [],
} }
) )
if notification: if notification:
@@ -627,17 +710,23 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
time_block=instance, time_block=instance,
requester=requester, requester=requester,
reviewers=list(reviewers), reviewers=list(reviewers),
is_modification=needs_re_approval,
changed_fields=getattr(instance, '_changed_fields', []) if needs_re_approval else [],
) )
@receiver(time_off_request_submitted) @receiver(time_off_request_submitted)
def send_time_off_email_notifications(sender, time_block, requester, reviewers, **kwargs): def send_time_off_email_notifications(sender, time_block, requester, reviewers, **kwargs):
""" """
Send email notifications to reviewers when a time-off request is submitted. Send email notifications to reviewers when a time-off request is submitted or modified.
""" """
from django.core.mail import send_mail from django.core.mail import send_mail
from django.conf import settings from django.conf import settings
# Check if this is a modification of an existing approved request
is_modification = kwargs.get('is_modification', False)
changed_fields = kwargs.get('changed_fields', [])
# Get the resource name for the email # Get the resource name for the email
resource_name = time_block.resource.name if time_block.resource else 'Unknown' resource_name = time_block.resource.name if time_block.resource else 'Unknown'
requester_name = requester.get_full_name() or requester.email requester_name = requester.get_full_name() or requester.email
@@ -653,9 +742,27 @@ def send_time_off_email_notifications(sender, time_block, requester, reviewers,
else: else:
date_desc = f"Recurring ({time_block.get_recurrence_type_display()})" date_desc = f"Recurring ({time_block.get_recurrence_type_display()})"
subject = f"Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}" # Different subject and message for new vs modified requests
if is_modification:
subject = f"Modified Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
changed_fields_str = ', '.join(changed_fields) if changed_fields else 'unspecified fields'
message = f"""
A previously approved time-off request has been modified and requires re-approval.
message = f""" Requester: {requester_name}
Resource: {resource_name}
Title: {time_block.title or 'Time Off'}
Date(s): {date_desc}
Description: {time_block.description or 'No description provided'}
Modified fields: {changed_fields_str}
The request has been returned to pending status and needs your review.
Please log in to review this request.
"""
else:
subject = f"Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
message = f"""
A new time-off request has been submitted and needs your review. A new time-off request has been submitted and needs your review.
Requester: {requester_name} Requester: {requester_name}

View File

@@ -263,13 +263,17 @@ class User(AbstractUser):
def can_self_approve_time_off(self): def can_self_approve_time_off(self):
""" """
Check if user can self-approve time off requests. Check if user can self-approve time off requests.
Owners and managers can always self-approve. Owners can always self-approve.
Managers can self-approve by default but can be denied.
Staff need explicit permission. Staff need explicit permission.
""" """
# Owners and managers can always self-approve # Owners can always self-approve
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: if self.role == self.Role.TENANT_OWNER:
return True return True
# Staff can self-approve if granted permission # Managers can self-approve by default, but can be denied
if self.role == self.Role.TENANT_MANAGER:
return self.permissions.get('can_self_approve_time_off', True)
# Staff can self-approve if granted permission (default: False)
if self.role == self.Role.TENANT_STAFF: if self.role == self.Role.TENANT_STAFF:
return self.permissions.get('can_self_approve_time_off', False) return self.permissions.get('can_self_approve_time_off', False)
return False return False