Files
TimeTracker/app/routes/calendar.py
Dries Peeters 7c518171f1 fix: use project's custom confirmation dialog for event deletion
Replaced browser's native confirm() dialog with the project's custom
confirm_dialog macro for consistency with the rest of the application.

Changes:
- Imported confirm_dialog macro from components/ui.html in event_detail.html
- Updated delete button to trigger custom modal instead of native confirm
- Modified delete_event route to accept both POST and DELETE methods
- Added flash messages and redirect logic for POST-based deletion
- Replaced JavaScript fetch-based deletion with form submission pattern
- Used hidden form with CSRF token for secure deletion (consistent with
  tasks and time entries deletion pattern)

The custom dialog provides:
- Consistent UI/UX matching the project's dark theme
- Better accessibility
- Proper styling with danger-colored confirmation button
- Standard project pattern for destructive actions

Fixes: Event deletion showing browser's native unstyled confirmation dialog
Related: Calendar feature implementation
2025-10-27 12:51:05 +01:00

429 lines
16 KiB
Python

from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import CalendarEvent, Task, Project, Client, TimeEntry
from datetime import datetime, timedelta
from app.utils.db import safe_commit
from app.utils.timezone import now_in_app_timezone
from app.utils.permissions import check_permission
calendar_bp = Blueprint('calendar', __name__)
@calendar_bp.route('/calendar')
@login_required
def view_calendar():
"""Display the calendar view with events, tasks, and time entries"""
view_type = request.args.get('view', 'month') # day, week, month
date_str = request.args.get('date', '')
# Parse the date or use today
if date_str:
try:
current_date = datetime.strptime(date_str, '%Y-%m-%d')
except ValueError:
current_date = now_in_app_timezone()
else:
current_date = now_in_app_timezone()
# Get projects and clients for event creation
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
return render_template(
'calendar/view.html',
view_type=view_type,
current_date=current_date,
projects=projects,
clients=clients
)
@calendar_bp.route('/api/calendar/events')
@login_required
def get_events():
"""API endpoint to fetch calendar events for a date range"""
start_str = request.args.get('start')
end_str = request.args.get('end')
include_tasks = request.args.get('include_tasks', 'true').lower() == 'true'
include_time_entries = request.args.get('include_time_entries', 'true').lower() == 'true'
print(f"\n{'='*80}")
print(f"API ENDPOINT CALLED - /api/calendar/events")
print(f" include_tasks query param: {request.args.get('include_tasks')}")
print(f" include_time_entries query param: {request.args.get('include_time_entries')}")
print(f" include_tasks parsed: {include_tasks}")
print(f" include_time_entries parsed: {include_time_entries}")
print(f"{'='*80}\n")
if not start_str or not end_str:
return jsonify({'error': 'Start and end dates are required'}), 400
try:
start_date = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
end_date = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
except (ValueError, AttributeError):
return jsonify({'error': 'Invalid date format'}), 400
print(f"\n{'='*80}")
print(f"ROUTE HANDLER - get_events API:")
print(f" user_id={current_user.id}")
print(f" start_date={start_date}")
print(f" end_date={end_date}")
print(f" include_tasks={include_tasks} (type: {type(include_tasks)})")
print(f" include_time_entries={include_time_entries} (type: {type(include_time_entries)})")
print(f"{'='*80}\n")
# Get events using the model's static method
result = CalendarEvent.get_events_in_range(
user_id=current_user.id,
start_date=start_date,
end_date=end_date,
include_tasks=include_tasks,
include_time_entries=include_time_entries
)
print(f"\n{'='*80}")
print(f"ROUTE HANDLER - Result from get_events_in_range:")
print(f" events count: {len(result.get('events', []))}")
print(f" tasks count: {len(result.get('tasks', []))}")
print(f" time_entries count: {len(result.get('time_entries', []))}")
print(f"{'='*80}\n")
# Add debug marker to verify this code is running
result['_debug_timestamp'] = datetime.now().isoformat()
result['_debug_version'] = 'v3_no_cache'
response = jsonify(result)
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
@calendar_bp.route('/api/calendar/events', methods=['POST'])
@login_required
def create_event():
"""Create a new calendar event"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
# Validate required fields
required_fields = ['title', 'start', 'end']
for field in required_fields:
if field not in data:
return jsonify({'error': f'Missing required field: {field}'}), 400
try:
# Parse dates
start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
# Create event
event = CalendarEvent(
user_id=current_user.id,
title=data['title'],
start_time=start_time,
end_time=end_time,
description=data.get('description'),
all_day=data.get('allDay', False),
location=data.get('location'),
event_type=data.get('eventType', 'event'),
project_id=data.get('projectId'),
task_id=data.get('taskId'),
client_id=data.get('clientId'),
is_recurring=data.get('isRecurring', False),
recurrence_rule=data.get('recurrenceRule'),
recurrence_end_date=datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data.get('recurrenceEndDate') else None,
reminder_minutes=data.get('reminderMinutes'),
color=data.get('color'),
is_private=data.get('isPrivate', False)
)
db.session.add(event)
if not safe_commit():
return jsonify({'error': 'Failed to create event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event created successfully')
}), 201
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error creating event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['GET'])
@login_required
def get_event(event_id):
"""Get a specific calendar event"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to view this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
return jsonify(event.to_dict())
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['PUT'])
@login_required
def update_event(event_id):
"""Update a calendar event"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
# Update fields
if 'title' in data:
event.title = data['title']
if 'description' in data:
event.description = data['description']
if 'start' in data:
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
if 'end' in data:
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
if 'allDay' in data:
event.all_day = data['allDay']
if 'location' in data:
event.location = data['location']
if 'eventType' in data:
event.event_type = data['eventType']
if 'projectId' in data:
event.project_id = data['projectId']
if 'taskId' in data:
event.task_id = data['taskId']
if 'clientId' in data:
event.client_id = data['clientId']
if 'isRecurring' in data:
event.is_recurring = data['isRecurring']
if 'recurrenceRule' in data:
event.recurrence_rule = data['recurrenceRule']
if 'recurrenceEndDate' in data:
event.recurrence_end_date = datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data['recurrenceEndDate'] else None
if 'reminderMinutes' in data:
event.reminder_minutes = data['reminderMinutes']
if 'color' in data:
event.color = data['color']
if 'isPrivate' in data:
event.is_private = data['isPrivate']
event.updated_at = now_in_app_timezone()
if not safe_commit():
return jsonify({'error': 'Failed to update event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event updated successfully')
})
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error updating event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['DELETE', 'POST'])
@login_required
def delete_event(event_id):
"""Delete a calendar event"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to delete this event
if event.user_id != current_user.id and not current_user.is_admin:
if request.method == 'POST':
flash(_('You do not have permission to delete this event.'), 'error')
return redirect(url_for('calendar.view_calendar'))
return jsonify({'error': 'Permission denied'}), 403
try:
db.session.delete(event)
if not safe_commit():
if request.method == 'POST':
flash(_('Failed to delete event'), 'error')
return redirect(url_for('calendar.view_calendar'))
return jsonify({'error': 'Failed to delete event'}), 500
if request.method == 'POST':
flash(_('Event deleted successfully'), 'success')
return redirect(url_for('calendar.view_calendar'))
return jsonify({
'success': True,
'message': _('Event deleted successfully')
})
except Exception as e:
db.session.rollback()
if request.method == 'POST':
flash(_('Error deleting event: %(error)s', error=str(e)), 'error')
return redirect(url_for('calendar.view_calendar'))
return jsonify({'error': f'Error deleting event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>/move', methods=['POST'])
@login_required
def move_event(event_id):
"""Move an event to a new time (drag and drop support)"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json()
if not data or 'start' not in data or 'end' not in data:
return jsonify({'error': 'Start and end times are required'}), 400
try:
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
event.updated_at = now_in_app_timezone()
if not safe_commit():
return jsonify({'error': 'Failed to move event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event moved successfully')
})
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error moving event: {str(e)}'}), 500
@calendar_bp.route('/api/calendar/events/<int:event_id>/resize', methods=['POST'])
@login_required
def resize_event(event_id):
"""Resize an event (change duration)"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
return jsonify({'error': 'Permission denied'}), 403
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
try:
if 'end' in data:
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
elif 'start' in data:
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
event.updated_at = now_in_app_timezone()
if not safe_commit():
return jsonify({'error': 'Failed to resize event'}), 500
return jsonify({
'success': True,
'event': event.to_dict(),
'message': _('Event resized successfully')
})
except (ValueError, AttributeError) as e:
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': f'Error resizing event: {str(e)}'}), 500
@calendar_bp.route('/calendar/event/<int:event_id>')
@login_required
def view_event(event_id):
"""View event details page"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to view this event
if event.user_id != current_user.id and not current_user.is_admin:
flash(_('You do not have permission to view this event.'), 'error')
return redirect(url_for('calendar.view_calendar'))
return render_template('calendar/event_detail.html', event=event)
@calendar_bp.route('/calendar/event/new')
@login_required
def new_event():
"""Create new event form"""
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
tasks = Task.query.filter_by(assigned_to=current_user.id, status='in_progress').order_by(Task.name).all()
# Get date from query params if provided
date_str = request.args.get('date')
time_str = request.args.get('time')
initial_date = None
initial_time = None
if date_str:
try:
initial_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
pass
if time_str:
try:
initial_time = datetime.strptime(time_str, '%H:%M').time()
except ValueError:
pass
return render_template(
'calendar/event_form.html',
projects=projects,
clients=clients,
tasks=tasks,
initial_date=initial_date,
initial_time=initial_time
)
@calendar_bp.route('/calendar/event/<int:event_id>/edit')
@login_required
def edit_event(event_id):
"""Edit event form"""
event = CalendarEvent.query.get_or_404(event_id)
# Check if user has permission to edit this event
if event.user_id != current_user.id and not current_user.is_admin:
flash(_('You do not have permission to edit this event.'), 'error')
return redirect(url_for('calendar.view_calendar'))
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
tasks = Task.query.filter_by(assigned_to=current_user.id).order_by(Task.name).all()
return render_template(
'calendar/event_form.html',
event=event,
projects=projects,
clients=clients,
tasks=tasks,
edit_mode=True
)