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