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:
@@ -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" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user