diff --git a/frontend/src/components/NotificationDropdown.tsx b/frontend/src/components/NotificationDropdown.tsx index 6e9befa..6963f94 100644 --- a/frontend/src/components/NotificationDropdown.tsx +++ b/frontend/src/components/NotificationDropdown.tsx @@ -57,7 +57,8 @@ const NotificationDropdown: React.FC = ({ 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 = ({ 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 ; } diff --git a/smoothschedule/schedule/signals.py b/smoothschedule/schedule/signals.py index 6cd4fa6..f7e79ed 100644 --- a/smoothschedule/schedule/signals.py +++ b/smoothschedule/schedule/signals.py @@ -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} diff --git a/smoothschedule/smoothschedule/users/models.py b/smoothschedule/smoothschedule/users/models.py index 6d9d5f2..7486e57 100644 --- a/smoothschedule/smoothschedule/users/models.py +++ b/smoothschedule/smoothschedule/users/models.py @@ -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