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
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');
setIsOpen(false);
return;
@@ -79,8 +80,8 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
};
const getNotificationIcon = (notification: Notification) => {
// Check for time-off request type in data
if (notification.data?.type === 'time_off_request') {
// Check for time-off request type in data (new or modified)
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
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
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():
"""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')
def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
"""
When a TimeBlock is created with PENDING approval status,
notify all managers and owners in the business.
"""
if not created:
return
When a TimeBlock is created with PENDING approval status or when
an approved TimeBlock is modified, notify all managers and owners.
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
# Only notify for pending requests (staff time-off that needs approval)
if instance.approval_status != TimeBlock.ApprovalStatus.PENDING:
# Check if this is a new pending request OR a modified approved request
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
# 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:
return
logger.info(
f"Time-off request submitted by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}'"
)
# Determine notification type for logging and data
if needs_re_approval:
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
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(
recipient=reviewer,
actor=requester,
verb='requested time off',
verb=verb,
action_object=instance,
target=instance.resource,
data={
@@ -615,7 +696,9 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
'title': instance.title,
'resource_name': instance.resource.name if instance.resource else None,
'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:
@@ -627,17 +710,23 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
time_block=instance,
requester=requester,
reviewers=list(reviewers),
is_modification=needs_re_approval,
changed_fields=getattr(instance, '_changed_fields', []) if needs_re_approval else [],
)
@receiver(time_off_request_submitted)
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.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
resource_name = time_block.resource.name if time_block.resource else 'Unknown'
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:
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.
Requester: {requester_name}

View File

@@ -263,13 +263,17 @@ class User(AbstractUser):
def can_self_approve_time_off(self):
"""
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.
"""
# Owners and managers can always self-approve
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
# Owners can always self-approve
if self.role == self.Role.TENANT_OWNER:
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:
return self.permissions.get('can_self_approve_time_off', False)
return False